diff --git a/.deepsource.toml b/.deepsource.toml index 035ff648..bd47e953 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -1,10 +1,17 @@ version = 1 -test_patterns = ["*.test.js"] +test_patterns = ["test/**/*.test.ts", "test/**/*.spec.ts"] + +exclude_patterns = [ + "src/engine/**", + "src/private/**", + "dist/**", + "coverage/**" +] [[analyzers]] name = "javascript" enabled = true [analyzers.meta] - plugins = ["react"] \ No newline at end of file + environment = ["browser", "nodejs"] diff --git a/.eslintrc b/.eslintrc deleted file mode 100644 index 4814e41f..00000000 --- a/.eslintrc +++ /dev/null @@ -1,20 +0,0 @@ -{ - "root": true, - "env": { - "node": true, - "es6": true - }, - "parserOptions": { - "ecmaVersion": 2020, // allow `const`, `let`, async/await, etc. - "sourceType": "module" // webpack config is CommonJS; use 'module' if you're using ESM - }, - "ignorePatterns": [ - "src/engine/" - ], - "plugins": [ - "import" - ], - "rules": { - "import/no-unresolved": "error" - } -} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4ca9b4e3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,184 @@ +# Force the following filetypes to have unix eols, so Windows does not break them +*.* text eol=lf +## GITATTRIBUTES FOR WEB PROJECTS +# +# These settings are for any web project. +# Details per file setting: +# text These files should be normalized (i.e. convert CRLF to LF). +# binary These files are binary and should be left untouched. +# Note that binary is a macro for -text -diff. +###################################################################### +# Auto detect +## Handle line endings automatically for files detected as +## text and leave all files detected as binary untouched. +## This will handle all files NOT defined below. +* text=auto +# Source code +*.bash text eol=lf +*.bat text eol=crlf +*.cmd text eol=crlf +*.coffee text +*.css text +*.htm text diff=html +*.html text diff=html +*.inc text +*.ini text +*.js text +*.json text +*.jsx text +*.less text +*.ls text +*.map text -diff +*.od text +*.onlydata text +*.php text diff=php +*.pl text +*.ps1 text eol=crlf +*.py text diff=python +*.rb text diff=ruby +*.sass text +*.scm text +*.scss text diff=css +*.sh text eol=lf +*.sql text +*.styl text +*.tag text +*.ts text +*.tsx text +*.xml text +*.xhtml text diff=html +# Docker +Dockerfile text +# Documentation +*.ipynb text +*.markdown text +*.md text +*.mdwn text +*.mdown text +*.mkd text +*.mkdn text +*.mdtxt text +*.mdtext text +*.txt text +AUTHORS text +CHANGELOG text +CHANGES text +CONTRIBUTING text +COPYING text +copyright text +*COPYRIGHT* text +INSTALL text +license text +LICENSE text +NEWS text +readme text +*README* text +TODO text +# Templates +*.dot text +*.ejs text +*.haml text +*.handlebars text +*.hbs text +*.hbt text +*.jade text +*.latte text +*.mustache text +*.njk text +*.phtml text +*.tmpl text +*.tpl text +*.twig text +*.vue text +# Configs +*.cnf text +*.conf text +*.config text +.editorconfig text +.env text +.gitattributes text +.gitconfig text +.htaccess text +*.lock text -diff +pnpm-lock.yaml text -diff +*.toml text +*.yaml text +*.yml text +browserslist text +Makefile text +makefile text +# Heroku +Procfile text +# Graphics +*.ai binary +*.bmp binary +*.eps binary +*.gif binary +*.gifv binary +*.ico binary +*.jng binary +*.jp2 binary +*.jpg binary +*.jpeg binary +*.jpx binary +*.jxr binary +*.pdf binary +*.png binary +*.psb binary +*.psd binary +# SVG treated as an asset (binary) by default. +*.svg text +# If you want to treat it as binary, +# use the following line instead. +# *.svg binary +*.svgz binary +*.tif binary +*.tiff binary +*.wbmp binary +*.webp binary +# Audio +*.kar binary +*.m4a binary +*.mid binary +*.midi binary +*.mp3 binary +*.ogg binary +*.ra binary +# Video +*.3gpp binary +*.3gp binary +*.as binary +*.asf binary +*.asx binary +*.fla binary +*.flv binary +*.m4v binary +*.mng binary +*.mov binary +*.mp4 binary +*.mpeg binary +*.mpg binary +*.ogv binary +*.swc binary +*.swf binary +*.webm binary +# Archives +*.7z binary +*.gz binary +*.jar binary +*.rar binary +*.tar binary +*.zip binary +# Fonts +*.ttf binary +*.eot binary +*.otf binary +*.woff binary +*.woff2 binary +# Executables +*.exe binary +*.pyc binary +# RC files (like .babelrc or .eslintrc) +*.*rc text +# Ignore files (like .npmignore or .gitignore) +*.*ignore text diff --git a/.github/actions/setup-node-project/action.yml b/.github/actions/setup-node-project/action.yml index 08e8a7b3..2a76e16a 100644 --- a/.github/actions/setup-node-project/action.yml +++ b/.github/actions/setup-node-project/action.yml @@ -1,21 +1,30 @@ name: 'Setup Node Project' -description: 'Setup Node.js, cache dependencies, and install (call after checkout)' +description: 'Setup pnpm, Node.js, cache dependencies, and install (call after checkout)' inputs: node-version: description: 'Node.js version to use' required: false - default: '22' + default: '24' runs: using: 'composite' steps: + # pnpm must be on PATH before setup-node, otherwise `cache: pnpm` cannot + # resolve the store directory. Version comes from `packageManager` in + # package.json, so it stays in lockstep with local development. + - name: Setup pnpm + uses: pnpm/action-setup@v4 + - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: ${{ inputs.node-version }} - cache: 'npm' + cache: 'pnpm' + # --ignore-scripts is belt-and-braces: pnpm already gates postinstall + # scripts behind the onlyBuiltDependencies allowlist in package.json. + # Nothing in CI needs the wrangler/sharp native binaries. - name: Install Dependencies shell: bash - run: npm ci --ignore-scripts + run: pnpm install --frozen-lockfile --ignore-scripts diff --git a/.github/workflows/build-pipeline.yml b/.github/workflows/build-pipeline.yml index fac9dec3..4f0eb364 100644 --- a/.github/workflows/build-pipeline.yml +++ b/.github/workflows/build-pipeline.yml @@ -11,6 +11,7 @@ on: - '**.md' - 'docs/**' - 'LICENSE' + - 'src/private' pull_request: branches: - main @@ -19,6 +20,7 @@ on: - '**.md' - 'docs/**' - 'LICENSE' + - 'src/private' permissions: contents: read @@ -41,15 +43,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - name: Setup Node Project uses: ./.github/actions/setup-node-project - - name: Run ESLint - run: npm run lint + - name: Run Biome + run: pnpm run lint - name: YAML Lint uses: ibiqlik/action-yamllint@v3.1.1 @@ -61,22 +63,22 @@ jobs: - name: Lint Summary run: | echo "### Lint Results" >> $GITHUB_STEP_SUMMARY - echo "- ESLint: Passed" >> $GITHUB_STEP_SUMMARY + echo "- Biome (lint + format): Passed" >> $GITHUB_STEP_SUMMARY type-check: name: Type Check runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - name: Setup Node Project uses: ./.github/actions/setup-node-project - name: TypeScript Type Check - run: npm run type-check + run: pnpm run typecheck - name: Type Check Summary run: | @@ -88,9 +90,9 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - name: Setup Node Project uses: ./.github/actions/setup-node-project @@ -101,7 +103,7 @@ jobs: timezoneLinux: 'America/New_York' - name: Run Tests with Coverage - run: npm test -- --coverage --coverage.reporter=text --coverage.reporter=lcov + run: pnpm test -- --coverage --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=json-summary --coverage.reporter=json - name: Upload Coverage Artifact @@ -140,19 +142,19 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - name: Setup Node Project uses: ./.github/actions/setup-node-project - name: Install Playwright Browsers - run: npx playwright install --with-deps chromium + run: pnpm exec playwright install --with-deps chromium - name: Run E2E Tests id: e2e - run: npm run test:e2e + run: pnpm run test:e2e - name: Upload Playwright Report if: always() @@ -176,22 +178,22 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - name: Setup Node Project uses: ./.github/actions/setup-node-project - - name: NPM Audit (Critical Only) - run: npm audit --audit-level=critical + - name: Audit (Critical Only) + run: pnpm audit --audit-level=critical continue-on-error: false - - name: NPM Audit Report (All Levels) + - name: Audit Report (All Levels) run: | echo "### Security Audit" >> $GITHUB_STEP_SUMMARY echo '```' >> $GITHUB_STEP_SUMMARY - npm audit --json | jq -r ' + pnpm audit --json | jq -r ' "Vulnerabilities: " + (.metadata.vulnerabilities | to_entries | map("\(.key): \(.value)") | join(", ")) ' >> $GITHUB_STEP_SUMMARY 2>/dev/null || echo "No vulnerabilities found" >> $GITHUB_STEP_SUMMARY @@ -208,15 +210,21 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - name: Setup Node Project uses: ./.github/actions/setup-node-project + - name: Verify src/private is absent (OSS build) + run: | + if [ -n "$(ls -A src/private 2>/dev/null)" ]; then + echo "ERROR: OSS build sees src/private contents"; exit 1 + fi + - name: Build Application - run: npm run build + run: pnpm run build - name: Upload Build Artifact uses: actions/upload-artifact@v4 @@ -241,7 +249,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 @@ -260,7 +268,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Initialize CodeQL uses: github/codeql-action/init@v4 @@ -286,7 +294,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Dependency Review uses: actions/dependency-review-action@v4 diff --git a/.github/workflows/deploy-pipeline.yml b/.github/workflows/deploy-pipeline.yml index e5bca30a..df9dec78 100644 --- a/.github/workflows/deploy-pipeline.yml +++ b/.github/workflows/deploy-pipeline.yml @@ -38,21 +38,26 @@ jobs: artifact-name: ${{ steps.artifact.outputs.name }} steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: - submodules: recursive + submodules: false - - name: Setup Node.js - uses: actions/setup-node@v4 + # Private submodule (src/private): read-only deploy key on thkruz/signal-range-private. + # A deploy key cannot fetch the public repo, so checkout stays on the token and only the + # submodule step uses SSH. + - name: Set up SSH for private submodule + uses: webfactory/ssh-agent@v0.9.0 with: - node-version: '20' - cache: 'npm' + ssh-private-key: ${{ secrets.PRIVATE_SUBMODULE_DEPLOY_KEY }} - - name: Install Dependencies - run: npm ci --ignore-scripts + - name: Update submodules + run: git submodule update --init --recursive + + - name: Setup Node Project + uses: ./.github/actions/setup-node-project - name: Build Application - run: npm run build + run: pnpm run build - name: Upload Build Artifact uses: actions/upload-artifact@v4 @@ -74,7 +79,7 @@ jobs: url: https://app.signalrange.space steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: Download Build Artifact uses: actions/download-artifact@v4 @@ -207,22 +212,27 @@ jobs: artifact-name: ${{ steps.artifact.outputs.name }} steps: - name: Checkout PR Branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: ${{ needs.verify-pr-checks.outputs.pr-sha }} - submodules: recursive + submodules: false - - name: Setup Node.js - uses: actions/setup-node@v4 + # Private submodule (src/private): read-only deploy key on thkruz/signal-range-private. + # A deploy key cannot fetch the public repo, so checkout stays on the token and only the + # submodule step uses SSH. + - name: Set up SSH for private submodule + uses: webfactory/ssh-agent@v0.9.0 with: - node-version: '20' - cache: 'npm' + ssh-private-key: ${{ secrets.PRIVATE_SUBMODULE_DEPLOY_KEY }} + + - name: Update submodules + run: git submodule update --init --recursive - - name: Install Dependencies - run: npm ci --ignore-scripts + - name: Setup Node Project + uses: ./.github/actions/setup-node-project - name: Build Application - run: npm run build + run: pnpm run build - name: Upload Build Artifact uses: actions/upload-artifact@v4 @@ -247,7 +257,7 @@ jobs: url: https://uat.signalrange.space steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: ref: dev diff --git a/.github/workflows/e2e-attestation.yml b/.github/workflows/e2e-attestation.yml index e03b33eb..3e560ae6 100644 --- a/.github/workflows/e2e-attestation.yml +++ b/.github/workflows/e2e-attestation.yml @@ -29,7 +29,7 @@ jobs: 'E2E attestation required: Please check the box in the PR description ' + 'confirming you have run E2E tests locally and they pass.\n\n' + 'Add this to your PR description:\n' + - '- [x] E2E tests pass locally (`npm run test:e2e`)' + '- [x] E2E tests pass locally (`pnpm run test:e2e`)' ); return; } diff --git a/.gitignore b/.gitignore index ad146ac8..ffd7c306 100644 --- a/.gitignore +++ b/.gitignore @@ -43,7 +43,6 @@ supabase/ # Notes *.notes.* -notes .archive # Unused Icons @@ -61,4 +60,14 @@ public/**/wip/ # Keep directory structure with .gitkeep files !public/assets/campaigns/**/.gitkeep -!public/assets/campaigns/**/README.md \ No newline at end of file +!public/assets/campaigns/**/README.md +# Generated by scripts/vo-manifest.mjs +*-vo-manifest.json +*-vo-script-*.md + +# External plugins: nested git clones restored from external-plugins.json via +# `pnpm run plugin -- restore`. The clones are never committed; the lockfile and +# the committed src/plugins/plugin-manifest.external.generated.ts capture the set. +/src/plugins-external/* +!/src/plugins-external/.gitkeep +!/src/plugins-external/README.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..bd0b4ab8 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/private"] + path = src/private + url = git@github.com:thkruz/signal-range-private.git diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 00000000..17420ed1 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,47 @@ +# Validate commit subjects so the history stays readable and machine-parseable. +# +# Rules enforced (see CLAUDE.md and the Copilot instructions in .vscode/settings.json): +# [()][!]: [::] +# Only the type and the message are required; the scope and the emoji are both +# optional, and any emoji is accepted (no type/emoji pairing check). +# Merge/Revert/fixup!/squash! subjects are exempt. +# Bypass in an emergency with: git commit --no-verify + +msg_file="$1" + +# First non-comment, non-blank line = the subject. +subject=$(grep -vE '^\s*#' "$msg_file" | grep -vE '^\s*$' | head -n1) + +fail() { + echo "commit-msg: $1" >&2 + echo " Subject: \"$subject\"" >&2 + echo " Expected: type[(scope)]: [:emoji:] message (scope and emoji optional)" >&2 + echo " types: feat fix refactor test chore docs style perf i18n build ci revert" >&2 + echo " Examples: feat(signal-hunter): :sparkles: add Campaign 5 scenario 2" >&2 + echo " fix: stop the timeline resetting on pause" >&2 + exit 1 +} + +# The classic offender is a PowerShell here-string leak (`@'...'@`) where the +# `@` delimiters bleed into `git commit -m`, leaving the subject as a bare `@`. +case "$subject" in + '@'*) + echo "commit-msg: subject line starts with '@': \"$subject\"" >&2 + echo " This is almost always a PowerShell here-string (@'...'@) leak." >&2 + fail "fix the message so the first line is the real conventional-commit summary." + ;; + '') + fail "empty subject line." + ;; + 'Merge '*|'Revert '*|'fixup! '*|'squash! '*) + # Git-generated / rework subjects are exempt from the conventional format. + exit 0 + ;; +esac + +# Shape: type[(scope)][!]: [:emoji:] message; scope and emoji are both optional. +if ! echo "$subject" | grep -qE '^(feat|fix|refactor|test|chore|docs|style|perf|i18n|build|ci|revert)(\([A-Za-z0-9][A-Za-z0-9 .,/_-]*\))?!?: (:[a-z0-9_+-]+: )?[^ ].*'; then + fail "subject does not match the conventional-commit format." +fi + +exit 0 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 00000000..0d6f7a4b --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,35 @@ +# Send all normal output to /dev/null +exec 1>/dev/null + +HUSKY_OUTPUT_FOOTER="Signal Range uses pre-commit hooks to run our linting and style checks. \ +We do this to avoid additional hassle during pull request reviews, \ +because all PRs must pass these checks. If you're doing something \ +temporary and want to skip these checks, you can disable this hook \ +with: git commit --no-verify For more details, see https://typicode.github.io/husky/guide.html#bypass-hooks" + +# This function is called when there is an error +onError() { + echo "$1" >&2 + echo "$HUSKY_OUTPUT_FOOTER" >&2 + exit 1 +} + +# Run a check silently, surfacing its full output only on failure. +runCheck() { + label="$1" + shift + output=$("$@" 2>&1) || { + echo "$output" >&2 + onError "$label failed. Please fix the errors and try again." + } +} + +# This function is the entry point for the pre-commit hook. +main() { + runCheck "Linting" pnpm run lint + runCheck "Type checking" pnpm run typecheck + + exit 0 +} + +main diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100644 index 00000000..9433b271 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,23 @@ +# Full local gate before anything reaches GitHub: lint + typecheck are seconds, +# the unit-test suite is the expensive part but far cheaper than a CI round-trip +# and a fixup commit. Bypass in an emergency with: git push --no-verify +# +# No git-lfs preamble here (unlike keeptrack-space): this repo does not use LFS. + +onError() { + echo "" >&2 + echo "pre-push: $1 Push aborted." >&2 + echo "Fix the errors above (or bypass once with: git push --no-verify)." >&2 + exit 1 +} + +echo "pre-push: running lint..." >&2 +pnpm run lint || onError "linting failed." + +echo "pre-push: running typecheck..." >&2 +pnpm run typecheck || onError "type checking failed." + +echo "pre-push: running unit tests (this takes about half a minute)..." >&2 +pnpm run test || onError "unit tests failed." + +exit 0 diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..24a3f172 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,9 @@ +{ + "recommendations": [ + "biomejs.biome" + ], + "unwantedRecommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index f428fcb8..ef178862 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,16 +1,17 @@ { + "[javascript]": { "editor.defaultFormatter": "biomejs.biome" }, + "[typescript]": { "editor.defaultFormatter": "biomejs.biome" }, + "[jsonc]": { "editor.defaultFormatter": "biomejs.biome" }, "editor.codeActionsOnSave": { - "source.fixAll.eslint": "always", - "source.organizeImports": "always", - "source.addMissingImports": "explicit", - "source.removeUnusedImports": "explicit" + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit", + "source.addMissingImports": "explicit" }, + // Biome's formatter writes LF; match it so Windows checkouts stop producing + // CRLF diffs. Paired with .gitattributes in Phase 5. + "files.eol": "\n", // TypeScript: give the TS server more headroom "typescript.tsserver.maxTsServerMemory": 4096, - // Optional: if ESLint is heavy in your repos - "eslint.execArgv": [ - "--max_old_space_size=4096" - ], // File watching: keep noise down for big projects "files.watcherExclude": { "**/.git/objects/**": true, diff --git a/CLAUDE.md b/CLAUDE.md index 3e59d45b..40c50122 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,18 @@ - Branded types (`Hertz`, `dB`, `Degrees`) require explicit casting - check type definitions early - State handlers typed as `(state: Partial) => void`, never `Function | null` - Don't use bracket notation (`obj['method']()`) to access methods - make them public instead +- Always use LF line endings, never CRLF (enforced by `.gitattributes` and Biome's `lineEnding: lf`) +- **Every regex MUST have the `u` flag** (enforced by the custom Biome plugin `biome-plugins/require-unicode-regexp.grit`; the `v` flag is also accepted). This applies everywhere: source code, tests, and e2e specs. It currently reports as a Biome **warning**, so it does not fail the build; the mandate still stands and it is slated to be ratcheted to an error once the existing violations are cleared. + + ```typescript + // BAD + /[a-z]+/.test(str) + name.replace(/\s+/g, '-') + + // GOOD + /[a-z]+/u.test(str) + name.replace(/\s+/gu, '-') + ``` ## CSS/Tabler @@ -153,21 +165,38 @@ new Satellite( ## TypeScript Type Checking -**Always use the npm script to check for TypeScript errors:** +**Always use the pnpm script to check for TypeScript errors:** ```bash -npm run type-check +pnpm run typecheck ``` **Do NOT run tsc directly on individual files:** ```bash # WRONG - will fail with module resolution errors -npx tsc --noEmit src/campaigns/nats/scenario5.ts +pnpm exec tsc --noEmit src/campaigns/nats/scenario5.ts ``` This project uses `@app/*` path aliases (e.g., `@app/types`, `@app/equipment/...`) that require the full tsconfig.json configuration. Running tsc on individual files bypasses this and produces false "Cannot find module" errors. +## Build-Time Constants (DefinePlugin) + +- rspack `DefinePlugin` in `rspack.config.mts` injects compile-time constants — prefer this over generated files or runtime lookups for any value known at build time +- Existing constants: `__APP_VERSION__` (from `package.json`), `__GIT_COMMIT_SHA__`, `__IS_PRIVATE__`, `__AUTHORING__`, plus the `process.env.PUBLIC_*` values +- Always use `JSON.stringify()` when adding values — `DefinePlugin` does textual replacement, so strings must be wrapped as JS string literals +- To add a new constant, all three must be updated or it breaks in one environment: + 1. `DefinePlugin` in `rspack.config.mts` (the bundle) + 2. `define` in `vitest.config.mts` (the tests) + 3. the ambient declaration in `src/declaration.d.ts` (the type checker) + +## Linting and Formatting + +- Biome (`biome.json`) is both the linter and the formatter: `pnpm run lint` checks, `pnpm run lint:fix` applies safe fixes, `pnpm run format` formats +- Only **errors** fail the gate; warnings are a backlog being cleared area by area +- `src/engine/**` (vendored) and `src/private/**` (submodule) are excluded from linting +- Biome does not type-check. The unused-code gate is `noUnusedLocals`/`noUnusedParameters` in tsconfig, via `pnpm run typecheck` + ## Git Commits - Do NOT add `Co-Authored-By` lines to commit messages @@ -184,13 +213,32 @@ This project uses `@app/*` path aliases (e.g., `@app/types`, `@app/equipment/... When you use Plan Mode or create multi-step plans in this repo: -- Store each plan as a Markdown file under `./plans/` in this project. +- Store each plan as a Markdown file under `src/private/plans/` (the private submodule) in this project. - Filename convention: `phase---plan.md` - Example: `phase-1-auth-refactor-plan.md` - After completing a phase: - - Write a brief retrospective to `./retrospectives/` in this project. + - Write a brief retrospective to `src/private/retrospectives/` (the private submodule) in this project. - Filename convention: `phase---retro.md` - Include sections: `What worked`, `What didn’t`, `What to change next time`. - Never write plans or retrospectives into the home directory; always use project-relative paths. + +## Private Content (`src/private` submodule) + +`src/private` is the private `thkruz/signal-range-private` submodule. It holds plans, retros, +sprint docs, the NATS/BOA world bible and working notes. Nothing in the OSS build reads it, and +contributors without access build and test without it. + +- **New plans, retros, sprint notes, content bibles and internal docs are private by default.** + Put them under `src/private/`, not in `docs/`, unless Ted says they are there for OSS users. + `docs/` is for contributor-facing material only (scenario guide, NICE guide, platform guide, schema). +- **Two-commit rule.** When committing "all changes", first run `git -C src/private status`. If it + is dirty, commit inside the submodule (conventional commits, same as here) and push it, then bump + the pointer in this repo with `chore(private): :wrench: update subproject commit reference`. + Never commit a pointer to an unpushed submodule commit. Push with + `git push --recurse-submodules=on-demand`. +- The parent `.gitignore` does not reach inside the submodule; add ignore rules to + `src/private/.gitignore`. +- `.gitmodules` uses the SSH URL (CI deploy key). Local checkouts point origin at HTTPS: + `git -C src/private remote set-url origin https://github.com/thkruz/signal-range-private.git`. diff --git a/CODE_PATTERNS.md b/CODE_PATTERNS.md index e664e47b..654caaea 100644 --- a/CODE_PATTERNS.md +++ b/CODE_PATTERNS.md @@ -11,6 +11,7 @@ This document outlines the architectural patterns, naming conventions, and best - [Event-Driven Architecture](#event-driven-architecture) - [Module Composition](#module-composition) - [Testing Patterns](#testing-patterns) +- [Plugins and Open Registries](#plugins-and-open-registries) --- @@ -425,7 +426,7 @@ describe('RFFrontEnd class', () => { let parentElement: HTMLElement; beforeEach(() => { - jest.resetModules(); + vi.resetModules(); document.body.innerHTML = '
'; parentElement = document.getElementById('test-root')!; @@ -435,7 +436,7 @@ describe('RFFrontEnd class', () => { }); afterEach(() => { - jest.clearAllMocks(); + vi.clearAllMocks(); document.body.innerHTML = ''; }); @@ -513,7 +514,7 @@ it('works', () => {}); 1. **Arrange-Act-Assert**: Structure tests in three clear sections 2. **One assertion per concept**: Each test should verify one behavior -3. **Use spies for side effects**: Verify method calls with jest.spyOn() +3. **Use spies for side effects**: Verify method calls with vi.spyOn() 4. **Clean up**: Always restore mocks and clear DOM in afterEach 5. **Test edge cases**: Include boundary conditions and error states 6. **Use toBeCloseTo()**: For floating-point comparisons @@ -533,6 +534,72 @@ expect(rfFrontEnd.state.lnb.noiseTemperature).toBeCloseTo(expectedTemp, 1); --- +## Plugins and Open Registries + +Phase 17 introduced a plugin system modelled on keeptrack-space. The patterns +it relies on apply to any future extension point. + +### Registries over enums + +Equipment identity that a plugin may extend is an open registry seeded from the +built-in table, not a closed enum. The enum stays for built-ins (and their +autocomplete); the public id type widens to `Enum | (string & {})`: + +```typescript +// antenna-registry.ts +export type AntennaConfigId = ANTENNA_CONFIG_KEYS | (string & {}); + +// antenna-core.ts resolves through the registry, never the record directly +this.config = AntennaRegistry.getInstance().get(configId); +``` + +Rules a registry enforces: built-ins cannot be replaced, a plugin may replace +only its own ids (hot reload), and an unknown id throws with the list of what +would have matched. Campaign data is untouched by the widening. + +### Guarded private imports + +A descriptor in `plugin-manifest.ts` carries `privateImport` behind the +compile-time flag so the OSS bundle never resolves the path: + +```typescript +{ + id: 'Authoring', + privateImport: __IS_PRIVATE__ ? () => import('@private/index') : undefined, + privateClassName: 'AuthoringPlugin', + defaultConfig: { enabled: true }, + alwaysEnabled: true, +} +``` + +Private-edition features register through the same `PluginApi` as external +plugins. Do not add new `__IS_PRIVATE__` branches elsewhere; add a descriptor. + +### Boot order + +`App.initDom_()` starts `PluginManager.loadAll()` before `Router.init()`. The +router keeps handling routes synchronously when the manifest has nothing to +load (the OSS default) and holds scenario, sandbox, and unknown routes until +`PluginManager.ready` settles otherwise. Anything a scenario page needs from a +plugin must therefore be registered inside `register()`, not lazily. + +### Strings + +New user-facing strings go through `t7e()` (`src/locales/i18n.ts`) with the +key in `src/locales/en.json`. Plugins keep to `plugins..*`. Only English is +bundled; the API is what makes a second language a data change. + +### Sandbox equipment is data, scenarios are fixed + +`ScenarioManager`'s `scenario` setter passes sandboxes through +`SandboxLoadoutService.applyToScenario()`, which clones only the stations it +changes and returns the original settings object otherwise. Scored scenarios +are never touched. The `?antenna=` query override exists for e2e specs and +the plugin dev harness; the LOADOUT control in the command bar is the operator +path and restarts the sandbox from a cleared store. + +--- + ## Summary ### Key Takeaways diff --git a/DEPLOYMENT_CONFIGURATION.md b/DEPLOYMENT_CONFIGURATION.md index 6f387460..b6997650 100644 --- a/DEPLOYMENT_CONFIGURATION.md +++ b/DEPLOYMENT_CONFIGURATION.md @@ -42,7 +42,7 @@ CLOUDFLARE_WORKER_NAME=my-signal-range-app #### Option B: CLI Flag Override ```bash -npm run deploy -- --name my-signal-range-app +pnpm run deploy -- --name my-signal-range-app ``` #### Option C: Package.json Name (Fallback) @@ -136,7 +136,7 @@ routes = [] # Empty = use workers.dev ```json // package.json "scripts": { - "deploy": "npm run build && wrangler deploy --name ${CLOUDFLARE_WORKER_NAME:-signal-range}" + "deploy": "pnpm run build && wrangler deploy --name ${CLOUDFLARE_WORKER_NAME:-signal-range}" } ``` @@ -163,7 +163,7 @@ routes = [] # Empty = use workers.dev 3. **Documentation** - Clear setup guide in README - - Quick start: "Just run `npm run deploy`" + - Quick start: "Just run `pnpm run deploy`" - Advanced: Custom domain setup guide --- @@ -212,7 +212,7 @@ SignalRange/ ```bash git clone cd SignalRange - npm install + pnpm install ``` 2. **Set application environment variables** @@ -223,7 +223,7 @@ SignalRange/ 3. **Deploy (uses defaults)** ```bash - npm run deploy + pnpm run deploy # Deploys to: https://signal-range.YOUR_SUBDOMAIN.workers.dev ``` @@ -243,14 +243,14 @@ SignalRange/ 2. **Deploy with custom config** ```bash - npm run deploy + pnpm run deploy ``` ### One-Off Deployment (CLI Flags) ```bash # Deploy with custom name without editing files -npm run deploy -- --name my-temp-deployment +pnpm run deploy -- --name my-temp-deployment ``` --- @@ -315,7 +315,7 @@ execSync(deployCmd, { stdio: 'inherit' }); Update `package.json`: ```json -"deploy": "npm run build && node scripts/deploy.js" +"deploy": "pnpm run build && node scripts/deploy.js" ``` ### 3. Updated R2 Sync Script @@ -443,7 +443,7 @@ not_found_handling = "single-page-application" # 1. Clone and install git clone https://github.com/user/SignalRange.git cd SignalRange -npm install +pnpm install # 2. Copy environment template cp .env.production.example .env.production @@ -453,7 +453,7 @@ nano .env.production # Add: PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY # 4. Deploy (uses all defaults) -npm run deploy +pnpm run deploy # Output: # ✨ Deployed to: https://signal-range.username.workers.dev @@ -467,10 +467,10 @@ npm run deploy ```bash # Option 1: Environment variable echo "CLOUDFLARE_WORKER_NAME=my-app" >> .env.production -npm run deploy +pnpm run deploy # Option 2: CLI flag (one-time) -npm run deploy -- --name my-app +pnpm run deploy -- --name my-app ``` ### Scenario: User Has Custom Domain @@ -479,7 +479,7 @@ npm run deploy -- --name my-app # 1. Set up domain in Cloudflare Dashboard # 2. Add route to .env.production echo "CLOUDFLARE_ROUTES=app.mydomain.com/*" >> .env.production -npm run deploy +pnpm run deploy ``` --- diff --git a/README.md b/README.md index 4a92ee26..3100551a 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,8 @@ Someone is jamming allied satellites. Find them using geolocation and advanced R ### Prerequisites -- **Node.js** 18.x or 20.x (LTS recommended) -- **npm** 9.x or later +- **Node.js** 24.x (see `.nvmrc`; [Volta](https://volta.sh) picks it up automatically) +- **pnpm** 10.x — `corepack enable` or `npm install -g pnpm@10` ### Quick Start @@ -66,18 +66,22 @@ git clone https://github.com/thkruz/SignalRange.git cd SignalRange # 2. Install dependencies -npm install +pnpm install # 3. Set up environment variables cp .env.example .env # 4. Pull campaign assets from R2 (audio/images) -npm run r2:pull +pnpm run r2:pull # 5. Start the development server -npm run dev +pnpm run dev ``` +> **Note:** `src/private` is an optional, private git submodule (plans, design docs and internal +> notes). Contributors do not need it and the build never reads it. Clone without +> `--recurse-submodules`, or ignore the "permission denied" from that one submodule if you do. + The app will be available at `http://localhost:3000` (or the port shown in terminal). ### Environment Variables @@ -99,10 +103,10 @@ Campaign assets (audio files, character images) are stored in Cloudflare R2 and ```bash # Download assets from R2 (no authentication required) -npm run r2:pull +pnpm run r2:pull # Preview what would be downloaded -npm run r2:pull:dry +pnpm run r2:pull:dry ``` Assets are downloaded to `public/assets/campaigns/` and `public/assets/characters/`. @@ -111,24 +115,44 @@ Assets are downloaded to `public/assets/campaigns/` and `public/assets/character | Command | Description | |---------|-------------| -| `npm run dev` | Start webpack dev server with hot reload | -| `npm run build` | Production build to `dist/` | -| `npm run preview` | Preview production build with Wrangler | -| `npm run r2:pull` | Download campaign assets from R2 | -| `npm run test` | Run Jest test suite | -| `npm run type-check` | TypeScript type checking | -| `npm run lint` | Run ESLint | +| `pnpm run dev` | Start the rspack dev server with hot reload | +| `pnpm run build` | Production build to `dist/` | +| `pnpm run preview` | Preview production build with Wrangler | +| `pnpm run r2:pull` | Download campaign assets from R2 | +| `pnpm test` | Run the Vitest suite | +| `pnpm run typecheck` | Type-check with tsgo (TypeScript 7 native) | +| `pnpm run typecheck:tsc` | Same check on stock tsc, as a fallback | +| `pnpm run lint` | Lint and format-check with Biome | +| `pnpm run lint:fix` | Apply Biome's safe fixes | +| `pnpm run format` | Format with Biome | ### Troubleshooting **Assets not loading?** -Run `npm run r2:pull` to download campaign audio and images. +Run `pnpm run r2:pull` to download campaign audio and images. **TypeScript errors?** -Run `npm run type-check` to see detailed type errors. +Run `pnpm run typecheck` to see detailed type errors. **Port already in use?** -The dev server defaults to port 3000. Check for other processes or modify `webpack.config.js`. +The dev server defaults to port 3000. Check for other processes or modify `rspack.config.mts`. + +## 🔌 Plugins + +Equipment and features can come from plugins: small git repositories that the +engine compiles into its own bundle. The toolchain mirrors keeptrack-space's. + +```bash +pnpm run plugin -- add https://github.com/thkruz/signal-range-plugin-example # install one +pnpm run plugin -- create my-dish # scaffold your own +pnpm run plugin -- dev signal-range-plugin-my-dish # boot with only it enabled +pnpm run plugin -- list # what is installed +``` + +Installed plugins are recorded in `external-plugins.json` and restored by +`pnpm run plugin -- restore` (the build runs this for you). Plugin antennas +appear in every sandbox's **LOADOUT** picker. See +[docs/plugin-development-guide.md](docs/plugin-development-guide.md). ## 🌐 Deployment @@ -143,10 +167,10 @@ SignalRange is deployed on Cloudflare Workers with static assets. There are two ```bash # Deploy to UAT (test changes first) -npx wrangler deploy --env uat +pnpm exec wrangler deploy --env uat # Deploy to Production (after UAT validation) -npx wrangler deploy --env production +pnpm exec wrangler deploy --env production ``` Always deploy to UAT first to validate changes before promoting to production. diff --git a/biome-plugins/require-unicode-regexp.grit b/biome-plugins/require-unicode-regexp.grit new file mode 100644 index 00000000..92284c8e --- /dev/null +++ b/biome-plugins/require-unicode-regexp.grit @@ -0,0 +1,16 @@ +// Custom Biome lint plugin: enforce the `u` (unicode) flag on every regex literal. +// +// Replaces ESLint's `require-unicode-regexp`, which Biome has no built-in +// equivalent for. The project mandates the `u` flag on all regexes (see +// CLAUDE.md). The `v` flag (unicodeSets) also enables unicode mode, so it is +// accepted too. +// +// Referenced from biome.json via the `plugins` array. +`/$pattern/$flags` as $regex where { + not $flags <: r".*[uv].*", + register_diagnostic( + span = $regex, + message = "Regex literal is missing the 'u' (unicode) flag (project mandate; 'v' also accepted).", + severity = "warn" + ) +} diff --git a/biome.json b/biome.json new file mode 100644 index 00000000..34745d85 --- /dev/null +++ b/biome.json @@ -0,0 +1,247 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.4/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": [ + "src/**/*.{js,ts,tsx}", + "test/**/*.{js,ts,tsx}", + "e2e/**/*.ts", + "build/**/*.ts", + "scripts/plugin/**/*.ts", + "!src/engine/**", + "!!src/private/**", + "!src/plugins-external/**", + "!**/*.min.*" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "indentStyle": "space", + "indentWidth": 2, + "lineEnding": "lf", + "lineWidth": 180, + "attributePosition": "auto", + "bracketSameLine": false, + "bracketSpacing": true, + "expand": "auto", + "useEditorconfig": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": false, + "complexity": { + "noAdjacentSpacesInRegex": "error", + "noArguments": "error", + "noCommaOperator": "warn", + "noDivRegex": "error", + "noExtraBooleanCast": "error", + "noUselessCatch": "error", + "noUselessEscapeInRegex": "error", + "noUselessLabel": "error", + "noUselessLoneBlockStatements": "error", + "noUselessRename": "error", + "noUselessStringConcat": "warn", + "noUselessTernary": "error", + "noUselessTypeConstraint": "error", + "noUselessUndefinedInitialization": "error", + "noVoid": "warn", + "useLiteralKeys": "warn", + "useMaxParams": { + "level": "warn", + "options": { + "max": 8 + } + }, + "useNumericLiterals": "error", + "useRegexLiterals": "error" + }, + "correctness": { + "noConstAssign": "error", + "noConstantCondition": "warn", + "noConstructorReturn": "error", + "noEmptyCharacterClassInRegex": "error", + "noEmptyPattern": "error", + "noGlobalObjectCalls": "error", + "noInnerDeclarations": "warn", + "noInvalidConstructorSuper": "error", + "noNonoctalDecimalEscape": "error", + "noSelfAssign": "error", + "noSetterReturn": "error", + "noSwitchDeclarations": "error", + "noUndeclaredVariables": "off", + "noUnreachable": "error", + "noUnreachableSuper": "error", + "noUnsafeFinally": "error", + "noUnsafeOptionalChaining": "error", + "noUnusedInstantiation": "warn", + "noUnusedLabels": "error", + "noUnusedVariables": "warn", + "useIsNan": "error", + "useValidForDirection": "error", + "useValidTypeof": "error", + "useYield": "error" + }, + "performance": { + "noAwaitInLoops": "warn" + }, + "security": { + "noGlobalEval": "error", + "noScriptUrl": "error" + }, + "style": { + "noCommonJs": "error", + "noExcessiveClassesPerFile": "warn", + "noMultilineString": "error", + "noNamespace": "error", + "noNestedTernary": "warn", + "noRestrictedGlobals": { + "level": "error", + "options": {} + }, + "noRestrictedImports": "error", + "noYodaExpression": "error", + "useArrayLiterals": "error", + "useAsConstAssertion": "error", + "useBlockStatements": "warn", + "useCollapsedElseIf": "error", + "useConsistentArrowReturn": "error", + "useConsistentBuiltinInstantiation": "warn", + "useConst": "error", + "useDefaultParameterLast": "error", + "useDefaultSwitchClause": "warn", + "useExponentiationOperator": "error", + "useGroupedAccessorPairs": "error", + "useObjectSpread": "error", + "useShorthandAssign": "error", + "useSpreadOverApply": "error", + "useSymbolDescription": "error", + "useTemplate": "warn" + }, + "suspicious": { + "noAlert": "warn", + "noAsyncPromiseExecutor": "error", + "noCatchAssign": "error", + "noClassAssign": "error", + "noCompareNegZero": "error", + "noConsole": "off", + "noControlCharactersInRegex": "error", + "noDebugger": "warn", + "noDoubleEquals": "warn", + "noDuplicateCase": "error", + "noDuplicateClassMembers": "error", + "noDuplicateElseIf": "error", + "noDuplicateEnumValues": "error", + "noDuplicateObjectKeys": "error", + "noDuplicateParameters": "error", + "noEmptyBlockStatements": "warn", + "noEqualsToNull": "warn", + "noExplicitAny": "warn", + "noExtraNonNullAssertion": "error", + "noFallthroughSwitchClause": "error", + "noFunctionAssign": "error", + "noGlobalAssign": "warn", + "noImportAssign": "error", + "noIrregularWhitespace": "error", + "noLabelVar": "error", + "noMisleadingCharacterClass": "error", + "noMisleadingInstantiator": "error", + "noNonNullAssertedOptionalChain": "error", + "noOctalEscape": "error", + "noProto": "error", + "noPrototypeBuiltins": "error", + "noRedeclare": "error", + "noReturnAssign": "warn", + "noSelfCompare": "error", + "noShadowRestrictedNames": "error", + "noSparseArray": "error", + "noTemplateCurlyInString": "error", + "noUnsafeDeclarationMerging": "warn", + "noUnsafeNegation": "error", + "noUnusedExpressions": "warn", + "noUselessRegexBackrefs": "error", + "noVar": "warn", + "noWith": "error", + "useAwait": "warn", + "useDefaultSwitchClauseLast": "error", + "useGetterReturn": "error", + "useGuardForIn": "warn", + "useIterableCallbackReturn": { + "level": "warn", + "options": {} + }, + "useNamespaceKeyword": "error" + } + } + }, + "javascript": { + "formatter": { + "jsxQuoteStyle": "double", + "quoteProperties": "asNeeded", + "trailingCommas": "es5", + "semicolons": "always", + "arrowParentheses": "always", + "bracketSameLine": false, + "quoteStyle": "single", + "attributePosition": "auto", + "bracketSpacing": true + }, + "globals": [ + "global", + "process", + "__dirname" + ] + }, + "overrides": [ + { + "includes": [ + "**/__tests__/**", + "**/*.test.{js,ts,tsx}", + "**/*.spec.{js,ts,tsx}", + "**/test/**" + ], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + }, + "correctness": { + "noUnusedVariables": "off" + }, + "complexity": { + "useMaxParams": "off", + "useLiteralKeys": "off" + } + } + } + }, + { + "includes": [ + "**/*.d.ts" + ], + "linter": { + "rules": { + "suspicious": { + "noExplicitAny": "off" + } + } + } + } + ], + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + }, + "plugins": [ + "./biome-plugins/require-unicode-regexp.grit" + ] +} diff --git a/deploy.md b/deploy.md index b7934195..3bde1642 100644 --- a/deploy.md +++ b/deploy.md @@ -23,8 +23,8 @@ This guide covers deploying SignalRange to Cloudflare Workers with R2 asset stor - **npm** (comes with Node.js) - **Wrangler CLI** (Cloudflare Workers CLI) ```bash - npm install -g wrangler - # Or use npx: npx wrangler + pnpm add -g wrangler + # Or use pnpm exec: pnpm exec wrangler ``` ### Cloudflare Account Setup @@ -125,7 +125,7 @@ PUBLIC_USER_API_URL=https://user.keeptrack.space PUBLIC_ASSETS_BASE_URL=https://assets.signalrange.space ``` -**Note**: Environment variables are injected at build time via webpack. Make sure `.env.production` is in your `.gitignore`. +**Note**: Environment variables are injected at build time via rspack. Make sure `.env.production` is in your `.gitignore`. ### Getting Supabase Credentials @@ -145,7 +145,7 @@ PUBLIC_ASSETS_BASE_URL=https://assets.signalrange.space For local testing: ```bash -npm run build +pnpm run build ``` This creates a production build in the `dist/` directory without deploying. @@ -156,7 +156,7 @@ The build process is automatically handled by deployment scripts, but you can bu ```bash # Build for production -npm run build +pnpm run build # Verify build output ls -la dist/ @@ -182,13 +182,13 @@ Large audio and image files are stored in Cloudflare R2, not in the Worker bundl ```bash # Preview what would be uploaded (dry run) -npm run r2:sync:dry +pnpm run r2:sync:dry # Upload new/changed files -npm run r2:sync +pnpm run r2:sync # Upload with verbose logging -npm run r2:sync -- --verbose +pnpm run r2:sync -- --verbose ``` **What Gets Synced**: @@ -232,12 +232,12 @@ PUBLIC_ASSETS_BASE_URL=https://pub-xxxxx.r2.dev Deploy everything in one command: ```bash -npm run deploy:full +pnpm run deploy:full ``` This command: -1. Builds the application (`npm run build`) -2. Syncs R2 assets (`npm run r2:sync`) +1. Builds the application (`pnpm run build`) +2. Syncs R2 assets (`pnpm run r2:sync`) 3. Deploys to Cloudflare Workers (`wrangler deploy`) ### Step-by-Step Deploy @@ -246,13 +246,13 @@ For more control, deploy in steps: ```bash # 1. Build the application -npm run build +pnpm run build # 2. Sync R2 assets (if you have new/changed assets) -npm run r2:sync +pnpm run r2:sync # 3. Deploy to Cloudflare Workers -npm run deploy +pnpm run deploy ``` ### Dry Run Deployment @@ -260,7 +260,7 @@ npm run deploy Test deployment without actually deploying: ```bash -npm run deploy:dry-run +pnpm run deploy:dry-run ``` This builds and validates the deployment without pushing to Cloudflare. @@ -270,7 +270,7 @@ This builds and validates the deployment without pushing to Cloudflare. Test your deployment locally with Wrangler: ```bash -npm run preview +pnpm run preview ``` This runs a local Cloudflare Workers environment that mimics production. @@ -297,7 +297,7 @@ This runs a local Cloudflare Workers environment that mimics production. 3. **Deploy**: ```bash - npm run deploy:full + pnpm run deploy:full ``` 4. **Verify DNS**: @@ -315,7 +315,7 @@ If you don't have a custom domain, Cloudflare provides a free subdomain: 1. **Deploy without routes**: - Remove or comment out `routes` in `wrangler.jsonc` - - Deploy: `npm run deploy` + - Deploy: `pnpm run deploy` - Your app will be available at: `signal-range.your-subdomain.workers.dev` 2. **Update Supabase Allowed Domains**: @@ -331,7 +331,7 @@ If you don't have a custom domain, Cloudflare provides a free subdomain: **Problem**: Build fails with TypeScript errors ```bash # Solution: Run type checking first -npm run type-check +pnpm run typecheck # Fix any TypeScript errors before building ``` @@ -339,8 +339,8 @@ npm run type-check **Problem**: Webpack build fails ```bash # Solution: Clean and rebuild -npm run clean -npm run build +pnpm run clean +pnpm run build ``` ### Deployment Errors @@ -348,9 +348,9 @@ npm run build **Problem**: `wrangler: command not found` ```bash # Solution: Install wrangler globally or use npx -npm install -g wrangler +pnpm add -g wrangler # Or -npx wrangler deploy +pnpm exec wrangler deploy ``` **Problem**: Authentication errors @@ -372,8 +372,8 @@ wrangler r2 bucket create signal-range-assets **Problem**: Files not uploading ```bash # Solution: Check file paths and permissions -npm run r2:sync:dry # Preview what would be uploaded -npm run r2:sync -- --verbose # See detailed output +pnpm run r2:sync:dry # Preview what would be uploaded +pnpm run r2:sync -- --verbose # See detailed output ``` **Problem**: Wrong files being uploaded @@ -409,7 +409,7 @@ npm run r2:sync -- --verbose # See detailed output - Check R2 bucket region (should be close to users) **Problem**: Large bundle size -- Check webpack bundle analyzer +- Check the rspack bundle analyzer - Ensure large assets are in R2, not in bundle - Verify tree-shaking is working @@ -422,16 +422,16 @@ Before deploying to production: - [ ] All environment variables set in `.env.production` - [ ] R2 buckets created and configured - [ ] R2 custom domain configured (if using) -- [ ] Assets synced to R2 (`npm run r2:sync`) -- [ ] Build succeeds without errors (`npm run build`) -- [ ] Type checking passes (`npm run type-check`) -- [ ] Linting passes (`npm run lint`) -- [ ] Tests pass (`npm test`) +- [ ] Assets synced to R2 (`pnpm run r2:sync`) +- [ ] Build succeeds without errors (`pnpm run build`) +- [ ] Type checking passes (`pnpm run typecheck`) +- [ ] Linting passes (`pnpm run lint`) +- [ ] Tests pass (`pnpm test`) - [ ] Wrangler authenticated (`wrangler login`) - [ ] Routes configured in `wrangler.jsonc` - [ ] Domain DNS configured (if using custom domain) - [ ] Supabase allowed domains updated -- [ ] Dry run successful (`npm run deploy:dry-run`) +- [ ] Dry run successful (`pnpm run deploy:dry-run`) --- @@ -460,10 +460,10 @@ jobs: node-version: '20' - name: Install dependencies - run: npm ci + run: pnpm install --frozen-lockfile - name: Build - run: npm run build + run: pnpm run build env: PUBLIC_SUPABASE_URL: ${{ secrets.PUBLIC_SUPABASE_URL }} PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.PUBLIC_SUPABASE_ANON_KEY }} @@ -471,12 +471,12 @@ jobs: PUBLIC_ASSETS_BASE_URL: ${{ secrets.PUBLIC_ASSETS_BASE_URL }} - name: Sync R2 Assets - run: npm run r2:sync + run: pnpm run r2:sync env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - name: Deploy to Cloudflare - run: npm run deploy + run: pnpm run deploy env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} ``` diff --git a/docs/boa/boa-universe-guide.md b/docs/boa/boa-universe-guide.md deleted file mode 100644 index 842bd084..00000000 --- a/docs/boa/boa-universe-guide.md +++ /dev/null @@ -1,752 +0,0 @@ -# Beacon Orbital Analytics — Universe Reference Guide - -**Document Type:** Campaign Fact Sheet -**Version:** 1.0 -**Last Updated:** November 2025 -**Purpose:** Character and organizational reference for scenario development - ---- - -## 🏢 PRIMARY ORGANIZATION - -### Beacon Orbital Analytics (BOA) - -**Founded:** 2022 -**Location:** Kendall Square, Cambridge, Massachusetts -**Address:** Converted warehouse space near Charles River, desk 4B for new analyst -**Company Size:** 15 employees (lean startup) -**Company Culture:** - -- Fresh coffee in communal kitchen (always available) -- Converted warehouse aesthetic (tech startup vibe) -- Workstation cooling fans humming -- Windows overlooking Charles River -- Fast-paced, operational focus over theory - -**Services Offered:** - -1. Ground station operations support -2. Orbital analysis & conjunction assessment -3. Launch campaign support -4. Satellite tracking & telemetry routing - -**Competitive Position:** - -- Small but fast-growing -- Known for high-quality work despite lean team -- Competes with 5+ other regional providers -- Reputation-driven business (word-of-mouth critical) - -**Office Equipment/Culture:** - -- KeepTrack software on all analyst workstations -- Model of Sputnik on Sarah's desk -- Sarah's mug: "I BRAKE FOR CONJUNCTIONS" -- Detailed operational logs maintained -- Friday morning ops briefs to management - ---- - -## 👥 BEACON ORBITAL ANALYTICS PERSONNEL - -### Sarah Chen — Senior Orbital Analyst / Mentor - -**Role:** Senior Orbital Analyst, Primary Mentor to Junior Analyst -**Years at Beacon:** 4 years (longest tenure except Mike) -**Previous Experience:** Draper Labs (lunar navigation systems, 3 years) -**Education:** Implied aerospace/orbital mechanics background - -**Personality Traits:** - -- Warm but direct communication style -- Patient mentor (has trained 3 analysts before player) -- Values fundamentals and precision -- Prefers operational work over theoretical -- Proactive problem-solver -- Detail-oriented ("precision matters, details matter") - -**Management Style:** - -- Encourages independent problem-solving first -- Available for questions when stuck -- Provides context and "why" explanations -- Uses teaching moments organically -- Balances support with autonomy - -**Physical Details:** - -- Keeps model Sputnik on desk -- Coffee mug: "I BRAKE FOR CONJUNCTIONS" -- Wheels chair over to other desks frequently -- Has well-worn map of New England on secondary monitor -- Three-ring binder: "SENSOR NETWORK CONFIG" - -**Quotes/Speech Patterns:** - -- "Reality punches you in the face every morning and you learn to punch back" -- Uses analogies (telescope calibration, space legs) -- Explains "why" not just "how" -- Direct but encouraging -- "Don't worry, you're not handling this yet. But you will be..." - -**Station Responsibilities:** - -- Cambridge station technician -- Emergency roof access for manual antenna pointing - -**Background Story:** -Left theoretical work at Draper because she wanted operational impact. Finds the chaos and reality of NewSpace more fulfilling than elegant mathematical solutions disconnected from practice. - ---- - -### Mike Rodriguez — Director of Operations - -**Role:** Director of Operations, Manager -**Years at Beacon:** Founding period (2022+) -**Previous Experience:** U.S. Air Force, Schriever Space Force Base -**Military Background:** Former Air Force (implied officer or senior enlisted) - -**Personality Traits:** - -- Pragmatic and efficiency-focused -- Little patience for excuses -- Deep respect for competence -- Values proactive problem identification -- Doesn't sugarcoat feedback -- Fair but demanding - -**Management Style:** - -- Pop quizzes without warning -- Tests thinking process, not just knowledge -- Brief, direct feedback ("solid work" = high praise) -- Walks by and observes without announcing presence -- Sets high standards but provides clear expectations -- "I like analysts who ask questions BEFORE they misconfigure a $2 million antenna" - -**Physical Details:** - -- Often carrying breakfast burrito -- Walks quietly (appears behind people unexpectedly) -- Fresh coffee in hand during morning rounds -- Doesn't always look up when speaking across room - -**Quotes/Speech Patterns:** - -- Very brief sentences -- Gets to the point immediately -- Rhetorical questions as teaching tools -- "Don't be a hero, kid" -- "You're learning" (high praise) -- Uses economy of language - -**Leadership Philosophy:** - -- Small team = everyone pulls weight -- Mistakes are expected, hiding them is not -- Proactive issue identification valued -- Every deliverable is a "reputation brick" -- Client relationships = company survival - -**Background Story:** -Career military background in space operations (Schriever SFB). Transitioned to commercial NewSpace sector, bringing operational discipline and standards from military space operations. Founded or co-founded Beacon Orbital Analytics in 2022. - ---- - -### Player Character — Junior Orbital Analyst - -**Role:** Junior Orbital Analyst (starting position) -**Start Date:** Week 1 of employment (Monday morning, 9:00 AM) -**Education:** Aerospace engineering degree (recent graduate, "aced") -**Previous Experience:** Strong theoretical knowledge, limited operational experience -**Desk Assignment:** Desk 4B - -**Career Progression:** - -- Scenarios 1-5: Junior Analyst -- Scenarios 6-10: Analyst -- Scenarios 11-15: Senior Analyst - -**Reporting Structure:** - -- Direct Mentor: Sarah Chen -- Manager: Mike Rodriguez - -**Learning Curve:** - -- Day 1: Interface and satellite identification -- Day 2: Ground station configuration -- Day 3: First client deliverable (pass predictions) -- Day 4: Proximity analysis -- Day 5: Weekly reporting - ---- - -## 🎓 CLIENT ORGANIZATIONS - -### Massachusetts University of Technology (MUT) - -**Real-World Parallel:** MIT (fictionalized) -**Location:** Cambridge, Massachusetts -**Relationship to Beacon:** Primary anchor client, long-standing relationship -**Satellite Asset:** NEBULA-1 (3U CubeSat) - -**Primary Contact: Dr. Emily Foster** - -**Role:** Faculty liaison, NEBULA-1 project lead -**Email:** -**Personality:** - -- Patient and professional -- Appreciates thorough work -- Juggling multiple responsibilities (professor workload) -- Values clarity for student operations team -- Refers business to colleagues when satisfied - -**Communication Style:** - -- Clear, specific requests -- Appreciative of good work -- Follows up with contextual questions -- Understands operational constraints -- "Please pass along my thanks to whoever prepared this" - -**Program Details:** - -- Student operations team runs satellite -- Educational mission (Earth observation + training) -- Weekly pass schedule requests standard -- Teaching labs Mon/Wed 2-5 PM EST -- Occasionally hosts visiting delegations (ESA, etc.) - -**Operational Needs:** - -- 7-day pass predictions weekly -- High-elevation passes preferred (>25-30°) -- Student-friendly passes during lab hours -- CSV exports for automated systems -- 24-48 hour turnaround acceptable - ---- - -### Dartford College - -**Real-World Parallel:** Dartmouth College (parody) -**Location:** New Hampshire (implied) -**Satellite Asset:** GRANITE-2 (6U CubeSat) - -**Mission Profile:** - -- Atmospheric density research -- Polar orbit (450 km, ~89° inclination) -- UHF downlink (compatible with Cambridge station) -- Lower altitude = higher drag environment - -**Relationship to Beacon:** - -- University client -- Scientific research mission -- Smaller scale than MUT - ---- - -### Hexad Communications - -**Type:** Commercial smallsat operator -**Satellite Asset:** HEXAD-A (IoT network node) -**Constellation Plan:** 6-satellite IoT constellation - -**Mission Profile:** - -- IoT communication network -- 550 km altitude, 45° inclined orbit -- S-band downlink -- Regional coverage focus - -**Operational Context:** - -- Part of planned 6-satellite deployment -- Commercial NewSpace operator -- Regional IoT services - -**Future Scenarios:** - -- Scenario 6 involves full HEXAD constellation deployment -- Tracking 6 satellites through phasing maneuvers - ---- - -### Maine Forestry Department - -**Type:** State government agency -**Satellite Asset:** PINE TREE-1 (6U CubeSat) -**Client Via:** Maine Spaceport Complex partnership - -**Mission Profile:** - -- Forest health monitoring -- 525 km SSO (sun-synchronous orbit) -- X-band downlink (Maine station compatible) -- Regional environmental monitoring - -**Relationship to Beacon:** - -- Government client -- Via Maine Spaceport relationship -- Applied science mission - ---- - -### Beacon Orbital Analytics (Internal Asset) - -**Satellite Asset:** BEACON-SAT (3U CubeSat) -**Mission:** Technology demonstration (internal) - -**Mission Profile:** - -- 400 km circular orbit (lowest altitude in portfolio) -- VHF downlink (Cambridge station compatible) -- 51.6° inclination -- High drag environment (frequent maintenance needed) - -**Strategic Purpose:** - -- Technology demonstration platform -- Proof of concept for services -- Training asset for new analysts -- Internal testing and validation - ---- - -## 🛰️ SATELLITE PORTFOLIO SUMMARY - -| Satellite | Client | Type | Orbit | Altitude | Inclination | Downlink | Primary Station | -|-----------|--------|------|-------|----------|-------------|----------|-----------------| -| NEBULA-1 | MUT | 3U CubeSat | SSO | 500 km | 97.8° | S-band | Maine | -| GRANITE-2 | Dartford | 6U CubeSat | Polar | 450 km | 89.5° | UHF | Cambridge | -| HEXAD-A | Hexad Comm | Smallsat | Inclined | 550 km | 45.0° | S-band | Maine | -| PINE TREE-1 | Maine Forestry | 6U CubeSat | SSO | 525 km | 97.9° | X-band | Maine | -| BEACON-SAT | Beacon (internal) | 3U CubeSat | Circular | 400 km | 51.6° | VHF | Cambridge | - ---- - -## 📡 GROUND STATION NETWORK - -### Maine Spaceport Complex (Primary) - -**Location:** Limestone, Maine (former Loring Air Force Base) -**Coordinates:** 46.8920°N, -67.8850°W, 152m elevation -**Callsign:** MAINE-GS-1 - -**Facility History:** - -- Former Cold War-era Air Force base -- Converted to commercial spaceport (2021) -- Northern latitude ideal for polar/SSO access - -**Technical Specifications:** - -- Dish: 7.3 meter Az-El pedestal -- Bands: S-band (2.0-2.3 GHz), X-band (8.0-8.5 GHz) -- Polarization: Dual (RHCP/LHCP) -- Slew Rate: 3°/second -- Elevation Mask: 10° general, 12° (120-150° azimuth, hangar obstruction) -- Weather: RF-tolerant (24/7 ops) - -**Station Manager: Robert "Bob" Theriault** - -- Thick Maine accent ("Ayuh") -- Deep knowledge of local terrain -- Knows every meter of horizon -- Practical operator perspective -- Phone: (207) 555-0147 - -**Operational Notes:** - -- Beacon's "workhorse" station -- $2 million+ facility -- Trees on south side affect low-elevation passes -- Best SSO coverage in network -- Can be driven to for manual operations - -**Coverage Profile:** - -- NEBULA-1: 4-6 passes/day, 70-85° max elevation -- Excellent for SSO satellites -- 65% of NEBULA-1 support burden - ---- - -### Beacon Cambridge Station (Secondary/Backup) - -**Location:** Kendall Square, Cambridge, MA (rooftop) -**Coordinates:** 42.3626°N, -71.0843°W, 34m elevation (ground + building) -**Callsign:** BCN-CAM - -**Facility Type:** - -- Office building rooftop installation -- 3-meter dish on roof -- Urban environment - -**Technical Specifications:** - -- Dish: 3.0 meter Az-El rotor -- Bands: VHF (137-138 MHz), UHF (400-406 MHz) -- Polarization: Circular (RHCP) -- Slew Rate: 2°/second -- Elevation Mask: 15° (urban obstructions) -- Weather: RF-tolerant -- RF Interference: Moderate (cellular towers nearby) - -**Station Technician:** Sarah Chen -**Emergency Access:** Mike Rodriguez -**Manual Operation:** Roof access available - -**Operational Notes:** - -- Beacon-owned and controlled -- Quick reaction capability -- CubeSat telemetry specialist -- Higher noise floor (urban) -- Unmanned overnight (requires pre-scheduling) -- Analysts can manually point if needed - -**Coverage Profile:** - -- GRANITE-2: 3-5 passes/day, 40-60° max elevation -- Limited to VHF/UHF satellites -- 24% of network coverage -- Backup and training station - ---- - -### MassState Observatory - Lowell (Optical) - -**Location:** Lowell, Massachusetts (MassState campus) -**Coordinates:** 42.6334°N, -71.3162°W, 110m elevation -**Callsign:** MASSSTATE-OPT - -**Facility Type:** - -- University optical telescope -- Educational astronomy facility -- Partnership agreement with Beacon - -**Technical Specifications:** - -- Telescope: 0.6m Schmidt-Cassegrain -- Mount: Equatorial tracking -- FOV: 1.2° × 1.2° -- Limiting Magnitude: 16.5 -- Camera: CCD imaging system -- Elevation Mask: 20° (atmospheric effects) -- Weather: CRITICAL (clouds block optical) -- Light Pollution: Moderate (urban campus) - -**Observatory Director: Dr. Patricia Okonkwo** - -- Email: -- Phone: (978) 555-0193 -- Scheduling: 48-hour advance notice required -- Sign-off: "Clear skies, Dr. O" - -**Operational Constraints:** - -- Astronomical twilight and night only -- Satellite must be sunlit (dawn/dusk optimal) -- Ground station must be in darkness -- Clear skies mandatory -- Student labs take priority (e.g., Thu 6-9 PM) -- ~60% availability due to weather/scheduling - -**Operational Notes:** - -- Visual confirmation capability -- Debris characterization -- Attitude determination -- Tumble detection -- Fragmentation identification -- Provides data RF cannot (visual state) - -**Coverage Profile:** - -- 1-3 twilight passes per day per satellite -- Dawn: 5:00-7:00 AM local -- Dusk: 6:00-8:00 PM local -- Weather-dependent availability - ---- - -### Gander Ground Station (Partner) - -**Location:** Gander, Newfoundland, Canada -**Coordinates:** 48.9369°N, -54.5680°W, 151m elevation -**Callsign:** GANDER-GS -**Operator:** Maritime Satellite Services Ltd. - -**Facility Type:** - -- Commercial tracking facility -- Partner station (not Beacon-owned) -- Located near Gander International Airport - -**Technical Specifications:** - -- Dish: 5.0 meter Az-El pedestal -- Bands: S-band (2.0-2.3 GHz), limited X-band receive -- Polarization: RHCP/LHCP switchable -- Slew Rate: 2.5°/second -- Elevation Mask: 10° (clear Atlantic horizon) -- Weather: RF-tolerant (24/7 operations) - -**Station Manager: Robert MacLeod** - -- Email: -- Scheduling: - -**Partnership Terms:** - -- Priority access: Tue/Thu/Sat 00:00-12:00 UTC -- Emergency access: 4-hour notice -- Data delivery: FTP within 30 minutes -- Scheduling coordination required - -**Operational Notes:** - -- Best northern coverage in network -- Clear eastern horizon (Atlantic) -- Partner-operated (no direct control) -- No on-site Beacon personnel -- Backup station for critical passes -- Earlier AOS, later LOS on ascending passes - -**Coverage Profile:** - -- NEBULA-1: 5+ passes/day, 56-60° avg elevation -- 40% of network coverage -- Best SSO geometry in network -- Strategic necessity despite external ownership - ---- - -## 🌐 REGIONAL CONTEXT - -### Kendall Square, Cambridge MA - -**Location:** Tech corridor near MIT -**Characteristics:** - -- Dense tech startup ecosystem -- Converted warehouses common -- Charles River waterfront -- MUT campus nearby -- Biotech and aerospace hub - -**Beacon's Position:** - -- Converted warehouse space -- Walking distance to Cambridge station (rooftop) -- Coffee culture (communal kitchen) -- Startup aesthetic - ---- - -### Maine Spaceport Complex Area - -**Location:** Limestone, Maine (Aroostook County) -**Nearby:** Caribou, ME; Canadian border -**Former Use:** Loring Air Force Base (Cold War) - -**Strategic Value:** - -- Northern latitude (46.9°N) -- Low light pollution -- Clear horizons -- SSO orbit access -- Existing aerospace infrastructure - ---- - -### New England Sensor Network Coverage - -**Geographic Coverage:** 42-49°N latitude -**Region:** Maine, Massachusetts, Newfoundland -**Strengths:** - -- Northern hemisphere mid-to-high latitude -- SSO and polar orbit coverage -- Regional client base - -**Coverage Gaps:** - -- No equatorial coverage -- No southern hemisphere coverage -- Pacific Ocean gaps -- 24/7 coverage requires partner networks - ---- - -## 🔗 EXTERNAL CONTACTS - -### Captain James Morrison - -**Organization:** U.S. Department of Defense -**Role:** DoD liaison -**Relationship:** Occasional taskings to Beacon -**Context:** Military space operations coordination - ---- - -## 📊 OPERATIONAL STANDARDS - -### Pass Prediction Quality Tiers - -**EXCELLENT (60°+ elevation):** - -- Nearly overhead passes -- Best signal quality -- Least atmospheric interference -- Schedule critical operations here - -**GOOD (40-60° elevation):** - -- Strong, reliable passes -- Standard telemetry/command operations -- Most routine work happens here - -**ACCEPTABLE (25-40° elevation):** - -- Usable but not ideal -- Higher noise floor -- More atmospheric path -- Non-critical telemetry acceptable - -**MARGINAL (15-25° elevation):** - -- Emergency use only -- Signal dropouts expected -- Higher error rates -- Generally filtered out for clients - -**POOR (<15° elevation):** - -- Avoid unless desperate -- More noise than signal -- Not recommended - ---- - -### Client Service Standards - -**Turnaround Times:** - -- Pass schedules: 24-48 hours -- Emergency analysis: Same day -- Weekly reports: Friday morning -- Partner station scheduling: 48+ hour notice - -**Deliverable Formats:** - -- Human-readable tables (primary) -- CSV exports (automation) -- Calendar files (.ics optional) -- Summary statistics included -- Client timezone (not UTC unless requested) - -**Quality Expectations:** - -- Accurate within ±30 seconds (fresh TLE) -- Professional formatting -- Context notes included -- Contact information provided -- TLE epoch documentation - ---- - -### TLE Management - -**Update Frequency:** - -- Review every 7 days minimum -- Update if >7 days old for predictions -- Flag if >14 days old (accuracy degraded) -- Re-run predictions if significant drift - -**Propagation Error:** - -- ~30-60 seconds per week of TLE age -- Atmospheric drag affects low-altitude most -- BEACON-SAT (400km) needs frequent updates -- HEXAD-A (550km) more stable - ---- - -## 🎯 TRAINING CAMPAIGN STRUCTURE - -### Act I: First Orbit (Scenarios 1-5, Beginner) - -**Timeline:** Weeks 1-2 on the job -**Theme:** Orientation, fundamentals, confidence building - -**Character Arc:** - -- Sarah: Patient, teaching fundamentals -- Mike: Observing from distance, occasional tests -- Clients: Introduced via email, no direct pressure - -### Act II: Constellation Rising (Scenarios 6-10, Intermediate) - -**Timeline:** Months 2-3 -**Theme:** Multi-sat operations, anomaly handling, customer support - -**Character Arc:** - -- Sarah: More delegation, less hand-holding -- Mike: Direct tasking, higher expectations -- Clients: Direct communication, time pressure - -### Act III: Debris Field (Scenarios 11-15, Advanced) - -**Timeline:** Month 6+ -**Theme:** Crisis operations, high-stakes decision-making - -**Character Arc:** - -- Sarah: Peer relationship, collaborative -- Mike: Trusts judgment, seeks recommendations -- Clients: Dependent on expertise, high stakes - ---- - -## 🗂️ WORLD-BUILDING DETAILS - -### Office Culture Elements - -- Fresh coffee always available (communal kitchen) -- Analysts maintain personal coffee mugs -- Models and memorabilia on desks (Sarah's Sputnik) -- Detailed operational logs required -- Friday morning briefings to management -- Quiet professionalism (cooling fans humming) -- Views of Charles River from windows - -### Industry Context - -- NewSpace competitive environment -- 5+ regional competitors for Beacon -- Word-of-mouth reputation critical -- Small team means every person matters -- Client relationships = company survival -- "Every deliverable is a reputation brick" - -### Realistic Operational Details - -- Station operators know local terrain better than databases -- Clients have multiple responsibilities (not just satellites) -- Weather affects optical, not RF -- Partner stations require coordination -- Overnight operations require pre-scheduling -- Manual backup operations possible (roof access) - ---- diff --git a/docs/nats/nats-campaign-plan.md b/docs/nats/nats-campaign-plan.md deleted file mode 100644 index 7af4831e..00000000 --- a/docs/nats/nats-campaign-plan.md +++ /dev/null @@ -1,543 +0,0 @@ -# NATS Campaign Plan - -**24-Scenario Training Progression — Singular Reference for Parallel Scenario Builds** - -This document is the canonical plan for the NATS campaign. It exists so that scenarios 10-24 can be built in parallel without contradictions: each entry specifies premise, mechanics, NICE codes, characters, satellites, prerequisites, and what the scenario must teach beyond what prior scenarios already taught. - -Mechanical "how to build a scenario" guidance lives in the [scenario-developer](../../.claude/skills/scenario-developer/SKILL.md) skill. NATS-specific tone, character voices, and archetype templates live in the [nats-campaign-builder](../../.claude/skills/nats-campaign-builder/SKILL.md) skill. This document is **what** each scenario covers, not **how** to write one. - ---- - -## Campaign Phase Map - -| Phase | Scenarios | Theme | Status | -|-------|-----------|-------|--------| -| 1 | 1-8 | Foundations — Guided learning with Charlie Brooks | Built | -| 2 | 9-16 | Qualified Operations — Solo shifts, real customers, operational judgment | Built | -| 3 | 17-24 | Crisis Operations — High-stakes, multi-system, mentoring | Built | - ---- - -## Phase 1: Foundations (Scenarios 1-8) — Built - -*Theme: Orientation and fundamentals. Charlie Brooks guides the player through one new mechanic per scenario.* - -| Lvl | Title | Subtitle | Core mechanic introduced | Primary NICE | -|-----|-------|----------|--------------------------|--------------| -| 1 | First Day | TIDEMARK-1 Health Check | UI navigation, equipment observation, alarm reading | K0740, K0741, T0153 | -| 2 | Scheduled Maintenance | Power Down and Recovery Procedures | Power-on/off sequencing, RF safety, antenna positioning | T1567, K0770, S0421 | -| 3 | Weather Emergency Handover | Multi-Site Operations | Feed heater, AGC monitoring, ME-02 commissioning, traffic handover | T0153, K0689, S0421 | -| 4 | New Bird on the Block | Satellite Switchover Operations | IF frequency calculation, satellite acquisition, full-duplex link establishment | S0421, K1032, K0773 | -| 5 | Interference Hunt | Spectrum Analysis and Mitigation | Spectrum analysis at wide span, cross-pol interference, notch filter | T0153, K0773, S0582 | -| 6 | Old Faithful | Step-Track Operations on Inclined Orbit | Step-track mode, AURORA-7 inclined orbit, BUC TX IF calculation | K1032, S0421, K0773 | -| 7 | Uplink Validation | Transmit Enable Sequence & Power Verification | BUC loopback, post-maintenance diagnosis, HPA enable sequencing | S0077, T1313 | -| 8 | Night Shift | Solo Operations Evaluation | Multi-fault diagnosis, intermittent fault, backup modem switching | T0081, S0582, K0773 | - -End-of-Phase-1 state (pre-S9): -- Charlie Brooks has transferred to a European station; he is not on-site after S8. -- Dana Torres is the on-site supervisor. -- Catherine Vega runs ME-02 (operational since S3). -- TIDEMARK-1, TIDEMARK-2, SES-10 (visible only), AURORA-7 are in the satellite roster. - ---- - -## Phase 2: Qualified Operations (Scenarios 9-16) - -*Theme: The player is qualified. Dialog density drops sharply. Quizzes default to `Character.SYSTEM`. Per-scenario character clips capped at 3-7. Tone is operational, not instructional.* - -**Phase 2 satellite roster:** TIDEMARK-1, TIDEMARK-2, TIDEMARK-3 (new in S9), AURORA-7, SES-10 (visible only). -**Phase 2 stations:** VT-01 and ME-02 (both fully operational). -**Phase 2 arc:** Mostly standalone day-in-the-life vignettes. One 2-scenario mini-arc at S11-S12 (planned maintenance: handover out → return to service). - ---- - -### S9 — Morning Rounds | **Built** - -| Field | Value | -|-------|-------| -| Subtitle | Multi-Satellite Health Check | -| Status | Built | -| Premise | First qualified shift. Compressed health checks across three TIDEMARK birds: VT-01/TM-1, ME-02/TM-2, and a quick spot-check on the newly commissioned TIDEMARK-3. No drama. | -| Duration | 20-25 min | -| Objectives | 18 | -| Mechanics reused | S1 health-check sequence, S4 antenna repoint, S5 spectrum tuning. Multi-station navigation from S3. | -| Satellites | TIDEMARK-1, TIDEMARK-2, **TIDEMARK-3** (new), SES-10 (visible) | -| Ground stations | VT-01 (active), ME-02 (active) | -| Characters & density | Dana only. 5 clips: text-message intro, 3 phase transitions, sign-off. All quizzes are SYSTEM. | -| NICE — Primary | T0153, T0431, S0421 | -| NICE — Supporting | K0645, K0740, K0741, K0773, K1032 | -| New value beyond Phase 1 | First scenario with no instructional dialog. Demonstrates compressed checklist execution across multiple satellites. Introduces TIDEMARK-3. | -| Prerequisites | nats-level-8-night-shift | -| Arc connection | Standalone | - ---- - -### S10 — Customer Pass - -| Field | Value | -|-------|-------| -| Subtitle | High-Throughput Window on AURORA-7 | -| Status | Built | -| Premise | SeaLink requests a 30-minute high-priority data window on AURORA-7. Marcus Chen is on the line throughout. Operator runs step-track, monitors C/N for sustained margin, optimizes HPA backoff for the pass duration. Customer is watching the link live. | -| Duration | 25-30 min | -| Objectives | 18-22 | -| Mechanics reused | S6 step-track on AURORA-7, S1 C/N monitoring, S2/S4 HPA backoff adjustment, S1 payload data integrity check | -| Satellites | AURORA-7 | -| Ground stations | VT-01 | -| Characters & density | Dana (intro). Marcus Chen (3-4 clips: pre-pass briefing, mid-pass throughput acknowledgment, post-pass thank-you). 5-6 clips total. | -| NICE — Primary | S0478, T1580, T0153 | -| NICE — Supporting | K1032, K0740, S0675 | -| New value beyond Phase 1 + S9 | First scenario with **active customer dialog during operations**. Pre-S10 customer interaction was bracketing (brief/debrief); S10 puts the customer on the link in real time. Throughput-vs-margin tradeoff is explicit. | -| Prerequisites | nats-scenario9 | -| Arc connection | Standalone | - ---- - -### S11 — Planned Maintenance: Hand Off (arc 1 of 2) - -| Field | Value | -|-------|-------| -| Subtitle | Coordinated Traffic Transfer to Sister Teleport | -| Status | Built | -| Premise | Scheduled 2-hour VT-01 maintenance window for HPA waveguide gasket inspection. Operator pre-coordinates with sister teleport and ME-02 (Catherine), executes planned TIDEMARK-1 handover, stows VT-01 for the maintenance crew. Unlike S3's emergency handover, there is no weather pressure. The grade is procedural cleanliness. | -| Duration | 25-30 min | -| Objectives | 18-22 | -| Mechanics reused | S3 traffic handover, S2 antenna maintenance position, S3 cross-station coordination | -| Satellites | TIDEMARK-1 | -| Ground stations | VT-01 (handing off), ME-02 (receiving control) | -| Characters & density | Dana (intro + sign-off). Catherine (2-3 clips: coordination, receive confirmation). 5-6 clips total. | -| NICE — Primary | K0718, T0129, S0593 | -| NICE — Supporting | K0645, K0741 | -| New value beyond Phase 1 + S9-S10 | S3 emergency handover under time pressure; S11 is the **same mechanic under planning discipline**. Pre-coordination protocol. Formal callouts. The lesson: the procedure does not relax just because the urgency is off. | -| Prerequisites | nats-scenario10 | -| Arc connection | Start of S11-S12 mini-arc (planned maintenance cycle) | - ---- - -### S12 — Planned Maintenance: Return to Service (arc 2 of 2) - -| Field | Value | -|-------|-------| -| Subtitle | Post-Maintenance Restoration | -| Status | Built | -| Premise | Maintenance is complete. Operator brings VT-01 back: power-up sequence, RX chain validation, post-maintenance leftover sweep (a small misconfig from the maintenance crew), retake TIDEMARK-1 traffic from the sister teleport, notify customer of restoration. | -| Duration | 25-35 min | -| Objectives | 20-25 | -| Mechanics reused | S2 power-up sequence, S7 post-maintenance validation (incl. leftover detection), S1/S9 beacon spot check, S3 traffic handover (return direction) | -| Satellites | TIDEMARK-1 | -| Ground stations | VT-01 (restoring), ME-02 (releasing) | -| Characters & density | Dana (intro + sign-off). Catherine (2 clips: release coordination). Marcus or James briefly acknowledging service restoration. 5-7 clips total. | -| NICE — Primary | T1314, T1567, T0431 | -| NICE — Supporting | T0153, K0773, K0645 | -| New value beyond Phase 1 + S9-S11 | Closes the **full maintenance cycle** for the first time. S2 and S7 taught the pieces in isolation; S11→S12 demonstrates the entire lifecycle: handover-out → planned downtime → maintenance → validation → handover-back. Discipline of finishing what you started. | -| Prerequisites | nats-scenario11 | -| Arc connection | End of S11-S12 mini-arc | - ---- - -### S13 — Thermal Anomaly - -| Field | Value | -|-------|-------| -| Subtitle | Reading the Trend | -| Status | Built | -| Premise | During an otherwise normal shift, BUC temperature trends upward over 15 minutes. No immediate alarm, but the slope matters. Operator must read the trend, decide to de-rate (lower BUC gain), schedule a swap, mute and switch to backup, or hold and monitor. The right answer is judgment, not a checklist. | -| Duration | 25-30 min | -| Objectives | 18-22 | -| Mechanics reused | S2/S7 BUC configuration, S2 HPA backoff, S5 spectrum monitoring | -| Satellites | TIDEMARK-1 (primary carrier on VT-01) | -| Ground stations | VT-01 | -| Characters & density | Dana (1 check-in at decision point). 3-4 clips total. | -| NICE — Primary | K0064, S0672, T1314 | -| NICE — Supporting | K0740, S0675, T0153 | -| New value beyond Phase 1 + S9-S12 | First scenario where the player must read a **trend** rather than respond to a discrete fault. S5 was a binary spike; S13 is "is this worth acting on yet, or am I overreacting?" Operational judgment under uncertainty. | -| Prerequisites | nats-scenario12 | -| Arc connection | Standalone | - ---- - -### S14 — Rain Fade - -| Field | Value | -|-------|-------| -| Subtitle | Adapt Without Handover | -| Status | Built | -| Premise | Light to moderate rain approaches VT-01. The customer (James Okafor) prefers no handover — SLA terms allow degradation but penalize handover events. Operator must enable feed heater, watch AGC headroom, optimize HPA backoff, monitor link margin. The scenario can end either way: adapt successfully, or recognize a threshold-justified handover. | -| Duration | 25-35 min | -| Objectives | 20-25 | -| Mechanics reused | S3 feed heater + AGC, S1 C/N margin tracking, S3 traffic handover (only if thresholds exceeded) | -| Satellites | TIDEMARK-1 | -| Ground stations | VT-01 primary, ME-02 standby | -| Characters & density | Dana (decision check-in). James Okafor (1 clip: SLA constraint context). 4-5 clips total. | -| NICE — Primary | K0689, S0675, K0721 | -| NICE — Supporting | T0153, K0773, S0593 | -| New value beyond Phase 1 + S9-S13 | S3 taught "weather hits → hand over." S14 teaches that **the right call is not always to escape**. Adds customer-constraint reasoning. First explicit `K0721` (risk management) introduction. | -| Prerequisites | nats-scenario13 | -| Arc connection | Standalone | - ---- - -### S15 — Frequency Coordination - -| Field | Value | -|-------|-------| -| Subtitle | Inter-Operator Spectrum Etiquette | -| Status | Built | -| Premise | Notification from a partner teleport: their next uplink will sit 2 MHz from a current TIDEMARK-3 carrier. Operator verifies guard bands, checks that the VT-01 carrier is not producing spurs that would land on the partner's slot, may need to retune slightly or adjust IF filtering. Coordination, not combat. | -| Duration | 25-30 min | -| Objectives | 18-22 | -| Mechanics reused | S5 spectrum analysis, S5 IF filter configuration, S4/S7 TX modem configuration, S7 uplink validation | -| Satellites | TIDEMARK-3 | -| Ground stations | VT-01 | -| Characters & density | Dana (intro). Partner-teleport coordinator (1-2 clips — render via SYSTEM as email/notification, not a named character, to avoid expanding the roster). 4 clips total. | -| NICE — Primary | K0737, T1143, S0648 | -| NICE — Supporting | K0792, K0773, S0593 | -| New value beyond Phase 1 + S9-S14 | S5 was "someone else interfered with us." S15 reverses the perspective: **make sure we are not interfering with them**. Introduces external-operator coordination — absent from S1-S14. | -| Prerequisites | nats-scenario14 | -| Arc connection | Standalone | - ---- - -### S16 — Cascade Failure - -| Field | Value | -|-------|-------| -| Subtitle | Multi-System Recovery Under Customer Pressure | -| Status | Built | -| Premise | Two minutes into a routine shift, multiple unrelated alarms fire at once: BUC over-temperature triggering automatic mute, LNB reference unlock, HPA backoff drift. Customers begin calling. Operator must prioritize (RF safety → customer impact → equipment health), work through each fault systematically without supervisor escalation. | -| Duration | 35-45 min | -| Objectives | 25-32 | -| Mechanics reused | S7 BUC fault diagnosis, S8 LNB power cycle, S2 HPA management, S3 alarm prioritization framework, S5 documentation discipline | -| Satellites | TIDEMARK-1, TIDEMARK-2 (background traffic) | -| Ground stations | VT-01 primary | -| Characters & density | Dana (escalation check). James Okafor (customer pressure, 1-2 clips). SYSTEM for all triage quizzes. 5-7 clips total. | -| NICE — Primary | T0531, S0677, S0807 | -| NICE — Supporting | T1538, S0593, T0081, T1606 | -| New value beyond Phase 1 + S9-S15 | S8 was multi-fault but all faults touched AURORA-7 (related). S16 is **unrelated faults at once**, requiring the operator to hold multiple mental tracks. The capstone of Phase 2. | -| Prerequisites | nats-scenario15 | -| Arc connection | Standalone (Phase 2 capstone) | - ---- - -## Phase 3: Crisis Operations (Scenarios 17-24) - -*Theme: High-stakes, time-critical, multi-system. Player begins taking on mentoring and reporting responsibilities. Crisis cadence — each scenario is a distinct kind of stress.* - -**Phase 3 satellite roster:** Same as Phase 2. -**Phase 3 stations:** Same as Phase 2. -**Phase 3 arc:** No fixed mini-arc by design. Each scenario is a unique crisis archetype. S22-S24 has loose narrative continuity (AURORA-7 sunset → final ops → constellation reorganization) but no hard dependencies. - ---- - -### S17 — Solar Event - -| Field | Value | -|-------|-------| -| Subtitle | Sun Transit Outage | -| Status | Built | -| Premise | The semiannual sun transit window arrives — the Sun passes directly behind TIDEMARK-1 from VT-01's perspective for ~8 minutes, raising the noise floor catastrophically. The event is predictable and mitigatable: pre-coordinate with sister teleport, accept the degradation, ride it out, document customer impact. No equipment failure; just astronomy. | -| Duration | 25-30 min | -| Objectives | 18-22 | -| Mechanics reused | S11-S12 sister-teleport coordination, S10 customer comms, S1/S9 monitoring, S5 documentation | -| Satellites | TIDEMARK-1 | -| Ground stations | VT-01 | -| Characters & density | Dana (briefing + sign-off). Marcus (impact confirm, 1 clip). 4-5 clips total. | -| NICE — Primary | K0751, T1020, K0689 | -| NICE — Supporting | T0153, K1032, S0593 | -| New value beyond Phases 1-2 | First scenario where **the right answer is to accept degradation**, not fight it. Astronomical/environmental factors at predictable scale. Planning around guaranteed degradation, not avoiding it. | -| Prerequisites | nats-scenario16 | -| Arc connection | Standalone | - ---- - -### S18 — Satellite Anomaly - -| Field | Value | -|-------|-------| -| Subtitle | TIDEMARK-2 Station-Keeping Drift | -| Status | Built | -| Premise | Marcus reports from Halifax: TIDEMARK-2's station-keeping thrusters degraded; the bird is drifting more than expected. Ground operator must switch from program-track to step-track to maintain lock, monitor C/N stability, prepare customer impact assessment. The spacecraft team is the prime decision-maker; ground role is to keep the link alive while they diagnose. | -| Duration | 30-35 min | -| Objectives | 22-26 | -| Mechanics reused | S6 step-track, S6 antenna tracking-mode transition, S9 beacon monitoring, S10 Marcus collaboration | -| Satellites | TIDEMARK-2 | -| Ground stations | ME-02 primary, VT-01 standby | -| Characters & density | Marcus (primary, 3-4 clips — spacecraft side reports). Dana (incident-handling support). Catherine (ME-02 hands-on). 6-7 clips total. | -| NICE — Primary | K1032, T1314, K0751 | -| NICE — Supporting | K0721, T0153, S0593 | -| New value beyond Phases 1-2 | First scenario where the **satellite itself is the unknown**, not the ground equipment. Working in concert with a spacecraft team during a vehicle anomaly. When to escalate vs. when to keep operating. | -| Prerequisites | nats-scenario17 | -| Arc connection | Standalone | - ---- - -### S19 — Train the New Hire - -| Field | Value | -|-------|-------| -| Subtitle | Producing the Quick-Reference Card | -| Status | Built | -| Premise | Dana asks the player to draft a quick-reference card for an incoming new hire by **performing the procedure correctly while selecting which callouts and common-mistake warnings to highlight**. Implementation: SYSTEM-voiced quizzes throughout each phase prompt the player to choose the right teaching emphasis ("Which warning belongs on this step?", "What is the most common new-hire mistake here?"). The output is an in-game training document accumulated across the scenario. | -| Duration | 30-35 min | -| Objectives | 22-28 | -| Mechanics reused | S2 power-up sequence (the procedure being taught), S6 step-track (alt teaching path), S7 BUC loopback. Quiz pattern from S6 ("training day"). | -| Satellites | AURORA-7 (step-track is the most teachable mechanic) | -| Ground stations | VT-01 | -| Characters & density | Dana (intro + acknowledgment of the finished doc). SYSTEM throughout for the "which callout?" quizzes. **No new character is introduced for the new hire** — they exist off-screen. 3-4 clips total. | -| NICE — Primary | T1411, T1334, K0645 | -| NICE — Supporting | T1567, S0421, K0773 | -| New value beyond Phases 1-2 | First scenario where the player **must articulate the why**, not just execute the how. Reinforces mastery by requiring the operator to identify which gotchas and reasons matter most. Aligns with mentoring NICE work-role expectations. | -| Prerequisites | nats-scenario18 | -| Arc connection | Standalone | - ---- - -### S20 — Dual Outage - -| Field | Value | -|-------|-------| -| Subtitle | Concurrent Site Loss — Prioritized Recovery | -| Status | Built | -| Premise | A storm front knocks VT-01 receive offline (heavy rain attenuation beyond AGC range). Simultaneously, ME-02 hits an HPA fault. Multiple customers are affected. Operator must prioritize which site to restore first based on SLA exposure, customer mix, and recovery time estimates. | -| Duration | 35-45 min | -| Objectives | 26-32 | -| Mechanics reused | S3/S14 weather mechanics, S2/S7 HPA fault response, S3/S9 multi-station ops, S16 alarm prioritization | -| Satellites | TIDEMARK-1, TIDEMARK-2 | -| Ground stations | Both VT-01 and ME-02 (both degraded) | -| Characters & density | Dana (escalation, 2 clips). Catherine (ME-02 status, 2-3 clips). James Okafor (customer pressure, 1-2 clips). 6-8 clips total. | -| NICE — Primary | T1144, S0671, S0807 | -| NICE — Supporting | T1538, S0593, T0531 | -| New value beyond Phases 1-2 | True **multi-site prioritization** under simultaneous degradation. S16 was one site, multiple faults; S20 is multiple sites, mixed root causes. Decision is which fire to fight first, not how to fight any one fire. | -| Prerequisites | nats-scenario19 | -| Arc connection | Standalone | - ---- - -### S21 — Hostile RF - -| Field | Value | -|-------|-------| -| Subtitle | Suspected Intentional Interference | -| Status | Built | -| Premise | Unusual interference appears on TIDEMARK-2: broadband-ish noise that doesn't fit cross-pol patterns or known terrestrial sources, intermittent in a pattern that suggests deliberation. Operator must document signature, distinguish from natural causes, coordinate with regulator (rendered as in-game ticket workflow), apply receive-side countermeasures while the investigation runs. | -| Duration | 30-35 min | -| Objectives | 22-28 | -| Mechanics reused | S5 spectrum analysis at wide span, S5 interference characterization, S5/S15 documentation discipline | -| Satellites | TIDEMARK-2 | -| Ground stations | ME-02 primary | -| Characters & density | Dana (incident-handling, 2 clips). Marcus (spacecraft-side consultation, 1-2 clips). Regulator interaction as SYSTEM-voiced notifications. 5-6 clips total. | -| NICE — Primary | K0926, S0615, S0648 | -| NICE — Supporting | T0081, S0593, T0153 | -| New value beyond Phases 1-2 | First **adversarial-aware** scenario. S5 was unintentional cross-pol leakage; S21 introduces the question "is this intentional?" Differentiating jamming from interference. Regulatory coordination workflow. | -| Prerequisites | nats-scenario20 | -| Arc connection | Standalone | - ---- - -### S22 — End-of-Life Planning - -| Field | Value | -|-------|-------| -| Subtitle | AURORA-7 Sunset Recommendation | -| Status | Built | -| Premise | Trend data shows AURORA-7's beacon power has been declining for months. The board (Francis Martin) wants a recommendation on retirement timing. Operator runs final data collection, generates a trend report, evaluates business risk, prepares an executive-level impact summary. Light on RF, heavy on analysis and communication. | -| Duration | 25-30 min | -| Objectives | 18-22 | -| Mechanics reused | S6 step-track session (final ops run), S10 Marcus consultation, status-check pattern for analysis questions | -| Satellites | AURORA-7 | -| Ground stations | VT-01 | -| Characters & density | Marcus (Halifax discussion, 2 clips). Dana (intro). Francis Martin (board-level question, 1-2 clips — rare appearance). SYSTEM for analysis quizzes. 5-6 clips total. | -| NICE — Primary | K0721, T1429, T1606 | -| NICE — Supporting | K0751, S0807 | -| New value beyond Phases 1-2 | First **report-producing** scenario. Earlier scenarios ended in log entries; S22 ends in a multi-paragraph impact assessment. Trend analysis. Business-aware communication. Introduces Francis Martin as an active speaker. | -| Prerequisites | nats-scenario21 | -| Arc connection | Loose narrative continuity with S23 (AURORA-7 sunset thread). No hard dependency. | - ---- - -### S23 — Emergency Bypass - -| Field | Value | -|-------|-------| -| Subtitle | Manual Operations During Automation Failure | -| Status | Built | -| Premise | The ACU automation controller crashes mid-shift. Antenna positioning, beacon tracking, and program-track all unavailable. Operator must use manual tracking-mode controls to keep the link alive while IT works the root cause. Reveals what the automation was doing under the hood. | -| Duration | 25-30 min | -| Objectives | 20-25 | -| Mechanics reused | S2/S4/S6 antenna control fundamentals, S6 manual tracking-mode awareness, S5 spectrum-based pointing verification | -| Satellites | TIDEMARK-1 | -| Ground stations | VT-01 | -| Characters & density | Dana (intro + IT-coordination updates). IT contact rendered as SYSTEM-voiced ticket updates (no new character). 4-5 clips total. | -| NICE — Primary | S0424, T1588, S0671 | -| NICE — Supporting | T0531, S0421, K1032 | -| New value beyond Phases 1-2 | First scenario where the **automation itself** is the broken thing. Reveals the dependencies between automation and operator skill. Manual ops as a fallback competency, not a daily skill. | -| Prerequisites | nats-scenario22 | -| Arc connection | Standalone (loose AURORA-7 thread continues if S22 was completed). | - ---- - -### S24 — Constellation Crisis - -| Field | Value | -|-------|-------| -| Subtitle | Campaign Capstone | -| Status | Built | -| Premise | Compound emergency: simultaneous degradation on TIDEMARK-1 and TIDEMARK-2, AURORA-7 in step-track with a marginal beacon, an inbound weather event on VT-01, two customers escalating, and a board notification cycle to manage. Operator orchestrates across both stations, all four satellites, all communication channels. The final exam. | -| Duration | 40-50 min | -| Objectives | 30-38 | -| Mechanics reused | Effectively all S1-S23 mechanics. Specifically: multi-station ops (S3/S9/S20), multi-fault triage (S8/S16/S20), customer comms (S10/S14/S20), reporting (S22). | -| Satellites | TIDEMARK-1, TIDEMARK-2, TIDEMARK-3, AURORA-7 | -| Ground stations | Both VT-01 and ME-02 (both stressed) | -| Characters & density | All named characters appear: Dana (primary throughput), Catherine (ME-02), Marcus (TIDEMARK anomaly), James (customer escalation), Francis (board-level pressure). 8-10 clips total — this is the one Phase 2/3 scenario where dialog density is allowed to expand. | -| NICE — Primary | S0807, T1606, T0531 | -| NICE — Supporting | T1538, S0593, S0677, T1429, T1144 | -| New value beyond Phases 1-2 | Highest-stakes problem-solving. First scenario combining **multi-system technical recovery + executive-level reporting + customer escalation management** simultaneously. Demonstrates the operator can hold the entire job in their head at once. | -| Prerequisites | nats-scenario23 | -| Arc connection | Phase 3 capstone and campaign finale. | - ---- - -## Build Coordination Notes - -These conventions exist so that parallel builds don't contradict each other. Follow them strictly. - -### Tone and dialog density - -- All S9-S24 scenarios use the qualified-operator tone — see [nats-campaign-builder/references/dialog-density-guide.md](../../.claude/skills/nats-campaign-builder/references/dialog-density-guide.md). -- Default every `status-check` to `Character.SYSTEM`. Reserve named characters for narrative beats. -- Per-scenario character clip cap: 4-7 clips (S24 may go to 8-10 as the capstone exception). -- Text-message intro format is canonical (see S7, S8, S9). - -### Character roster (immutable) - -`DANA_TORRES`, `CATHERINE_VEGA`, `MARCUS_CHEN`, `JAMES_OKAFOR`, `FRANCIS_MARTIN`, `SYSTEM`. **Do not introduce new characters.** Charlie Brooks may appear only via phone/text from Europe. - -### Satellite roster (immutable) - -`tidemark1Satellite` (61525), `tidemark2Satellite` (61526), `tidemark3Satellite` (61527), `aurora7Satellite` (28899), `ses10Satellite` (42432, visible only). **Do not add new satellites.** If a scenario seems to require one, raise the question before building. - -### Station roster (immutable) - -`vermontGroundStation` (VT-01), `maineGroundStation` (ME-02). Both fully operational from S9 onward. **Do not add new stations.** - -### Dual registration - -Every new scenario must be registered in **two** places. Missing either causes a runtime "Scenario not found" error: -1. [src/campaigns/nats/campaign-data.ts](../../src/campaigns/nats/campaign-data.ts) — push into `natsCampaignData.scenarios`. -2. [src/scenario-manager.ts](../../src/scenario-manager.ts) — push into the top-level `SCENARIOS` array. - -### Per-scenario quality gates - -Before declaring a scenario done: -- `npm run type-check` passes clean. -- Scenario loads in the dev server and routes correctly from the campaign page. -- An `*-full-completion.spec.ts` Playwright test passes (see [scenario9-full-completion.spec.ts](../../e2e/specs/scenario9-full-completion.spec.ts) for the qualified-operator pattern with multi-station and antenna-repoint helpers). -- A mission brief MDX exists at `signal-range-docs/src/content/docs/campaign-1/scenario-N.mdx`. - ---- - -## Variation Pattern - -*Challenge types are varied across phases to prevent predictability.* - -| Position | Phase 1 (built) | Phase 2 (qualified ops) | Phase 3 (crisis ops) | -|----------|-----------------|-------------------------|----------------------| -| +1 (9, 17) | First Day | Morning Rounds | Solar Event | -| +2 (10, 18) | Power | Customer Pass | Satellite Anomaly | -| +3 (11, 19) | Weather | Maintenance arc — out | Train the New Hire | -| +4 (12, 20) | Calculate | Maintenance arc — return | Dual Outage | -| +5 (13, 21) | Multi-Carrier | Thermal Anomaly | Hostile RF | -| +6 (14, 22) | Step-Track | Rain Fade | End-of-Life Planning | -| +7 (15, 23) | Transmit | Frequency Coordination | Emergency Bypass | -| +8 (16, 24) | Fault | Cascade Failure | Constellation Crisis | - ---- - -## NICE Code Reference - -*Refreshed to reflect actual codes used across S1-S24. Sorted by NICE work role.* - -### Network Operations (IO-WRL-004) - -| Code | Description | First introduced | Reinforced in | -|------|-------------|------------------|---------------| -| K0689 | Knowledge of network infrastructure principles and practices | S3 | S14, S17 | -| K0718 | Knowledge of network communications principles and practices | S11 | S12 | -| K0721 | Knowledge of risk management principles and practices | S14 | S18, S22 | -| K0737 | Knowledge of bandwidth management tools and techniques | S5 | S15 | -| K0740 | Knowledge of system performance indicators | S1 | S9, S13 | -| K0741 | Knowledge of system availability measures | S1 | S9, S11 | -| K0751 | Knowledge of system threats | S17 | S18, S22 | -| K0770 | Knowledge of system administration principles and practices | S2 | — | -| K0773 | Knowledge of telecommunications principles and practices | S1 | S4, S5, S15 | -| K0792 | Knowledge of network configurations | S2 | S15 | -| K0926 | Knowledge of signal jamming tools and techniques | S21 | — | -| K1032 | Knowledge of satellite-based communication systems and software | S1 | S4, S6, S18 | -| S0077 | Skill in securing network communications | S7 | — | -| S0421 | Skill in operating network equipment | S1 | All phase 1 | -| S0582 | Skill in troubleshooting system performance | S5 | S8 | -| S0648 | Skill in detecting anomalies | S15 | S21 | -| S0675 | Skill in optimizing system performance | S3 | S10, S14 | -| S0815 | Skill in troubleshooting network equipment | S8 | — | -| T0081 | Diagnose network connectivity problems | S8 | S16, S21 | -| T0129 | Integrate new systems into existing network architecture | S11 | S12 | -| T0153 | Monitor network capacity and performance | S1 | S9, S10, S17 | -| T1143 | Develop network backup and recovery procedures | S15 | — | -| T1313 | Test network infrastructure | S7 | — | -| T1314 | Maintain network infrastructure | S12 | S13, S18 | - -### System Administration (IO-WRL-005) - -| Code | Description | First introduced | Reinforced in | -|------|-------------|------------------|---------------| -| K0064 | Knowledge of performance tuning tools and techniques | S13 | — | -| S0424 | Skill in executing command line tools | S23 | — | -| S0593 | Skill in handling incidents | S11 | S14, S16, S20, S21 | -| S0671 | Skill in implementing contingency and recovery plans | S20 | S23 | -| S0672 | Skill in troubleshooting failed system components | S13 | — | -| S0677 | Skill in recovering failed systems | S16 | S20, S24 | -| T1538 | Resolve customer-reported system incidents | S8 | S16, S20, S24 | -| T1567 | Configure system hardware, software, peripheral equipment | S2 | S12, S19 | -| T1588 | Diagnose faulty system and server hardware | S23 | — | - -### Technical Support (IO-WRL-007) - -| Code | Description | First introduced | Reinforced in | -|------|-------------|------------------|---------------| -| S0478 | Skill in providing customer support | S10 | — | -| T1580 | Monitor client-level computer system performance | S10 | — | - -### Systems Testing and Evaluation (DD-WRL-007) - -| Code | Description | First introduced | Reinforced in | -|------|-------------|------------------|---------------| -| T0531 | Troubleshoot hardware/software interoperability problems | S16 | S20, S23 | -| T1020 | Determine operational and safety impacts | S17 | — | - -### Data Analysis (IO-WRL-001) - -| Code | Description | First introduced | Reinforced in | -|------|-------------|------------------|---------------| -| T1429 | Prepare trend analysis reports | S22 | S24 | - -### Cross-Cutting / Other - -| Code | Description | First introduced | Reinforced in | -|------|-------------|------------------|---------------| -| K0645 | Knowledge of standard operating procedures (SOPs) | S1 | All scenarios | -| S0615 | Skill in protecting a network against malware | S21 | — | -| S0807 | Skill in solving problems | S16 | S20, S24 | -| T0431 | Check system hardware availability and integrity | S1 | S9, S12 | -| T1144 | Implement network backup and recovery procedures | S20 | S24 | -| T1334 | Produce cybersecurity instructional materials | S19 | — | -| T1411 | Deliver technical training to customers | S19 | — | -| T1606 | Prepare impact reports | S22 | S24 | - -### Coverage Summary - -- **Network Operations (IO-WRL-004):** 24 codes covered across the campaign. Heaviest representation — this is the primary NICE work role NATS targets. -- **System Administration (IO-WRL-005):** 9 codes covered. Concentrated in S11-S13, S16-S20, S23-S24. -- **Technical Support (IO-WRL-007):** 2 codes, both introduced in S10. Reinforcement happens implicitly in S14, S16, S20, S24 via customer-facing situations. -- **Systems Testing (DD-WRL-007):** 2 codes. S16 introduces interoperability troubleshooting; S17 introduces operational impact assessment. -- **Data Analysis (IO-WRL-001):** 1 code (T1429) — trend reporting, S22. -- **Cross-cutting / instructional:** T1334 and T1411 enter via S19's mentoring scenario. diff --git a/docs/nats/nats-character-guide.md b/docs/nats/nats-character-guide.md deleted file mode 100644 index c18826dd..00000000 --- a/docs/nats/nats-character-guide.md +++ /dev/null @@ -1,375 +0,0 @@ -# NATS Character Guide - -**Document Type:** Campaign Character & Dialog Reference -**Campaign:** North Atlantic Teleport Services (NATS) -**Last Updated:** January 2026 - -This guide provides all context needed to write or revise dialog for SignalRange training scenarios featuring Charlie Brooks as the mentor character. Use this when creating new scenarios or refining existing ones. - ---- - -## 1. Character Profile: Charlie Brooks - -### 1.1 Background - -| Attribute | Value | -|-----------|-------| -| **Role** | Senior Operator at North Atlantic Teleport Services (NATS) | -| **Experience** | 6 years at NATS, deep operational expertise | -| **Situation** | Transferring to a European ground station (unspecified). Family reasons. | -| **Training Load** | Has 3 new hires to train before he leaves, including the player | -| **Arc** | Mentors player through Phase 1 (Scenarios 1-8), then departs | - -### 1.2 Personality & Motivation - -- **Stressed but competent:** Busy, limited time, not going to waste it -- **Professional pride:** Sees poor trainee performance as a reflection of his teaching ability -- **Not emotionally invested:** This isn't his company. He wants you to succeed, but he's not your friend or cheerleader -- **Pragmatic teacher:** Shares just enough context to make lessons stick, no more -- **Dry humor:** Occasional, understated - never forced -- **Direct communication:** Short sentences when giving instructions. Says "Go." not "Whenever you're ready, please proceed." - -### 1.3 What Charlie IS NOT - -- Warm and nurturing -- Overly patient or encouraging -- Emotionally invested in your career -- Going to repeat himself -- Your buddy - -### 1.4 Speech Patterns - -- Terse when giving instructions -- Slightly more expansive when explaining *why* something matters -- Uses consequences to frame importance ("dBs are money", "ruin your day", "before the customer does") -- Shares war stories sparingly and briefly ("I've seen guys reach into the waveguide...") -- Acknowledges correct answers without gushing ("Good start", "Right answer", "That's solid") -- Dismisses you casually when done - he has his own work - -### 1.5 Example Phrases - -``` -"Point is, I've got three of you to get up to speed before I leave, and not a lot of time to do it." -"I'm not repeating myself, but the system will." -"Don't be that person." -"You did fine." -"Go get some coffee or something. I've got logs to finish." -"Go." -``` - ---- - -## 2. Other Characters - -### 2.1 Catherine Vega (Maine Operator) - -| Attribute | Value | -|-----------|-------| -| **Role** | Operator at ME-02 (Maine backup station) | -| **Appears in** | Scenario 3 (Weather Handover) and later | -| **Personality** | Professional, helpful, provides sanity checks | - -**Speech patterns:** -- Collaborative approach -- Offers practical validation ("I did a quick sanity check...") -- Casual but competent - -### 2.2 Dana Torres (Shift Supervisor) - -| Attribute | Value | -|-----------|-------| -| **Role** | Shift Supervisor at NATS | -| **Appears in** | Scenario 7+ | -| **Personality** | Peer-level, slightly skeptical of new hire's readiness | - -**Speech patterns:** -- "Just making sure neither of us gets in trouble" -- Professional distance -- Checks in at key decision points - ---- - -## 3. Dialog Structure - -### 3.1 Format - -Dialog is stored in a `dialogClips` object with: -- `intro`: Plays at scenario start -- `objectives`: Object keyed by objective ID, each containing dialog that plays after the player completes that objective - -### 3.2 TypeScript Structure - -```typescript -dialogClips: { - intro: { - text: `

...

`, - character: Character.CHARLIE_BROOKS, - emotion: Emotion.CONFIDENT, - audioUrl: getAssetUrl('/assets/campaigns/nats/1/intro.mp3'), - }, - objectives: { - 'objective-id': { - text: `

...

`, - character: Character.CHARLIE_BROOKS, - emotion: Emotion.CONFIDENT, - audioUrl: getAssetUrl('/assets/campaigns/nats/1/obj-objective-id.mp3'), - }, - }, -}, -``` - -### 3.3 Available Emotions - -```typescript -export enum Emotion { - NEUTRAL = 'neutral', - HAPPY = 'happy', - ANGRY = 'angry', - SAD = 'sad', - SURPRISED = 'surprised', - CONCERNED = 'concerned', - CONFIDENT = 'confident', - SKEPTICAL = 'skeptical', - EXCITED = 'excited', - FRUSTRATED = 'frustrated', -} -``` - -### 3.4 Charlie's Typical Emotions - -| Emotion | When to Use | -|---------|-------------| -| **CONFIDENT** | Default for most instructional dialog | -| **NEUTRAL** | Straightforward information delivery | -| **SKEPTICAL** | When player makes a questionable choice (later scenarios) | -| **FRUSTRATED** | When something goes wrong operationally (not at player) | -| **CONCERNED** | During anomaly/troubleshooting scenarios | - -Charlie rarely uses: HAPPY (only genuine satisfaction), EXCITED (almost never), SAD (never), ANGRY (only in serious situations), SURPRISED (only if genuinely unexpected) - -### 3.5 HTML Formatting - -- Use `

` tags for paragraphs -- No other HTML formatting needed -- Template literals (backticks) for multi-line strings - ---- - -## 4. Dialog Flow Pattern - -### 4.1 Critical Rule: Hint BEFORE Question - -The player must answer quiz questions to progress. Charlie should guide them to where they need to look BEFORE the question appears, without revealing the answer. - -### 4.2 Pattern for Each Objective Dialog - -1. **Acknowledge** the previous answer (1-2 sentences) - - Brief, not effusive - - Can add operational context about why that answer matters - -2. **Teach** the next concept (2-4 sentences) - - What is this equipment/indicator? - - Why does it matter operationally? - - What are the possible states/values? - -3. **Navigate** to the correct location (1 sentence) - - Explicitly state which tab to click - - Be specific: "TX Chain tab" not "go check the transmitter" - -4. **Prompt** for what to look for (1-2 sentences) - - Tell them what to find without telling them the answer - - End with a direct prompt or "Go." - -### 4.3 Pattern for Final Objective Dialog - -1. **Acknowledge** the final answer -2. **Summarize** what was covered (brief list) -3. **Set expectation** for next scenario -4. **Dismiss** casually - Charlie has other work - ---- - -## 5. Navigation Reference - -### 5.1 Player UI Buttons (Left Side) - -- **Mission Brief** - Detailed mission background -- **Checklist** - Step-by-step procedures -- **Dialog History** - Replay character instructions -- **Vermont Ground Station (VT-01)** - Access equipment panels -- **Maine Ground Station (ME-02)** - Backup station (scenarios 3+) -- **TIDEMARK-1** - Satellite info - -### 5.2 Ground Station Tabs - -| Tab | Equipment/Functions | -|-----|---------------------| -| **Dashboard** | Overview, alarm aggregation, traffic status | -| **ACU Control** | Antenna pointing, tracking mode, polarization, feed heater | -| **RX Analysis** | LNB, spectrum analyzer, receiver modem, constellation | -| **TX Chain** | BUC, HPA, transmitter modem | -| **GPS Timing** | GPSDO status and timing info | - -### 5.3 Navigation Language Examples - -``` -"Click Vermont Ground Station, then GPS Timing tab." -"Switch to the TX Chain tab." -"Stay on ACU Control." -"Head back to RX Analysis." -"Dashboard tab." -``` - ---- - -## 6. Technical Reference - -### 6.1 Truth Data Sources - -Before writing dialog, verify all technical details against: -- `satellites.ts` - Satellite parameters, frequencies, polarization -- `ground-stations.ts` - Equipment states, settings, values -- `nats-technical-reference.md` - Consolidated frequency and equipment data - -### 6.2 TIDEMARK-1 Frequencies - -> **Source of Truth:** `satellites.ts` and `nats-technical-reference.md` - -| Parameter | Value | -|-----------|-------| -| Beacon RF frequency | **4175.5 MHz** | -| LNB LO frequency | 5250 MHz | -| Beacon IF frequency | **1074.5 MHz** (5250 - 4175.5) | - -The spectrum analyzer shows IF, not RF. - -### 6.3 TIDEMARK-2 Frequencies - -| Parameter | Value | -|-----------|-------| -| Beacon RF frequency | **4180 MHz** | -| LNB LO frequency | 5250 MHz | -| Beacon IF frequency | **1070 MHz** (5250 - 4180) | - -### 6.4 Common Values to Verify - -- GPSDO lock state -- LNB noise temperature (in Kelvin) -- HPA state (enabled, backoff value) -- Antenna tracking mode -- Polarization angle -- Spectrum analyzer center frequency and reference level -- Receiver C/N ratio -- Modulation type (QPSK, etc.) - ---- - -## 7. Campaign Universe - -### 7.1 NATS (North Atlantic Teleport Services) - -- Commercial satellite ground station facility -- Located in rural Vermont (VT-01 primary, ME-02 backup) -- Provides ground segment services for TIDEMARK constellation - -### 7.2 TIDEMARK Constellation - -- Owned by SeaLink Global Communications -- GEO satellites providing maritime broadband across the Atlantic -- TIDEMARK-1: 53°W, 8+ years old, starting to show age -- TIDEMARK-2: 45°W, newly operational - -### 7.3 Player Role - -- New hire at NATS -- Being trained by Charlie before he leaves -- Will eventually operate independently (Phase 2+) - ---- - -## 8. Charlie's Arc Across Scenarios - -### Phase 1: Foundation (Scenarios 1-8) - -Charlie mentors the player through all Phase 1 scenarios. - -| Scenarios | Charlie's Approach | -|-----------|-------------------| -| 1-2 | More explanation, setting baseline expectations | -| 3-5 | Standard teaching, expects basics to be understood | -| 6-8 | Shorter dialog, focuses on new concepts, treats player as more capable | - -### Phase 2-3: Independent Operations (Scenarios 9-24) - -Charlie has departed for Europe. Player operates with: -- Dana Torres (Shift Supervisor) for oversight -- Catherine Vega (Maine Operator) for coordination -- Other new characters TBD - ---- - -## 9. Writing Checklist - -Before submitting dialog: - -### Technical Accuracy -- [ ] Verified all frequencies against `nats-technical-reference.md` -- [ ] Values match: condition params, objective descriptions, dialog text -- [ ] Equipment states match scenario premise - -### Dialog Structure -- [ ] Each objective dialog includes navigation hint (which tab) -- [ ] Hints tell player WHERE to look, not WHAT the answer is -- [ ] Intro establishes scenario context and first task -- [ ] Final dialog summarizes, sets up next scenario, dismisses player - -### Character Voice -- [ ] Charlie's voice is consistent (direct, professional, slightly impatient) -- [ ] No warm/fuzzy language that doesn't fit character -- [ ] Appropriate emotion selected for each clip -- [ ] Other characters (Catherine, Dana) have distinct voices - -### Format -- [ ] All objective IDs match between dialog and objectives array -- [ ] HTML uses only `

` tags -- [ ] Template literals used for multi-line strings -- [ ] Audio URLs follow naming convention - ---- - -## 10. Example: Good vs Bad Dialog - -### ❌ Bad (too warm, too vague) - -``` -Great job on that one! You're really getting the hang of this. -Now, whenever you're ready, take a look at the transmit side of -things and see what you can find out about the amplifier status. -Take your time! -``` - -### ✅ Good (Charlie's voice, specific navigation) - -``` -That's solid. The cooler the LNB runs, the less noise it adds -to your signal. You start seeing that number climb, it's an -early warning. - -Now the HPA - High Power Amplifier. Takes your milliwatt signal -and turns it into hundreds of watts. Also the equipment most -likely to ruin your day if you're not paying attention. - -TX Chain tab. Tell me what state the HPA is in. -``` - ---- - -## 11. Related Documentation - -| Document | Content | -|----------|---------| -| `nats-campaign-plan.md` | 24-scenario structure and progression | -| `nats-technical-reference.md` | Frequencies, ground stations, equipment specs | -| `scenario-development-guide.md` | How to write scenarios, condition types | -| `signalrange-platform-guide.md` | Platform architecture overview | -| `boa-universe-guide.md` | BOA campaign characters (separate campaign) | diff --git a/docs/nats/nats-technical-reference.md b/docs/nats/nats-technical-reference.md deleted file mode 100644 index 361ac6a6..00000000 --- a/docs/nats/nats-technical-reference.md +++ /dev/null @@ -1,312 +0,0 @@ -# NATS Technical Reference - -**Document Type:** Campaign Technical Specifications -**Campaign:** North Atlantic Teleport Services (NATS) -**Last Updated:** January 2026 - ---- - -## 1. Satellite Constellation - -### 1.1 TIDEMARK Satellites - -The NATS campaign uses the TIDEMARK constellation, operated by SeaLink Global Communications for maritime broadband across the Atlantic. - -| Satellite | Position | Orbit Type | Status | Used In | -|-----------|----------|------------|--------|---------| -| TIDEMARK-1 | 53°W | GEO | Operational (8+ years, aging) | Scenarios 1-3, 5, 7 | -| TIDEMARK-2 | 45°W | GEO | Newly operational | Scenario 4 | -| TIDEMARK-3 | 37°W | GEO | Operational | Background reference | -| TIDEMARK-4 | 29°W | GEO | Commissioning | Future scenarios | - -### 1.2 Other Satellites - -| Satellite | Position | Orbit Type | Notes | Used In | -|-----------|----------|------------|-------|---------| -| SES-10 | 67°W | GEO | Third-party satellite | Background/interference | -| AURORA-7 | 190° Az | Geosynchronous (inclined ~3°) | Legacy, 15+ years old | Scenario 6 | - ---- - -## 2. Frequency Plan - -### 2.1 C-Band Allocation - -| Function | Frequency Range | -|----------|-----------------| -| Downlink (space-to-Earth) | 3.7 – 4.2 GHz | -| Uplink (Earth-to-space) | 5.925 – 6.425 GHz | -| Beacons | ~4.1 – 4.2 GHz | - -### 2.2 TIDEMARK-1 Frequencies - -**Source of Truth:** `satellites.ts` - -| Parameter | Value | -|-----------|-------| -| Beacon RF | **4175.5 MHz** | -| TP-1 Uplink Center | 5943 MHz | -| TP-1 Downlink Center | 3718 MHz | -| TP-2 Uplink Center | 5906 MHz | -| TP-2 Downlink Center | 3681 MHz | -| Transponder Bandwidth | 36 MHz | -| Frequency Offset (Uplink - Downlink) | 2225 MHz | - -**Look Angles from VT-01:** -- Azimuth: 161.8° -- Elevation: 34.2° -- Polarization Rotation: 14° - -### 2.3 TIDEMARK-2 Frequencies - -| Parameter | Value | -|-----------|-------| -| Beacon RF | **4180 MHz** | -| TP-1 Uplink Center | 6017 MHz | -| TP-1 Downlink Center | 3792 MHz | -| Transponder Bandwidth | 36 MHz | - -**Look Angles from VT-01:** -- Azimuth: 219.7° -- Elevation: 26.3° -- Polarization Rotation: -25° - -### 2.4 SES-10 Frequencies - -| Parameter | Value | -|-----------|-------| -| Beacon RF | **4178 MHz** | -| TP-1 Uplink Center | 5869 MHz | -| TP-1 Downlink Center | 3644 MHz | - -### 2.5 AURORA-7 Frequencies - -| Parameter | Value | -|-----------|-------| -| Beacon RF | **4165 MHz** | -| Uplink Center | 5830 MHz | -| Downlink Center | 3605 MHz | -| Transponder Bandwidth | 24 MHz (narrower than TIDEMARK) | - ---- - -## 3. Ground Stations - -### 3.1 VT-01 (Vermont) - Primary Station - -| Parameter | Value | -|-----------|-------| -| Station ID | VT-01 | -| Location | Rural Vermont | -| Role | Primary ground station | -| Antenna | 9-meter C-band | - -**LNB Configuration:** -| Parameter | Value | -|-----------|-------| -| LO Frequency | 5250 MHz | -| Gain | 60 dB | -| Noise Temperature | ~55 K (typical) | - -**BUC Configuration:** -| Parameter | Value | -|-----------|-------| -| LO Frequency | 4900 MHz | - -### 3.2 ME-02 (Maine) - Backup Station - -| Parameter | Value | -|-----------|-------| -| Station ID | ME-02 | -| Location | ~150 miles from Vermont | -| Role | Backup for weather failover | -| Antenna | 9-meter C-band | - -**LNB Configuration:** Same as VT-01 (5250 MHz LO, 60 dB gain) - -**BUC Configuration:** Same as VT-01 (4900 MHz LO) - -> **Note:** Both stations use identical LO frequencies so downstream IF frequencies match, simplifying handover procedures and reducing configuration errors. - ---- - -## 4. IF Frequency Calculations - -### 4.1 Receive Path (Downconversion) - -The LNB downconverts RF to IF using high-side mixing: - -``` -IF = LO - RF -``` - -**With LNB LO = 5250 MHz:** - -| Satellite | Beacon RF | Beacon IF | Calculation | -|-----------|-----------|-----------|-------------| -| TIDEMARK-1 | 4175.5 MHz | **1074.5 MHz** | 5250 - 4175.5 | -| TIDEMARK-2 | 4180 MHz | **1070 MHz** | 5250 - 4180 | -| SES-10 | 4178 MHz | **1072 MHz** | 5250 - 4178 | -| AURORA-7 | 4165 MHz | **1085 MHz** | 5250 - 4165 | - -**Downlink IF (for receiver modem):** - -| Satellite | Downlink RF | Downlink IF | Calculation | -|-----------|-------------|-------------|-------------| -| TIDEMARK-1 TP-1 | 3718 MHz | **1532 MHz** | 5250 - 3718 | -| TIDEMARK-2 TP-1 | 3792 MHz | **1458 MHz** | 5250 - 3792 | -| AURORA-7 | 3605 MHz | **1645 MHz** | 5250 - 3605 | - -### 4.2 Transmit Path (Upconversion) - -The BUC upconverts IF to RF using low-side mixing: - -``` -RF = IF + LO -IF = RF - LO -``` - -**With BUC LO = 4900 MHz:** - -| Satellite | Uplink RF | TX IF | Calculation | -|-----------|-----------|-------|-------------| -| TIDEMARK-1 TP-1 | 5943 MHz | **1043 MHz** | 5943 - 4900 | -| TIDEMARK-2 TP-1 | 6017 MHz | **1117 MHz** | 6017 - 4900 | -| AURORA-7 | 5830 MHz | **930 MHz** | 5830 - 4900 | - -> **Scenario 3 uses different TX IF:** The Maine transmitter modem is configured for 1094 MHz IF in scenario 3. This suggests either a different transponder or a scenario-specific configuration. Verify against scenario file if needed. - ---- - -## 5. Equipment Default States - -### 5.1 Operational State (Scenario 1) - -When TIDEMARK-1 is online and serving traffic: - -| Equipment | State | -|-----------|-------| -| GPSDO | Locked, providing 10 MHz reference | -| LNB | Powered, thermally stable, LO 5250 MHz | -| BUC | Powered, unmuted, LO 4900 MHz | -| HPA | Enabled, ~10 dB backoff | -| Antenna | Step-track or program-track on TIDEMARK-1 | -| RX Modem | Locked, receiving traffic | -| TX Modem | Transmitting | - -### 5.2 Safe State (Switchover/Maintenance) - -Before switching satellites or during maintenance: - -| Equipment | State | -|-----------|-------| -| HPA | Disabled (no RF output) | -| BUC | Muted | -| Antenna | May remain pointed or commanded to stow | -| Modems | May continue operating (no RF path) | - -### 5.3 Cold Standby (Backup Station) - -Backup station waiting for activation: - -| Equipment | State | -|-----------|-------| -| GPSDO | Locked (always on) | -| LNB | Powered off | -| BUC | Powered off or muted | -| HPA | Disabled | -| Antenna | Stowed or last known position | - ---- - -## 6. Modem Configuration Reference - -### 6.1 TIDEMARK-1 Standard Configuration - -**Receiver Modem:** -| Parameter | Value | -|-----------|-------| -| Frequency | 1532 MHz (IF) | -| Bandwidth | 36 MHz | -| Modulation | QPSK | -| FEC | 3/4 | - -**Transmitter Modem:** -| Parameter | Value | -|-----------|-------| -| Frequency | 1043 MHz (IF) | -| Bandwidth | 36 MHz | -| Modulation | QPSK | -| FEC | 3/4 | -| Power | -7 dBm (typical) | - -### 6.2 AURORA-7 Configuration - -**Receiver Modem:** -| Parameter | Value | -|-----------|-------| -| Frequency | 1645 MHz (IF) | -| Bandwidth | 24 MHz | -| Modulation | QPSK | -| FEC | 3/4 | - -**Transmitter Modem:** -| Parameter | Value | -|-----------|-------| -| Frequency | 930 MHz (IF) | -| Bandwidth | 24 MHz | -| Modulation | QPSK | -| FEC | 3/4 | - ---- - -## 7. Antenna Tracking Modes - -| Mode | Description | Use Case | -|------|-------------|----------| -| **Stow** | Antenna parked in safe position | Maintenance, severe weather | -| **Manual** | Operator-commanded position | Initial acquisition, troubleshooting | -| **Program-Track** | Follows predicted orbital elements | GEO satellites with good TLE | -| **Step-Track** | Uses beacon signal for continuous adjustment | Inclined orbits, aging satellites | - -**AURORA-7 requires step-track** because its inclined orbit causes it to trace a figure-8 pattern in the sky. Program-track predictions aren't accurate enough. - ---- - -## 8. Quick Reference Card - -### Beacon IF Frequencies (LNB LO = 5250 MHz) - -| Satellite | Beacon IF | -|-----------|-----------| -| TIDEMARK-1 | 1074.5 MHz | -| TIDEMARK-2 | 1070 MHz | -| SES-10 | 1072 MHz | -| AURORA-7 | 1085 MHz | - -### TX IF Frequencies (BUC LO = 4900 MHz) - -| Satellite | TX IF | -|-----------|-------| -| TIDEMARK-1 | 1043 MHz | -| TIDEMARK-2 | 1117 MHz | -| AURORA-7 | 930 MHz | - -### Key Formulas - -``` -Receive: IF = LO - RF (high-side LNB) -Transmit: IF = RF - LO (low-side BUC) - RF = IF + LO -``` - ---- - -## 9. Related Documentation - -| Document | Content | -|----------|---------| -| `nats-campaign-plan.md` | 24-scenario structure and progression | -| `nats-character-guide.md` | Charlie Brooks dialog writing guide | -| `scenario-development-guide.md` | How to write scenarios | -| `signalrange-platform-guide.md` | Platform architecture overview | diff --git a/docs/nice-framework-guide.md b/docs/nice-framework-guide.md index d77df320..89dfad72 100644 --- a/docs/nice-framework-guide.md +++ b/docs/nice-framework-guide.md @@ -36,6 +36,7 @@ For each objective: | `tab-active` | S0421 | Navigating equipment panels | | `equipment-powered` | T0431 | Checking hardware availability | | `status-check` (quiz) | K-codes based on question content | Knowledge demonstration | +| `decision` | S0477, S0648, S0593 plus the domain codes of the facts it reads | Judgement under ambiguity (fault, interference or attack) | | `signal-detected` | T0153 | Monitoring performance | | `mission-brief-opened` | K0645 | SOP familiarity | @@ -233,6 +234,7 @@ For each objective: | `tab-active` | S0421 | Navigating equipment panels | | `equipment-powered` | T0431 | Checking hardware availability | | `status-check` (quiz) | K-codes based on question content | Knowledge demonstration | +| `decision` | S0477, S0648, S0593 plus the domain codes of the facts it reads | Judgement under ambiguity (fault, interference or attack) | | `signal-detected` | T0153 | Monitoring performance | | `mission-brief-opened` | K0645 | SOP familiarity | diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md new file mode 100644 index 00000000..d27db0a6 --- /dev/null +++ b/docs/plugin-development-guide.md @@ -0,0 +1,208 @@ +# Plugin development guide + +SignalRange plugins are small git repositories that the engine compiles into its +own bundle. A plugin registers things through a fixed API at boot: today that is +antenna hardware configs (with an optional custom core class), routes, and +campaigns. Everything a plugin adds shows up wherever the engine already looks, +so a plugin antenna appears in every sandbox's loadout picker with no UI work. + +The toolchain is a port of keeptrack-space's `scripts/plugin/` CLI. If you have +used that, the commands and files are the same with `keeptrack` replaced by +`signal-range`. + +## Quick start + +```bash +# In a SignalRange checkout +pnpm install + +# Scaffold a plugin (creates src/plugins-external/signal-range-plugin-my-dish) +pnpm run plugin -- create my-dish + +# Boot the app with only this plugin enabled, in a sandbox, with its antenna selected +pnpm run plugin -- dev signal-range-plugin-my-dish + +# Validate and test it +pnpm run plugin -- doctor signal-range-plugin-my-dish +pnpm run plugin -- test signal-range-plugin-my-dish + +# Publish: the folder is its own git repo +cd src/plugins-external/signal-range-plugin-my-dish +git remote add origin git@github.com:you/signal-range-plugin-my-dish.git +git push -u origin main +``` + +Users install a published plugin with: + +```bash +pnpm run plugin -- add https://github.com/you/signal-range-plugin-my-dish +pnpm run build # or restart pnpm run dev +``` + +The public template repo, `thkruz/signal-range-plugin-example`, is the rendered +output of `create` and can be used with GitHub's "Use this template" button when +you do not have an engine checkout to hand. + +## What gets committed where + +| File | Lives in | Committed | Purpose | +|---|---|---|---| +| `src/plugins-external//` | engine | no (gitignored) | The plugin clone; its own git repo | +| `external-plugins.json` | engine root | yes | Lockfile: url, ref, pinned commit per plugin | +| `src/plugins/plugin-manifest.external.generated.ts` | engine | yes | Generated descriptors; empty upstream | +| `signal-range-plugin.json` | plugin | yes | The plugin's manifest | + +`pnpm run plugin -- sync` reconciles the three: it restores missing clones from +the lockfile and regenerates the manifest from what is on disk. `prebuild` and +`predev` run it, so a fresh checkout that committed the lockfile rebuilds the +same plugin set. `sync --check` fails when the committed file has drifted. + +## The manifest + +```json +{ + "formatVersion": 1, + "name": "signal-range-plugin-my-dish", + "version": "0.1.0", + "description": "A 4.5 m Ka-band dish.", + "author": "You", + "repository": "https://github.com/you/signal-range-plugin-my-dish", + "engine": ">=1.1.0 <2.0.0", + "plugins": [ + { + "id": "MyDishPlugin", + "className": "MyDishPlugin", + "entry": "src/plugin.ts", + "defaultConfig": { "enabled": true, "order": 500 }, + "dependencies": [] + } + ], + "localesDir": "locales", + "provides": { "antennas": ["MY_DISH_ANTENNA"] } +} +``` + +- `name` must equal the install directory name and be kebab-case. +- `engine` is a semver range checked against the engine's `package.json` + version. A same-major mismatch warns; a different major refuses to install + unless `--force`. +- Each `plugins[]` entry becomes one descriptor. `id` is the plugin's key in + settings and locales and MUST equal the class's `readonly id`. `className` + MUST be a named export of `entry`. +- `provides.antennas` is informational: `doctor` checks the ids appear in the + source, and `dev` selects the first one in the sandbox it opens. + +## The plugin class + +```ts +import { type PluginApi, SignalRangePlugin } from '@app/plugins/signal-range-plugin'; + +export class MyDishPlugin extends SignalRangePlugin { + readonly id = 'MyDishPlugin'; + + register(api: PluginApi): void { + api.antennas.register('MY_DISH_ANTENNA', config, { source: `plugin:${this.id}` }); + } + + dispose(): void { + // optional + } +} +``` + +`register()` runs once at boot, after the plugin's locale bundle is merged and +before the router resolves the first route, so anything registered here exists +before any scenario builds equipment. It may be async. A plugin that throws is +logged and skipped; boot never fails because of a plugin. + +### PluginApi + +| Member | What it is | +|---|---| +| `engineVersion` | The host version string | +| `isPrivate`, `isAuthoring` | Build flags | +| `antennas` | `AntennaRegistry`: `register(id, config, { source, core? })`, `unregister`, `get`, `find`, `has`, `list` | +| `router` | `Router`: `addRoute({ pattern, show, hide })` | +| `campaigns` | `CampaignManager`: `registerCampaign(data)` | +| `settings` | `SettingsManager` (read your own config with `settings.settings.plugins[id]`) | +| `events` | The application `EventBus` | +| `t7e` | Translate; your strings live under `plugins..*` | + +The api is additive: members are only ever added within a major version. + +### Antennas + +`register(id, config)` takes an `AntennaConfig` (see +`src/equipment/antenna/antenna-configs.ts` for every field). Ids are upper +snake case and must not collide with a built-in or another plugin; a plugin may +re-register its own id (hot reload) but never someone else's. + +Pass `core` to supply a factory that builds a custom `AntennaCore` subclass. The +factory is used for the headless variant Mission Control instantiates; the +DOM-bearing UI classes stay the engine's. This is the hook for behaviour that a +config cannot express (electronic beam steering, multi-beam terminals). + +### Locales + +Put strings in `locales/en.json`, rooted at `plugins.`: + +```json +{ "plugins": { "MyDishPlugin": { "antennaName": "My Dish 4.5m Ka-Band" } } } +``` + +The generated manifest wires the file in; the PluginManager merges it before +`register()` runs, so `api.t7e('plugins.MyDishPlugin.antennaName')` works +inside `register()`. Only English is bundled today; the machinery is ready for +more. + +## Trying it in a sandbox + +Every campaign sandbox has a LOADOUT control in the command bar. It lists every +registered antenna, built-in and plugin, per station. Applying a choice restarts +the sandbox from a clean store; the choice is remembered per scenario in +localStorage. For a one-shot launch (e2e specs, the dev harness) append +`?antenna=` to the sandbox URL; `?antenna.1=` targets the second +station. + +## The dev harness and the settings override + +`pnpm run plugin -- dev ` boots the app with a settings override that +enables only your plugin (`isStrictPluginList`), opens a sandbox, and selects +your first antenna. The override travels as `?settingsOverride=` +and is also honoured from `window.settingsOverride` (set by an init script). +Overrides are session-only; user toggles in the dev menu persist in +localStorage. + +`dev --url` prints the URL without starting anything, which is handy for a +browser you already have open. + +## Testing + +Plugin unit tests live in `__tests__/` and run under the engine's vitest with +the same jsdom environment and aliases (`pnpm run plugin -- test `). They +are excluded from the engine's own suite. The scaffolded test shows the pattern: +register with a minimal `PluginApi`, then assert against the registry and a +headless antenna built from your config. + +The scaffolded CI workflow checks out the engine at the version your manifest +targets, installs your plugin with `add`, and runs typecheck and tests. + +## Things to know + +- **The engine type-checks plugin sources.** `tsconfig.json` includes + `src/plugins-external/**`, so a type error in an installed plugin fails + `pnpm run typecheck` and the pre-commit hook until it is fixed or the plugin is + removed. That is deliberate: a plugin that does not compile cannot ship. +- **No runtime dependencies.** The build does not compile a plugin's + `node_modules`. Use the engine's dependencies. +- **Class names survive minification.** Production builds keep class names, so + resolving `className` by string is safe. +- **Lint stops at the plugin boundary.** Biome excludes `src/plugins-external/`, + so third-party code is not held to the engine's rules. +- **HMR is tolerated.** The registry lets a plugin replace its own ids, so an + edit under `pnpm run dev` does not throw on re-registration. + +## Not yet available to plugins + +Mission Control tabs, objective condition types, fault types, and an in-app +plugin manager UI. Each is an additive `PluginApi` member for a later phase. diff --git a/docs/scenario-development-guide.md b/docs/scenario-development-guide.md index aa4ade1a..da7fb72e 100644 --- a/docs/scenario-development-guide.md +++ b/docs/scenario-development-guide.md @@ -490,7 +490,7 @@ timePenalty: { | Condition | Description | Key Params | |-----------|-------------|------------| | `receiver-signal-locked` | Demodulation lock | `modemNumber` | -| `receiver-snr-threshold` | C/N ratio meets threshold | `minCNRatio`, `modemNumber` | +| `receiver-snr-threshold` | C/N ratio meets threshold (or sits at or below `maxCNRatio`) | `minCNRatio`, `maxCNRatio`, `cnHoldSeconds`, `modemNumber` | | `rx-modem-frequency-set` | Center frequency set | `frequency`, `frequencyTolerance` | | `rx-modem-bandwidth-set` | Bandwidth set | `bandwidth`, `bandwidthTolerance` | | `rx-modem-modulation-set` | Modulation type set | `modulation` | @@ -541,6 +541,109 @@ timePenalty: { | `fault-active` | Fault is injected | `faultId` | | `fault-cleared` | Fault has been cleared | `faultId` | +### 9.13 Decision Conditions + +A `status-check` asks *what is true?* and grades against a fixed correct option. A `decision` asks +*what do you do?* and grades the chosen option against **evidence facts read from the simulation at +the moment of the answer** — then does something about it. The same option set has a different +right answer in a scenario with a different interference schedule or injected fault. + +| Condition | Description | Key Params | +|-----------|-------------|------------| +| `decision` | Judgement graded on live evidence, with consequences | `prompt`, `decisionOptions`, `evidence`, `pointPenalty`, `partialCreditUnevidenced` | + +```typescript +{ + type: 'decision', + description: 'Call it: fault, interference, or attack', + params: { + character: Character.PRIYA_SHARMA, + prompt: 'C/N on GW-01 has dropped 6 dB and is holding. What is this?', + // ids of requiresObservation conditions in this objective the player must latch first + evidence: ['cn-read', 'speca-read', 'crypto-read'], + decisionOptions: [ + { label: 'Equipment fault - open a maintenance ticket', + correctWhen: { fact: 'equipment-fault-active', is: true } }, + { label: 'Interference - characterise it and report', + correctWhen: { all: [{ fact: 'interference-active', is: true }, { fact: 'crypto-intact', is: true }] }, + consequence: { activateObjective: 'characterise-event' } }, + { label: 'Intrusion - escalate to security, hold commanding', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { auditEvent: { id: 'op-escalation', actor: 'operator', action: 'Escalated to CSIRT', category: 'access', severity: 'warning' } } }, + { label: 'Propagation - log it and continue', + correctWhen: { fact: 'weather-attenuation-dominant', is: true }, + consequence: { destroyEvidence: { auditEventId: 'probe-0314' } } }, + ], + explanation: 'Interference with crypto intact is RF denial, not intrusion.', + pointPenalty: 10, + partialCreditUnevidenced: 0.5, + }, + mustMaintain: false, +} +``` + +**Evidence facts** (the only vocabulary `correctWhen` may use): `interference-active`, +`equipment-fault-active`, `crypto-intact`, `gnss-constellation-healthy`, `timing-drifting`, +`reference-in-holdover`, `weather-attenuation-dominant`, `audit-anomaly-present`, `config-drifted`, +`command-window-open`, `uplink-jammed`, `evidence-chain-intact`, `soh-red-limit`, `soh-yellow-limit`, +`telemetry-stale`, `transponder-interference-active`, `terrestrial-interference-active`. Every fact is held for the observation grace period so a +transient frame cannot flip the right answer, and `interference-active` reads the event *envelope* +(a duty-cycled jammer counts in its off phase too); the `transponder-` / `terrestrial-interference-active` +pair splits that read by `path`, which is the uplink-versus-downlink call. `uplink-jammed` needs `settings.commanding.uplinkFrequencyHz` +and a transponder-path interference event on the target bird; TRANSEC hop-sync clears it. + +**Evidence gating.** `evidence` names sibling conditions by their `id`. The options stay clickable +before those latch, but a correct answer given early scores only `partialCreditUnevidenced`. The +modal shows the checklist live. Give the sibling conditions an `id` and `requiresObservation`. + +### 9.14 Spacecraft Telemetry and Ranging + +`settings.telemetry` starts `TelemetryManager` and the read-only **Telemetry** tab: channels grouped +by subsystem with nominal values, noise, yellow/red limit bands and scripted `excursions` on the +mission clock. Frames flow only while an antenna on the station is locked on the bird +(`antennaIndex` picks one); a dropped link freezes the last values and the stream reads STALE. + +| Condition | Description | Key Params | +|-----------|-------------|------------| +| `telemetry-frames-received` | At least `minFrames` frames have arrived | `minFrames` | +| `telemetry-channel-in-band` | A channel reads in the given band (stale streams never match) | `channelId`, `telemetryBand` | +| `telemetry-soh-nominal` | Every channel green and the stream fresh | - | +| `ranging-measurements` | Ranging tones ACKed through the command path | `minCount` | + +Facts: `soh-red-limit`, `soh-yellow-limit` (a channel at that band or worse, stream fresh) and +`telemetry-stale`. Pair the channel read with `requiresObservation` on the `telemetry` tab so a +state-of-health decision is graded on what the operator looked at. + +Ranging is `settings.commanding.ranging: { requiredMeasurements, toneId? }`: the TT&C console gains a +TONE control; each tone is gated like a command and, when it ACKs, records the true slant range to +the target. `ranging-measurements` (default `minCount` = `requiredMeasurements`) grades the pass. + +### 9.15 Campaign Record + +A scenario's Working Document is filed into a campaign-scoped store (`CampaignDocumentStore`, +localStorage, keyed by campaign id) when the scenario completes, together with any audit-event ids a +`destroyEvidence` consequence removed during the run. Later scenarios read it back: + +| Condition | Description | Key Params | +|-----------|-------------|------------| +| `campaign-document-reviewed` | The Campaign Record panel (sidebar, next to Working Doc) has been opened this run | - | + +An audit entry can carry evidence forward with `requiresCampaignEvidence: { scenarioId, eventId }`. +If the record says that evidence was destroyed there, the entry is dropped from this scenario's log at +load and the `evidence-chain-intact` fact reads false - so a decision can grade "attributable" against +"unattributable" on what the player actually preserved. Scenarios not completed in this browser show +their canonical `documentLine`s in the panel, marked "as filed", so a fresh S22 still has S17-S21 to read. + +**Consequences** fire on whichever option is chosen, right or wrong — that is the teaching. Keys: +`inject` (a `FAULT_TEMPLATES` key), `clearFault`, `startInterference` / `stopInterference` (an +`interferenceEvents` id, overriding its schedule), `auditEvent`, `destroyEvidence`, +`activateObjective`, `deactivateObjective`, `pointDelta`, `log`. A per-scenario budget (default 2) +caps live injected faults plus forced interference so wrong answers cannot cascade. + +**Option discipline.** The 2026-09-17 uniformity rule applies: every option in one shape and length +band, the correct one never the most detailed. A wrong answer costs `pointPenalty` and leaves the +decision open; a wrong option that carries a consequence still fires it. + --- ## 10. Condition Maintenance Flags diff --git a/docs/toolchain-migration-plan.md b/docs/toolchain-migration-plan.md new file mode 100644 index 00000000..7ea79ddf --- /dev/null +++ b/docs/toolchain-migration-plan.md @@ -0,0 +1,408 @@ +# Toolchain migration plan: align signal-range with keeptrack-space + +> **STATUS 2026-09-12: all eight phases are done and committed on `dev`.** +> Phase 0 needed no work (tree was clean). The only deliberate deferral is the +> Biome **warning** backlog (Phase 2 step 6), which gates nothing. See the +> "Outcome" section at the end for what was measured and what differed from +> this plan. + +Written 2026-09-10. Reference repo: `D:\code\keeptrack\keeptrack-space` (v13.10.0). +keeptrack.space did this same migration in 2026 in this order: pnpm, Biome, CI, tsgo, +rspack/SWC, hooks. Its commit history is the playbook (see "Prior art" at the end). + +## Target state (what keeptrack-space runs) + +| Concern | signal-range today | Target (keeptrack-space) | +|---|---|---| +| Package manager | npm + `package-lock.json` | pnpm 10.33 (`nodeLinker: hoisted`), `pnpm-lock.yaml` | +| Node | CI: 20 (deploy) / 22 (build); no pin | Node 24 (`.nvmrc`, `engines`, `volta`) | +| Lint | ESLint 9 with **one rule** (`import/no-unresolved`) | Biome 2.5.4, full rule set + custom `require-unicode-regexp.grit` plugin | +| Format | none | Biome formatter (2-space, single quotes, 180 cols, LF) | +| Typecheck | `tsc --noEmit` (incremental) | `tsgo --noEmit` (TS 7 native) with `tsc` fallback script | +| Bundler | webpack 5 + `ts-loader` | rspack 2 + `builtin:swc-loader`, persistent cache | +| Unit tests | vitest 4 + jsdom | same (already aligned; small config deltas) | +| E2E | Playwright | same | +| Git hooks | husky installed, **no hooks** | pre-commit (lint+typecheck), commit-msg (conventional), pre-push (lint+typecheck+test) | +| Line endings | no `.gitattributes` | `.gitattributes` forcing LF | +| Editor | ESLint code actions | Biome formatter + code actions | + +### Baseline measurements (this machine, 2026-09-10) + +| Step | Current | Notes | +|---|---|---| +| `npm run lint` (ESLint) | 1.3 s | Only one rule is enabled, so this is not a real gate. | +| `tsc --noEmit` (cold) | 3.1 s | Small project; tsgo gain will be modest. | +| `npm run build` (webpack prod) | 14.7 s | SWC should cut this to a few seconds; dev rebuilds are the bigger win. | +| Biome `check` dry run, keeptrack config | 0.7 s | 476 files (src + test, engine and private excluded). | + +The speed gain on signal-range is real but modest at this size. The larger effect of the +migration is that signal-range gets an actual lint gate, a formatter, and hooks for the first +time, with the same rules as keeptrack so the two codebases stop drifting. + +## Biome dry run against signal-range (keeptrack's `biome.json` verbatim) + +Lint: 105 errors, 1156 warnings. Format: 400 of 476 files need reformatting. +Import order: 179 files. + +| Rule | Count | Level | Auto-fix | +|---|---|---|---| +| assist/source/organizeImports | 179 | error | yes | +| style/useConsistentArrowReturn | 43 | error | yes | +| style/useExponentiationOperator | 39 | error | yes | +| style/useShorthandAssign | 8 | error | yes | +| style/noCommonJs | 6 | error | manual | +| style/useConst | 4 | error | yes | +| style/useCollapsedElseIf | 2 | error | yes | +| style/useGroupedAccessorPairs | 2 | error | manual | +| complexity/noArguments | 1 | error | manual | +| style/useBlockStatements | 611 | warn | yes (unsafe) | +| complexity/useLiteralKeys | 178 | warn | yes (unsafe) | +| suspicious/noExplicitAny | 85 | warn | manual | +| plugin require-unicode-regexp | 53 | warn | manual (add `u`) | +| suspicious/noEmptyBlockStatements | 51 | warn | manual | +| suspicious/useIterableCallbackReturn | 48 | warn | manual | +| correctness/noUnusedInstantiation | 33 | warn | manual | +| style/useTemplate | 25 | warn | yes (unsafe) | +| style/useDefaultSwitchClause | 17 | warn | manual | +| suspicious/useAwait | 17 | warn | manual | +| suspicious/noAlert | 14 | warn | manual | +| style/noNestedTernary | 10 | warn | manual | +| remaining 8 rules | 12 | warn | mixed | + +Only errors fail `biome check` (and therefore hooks and CI). After `biome check --write`, the +manual error backlog is 9 diagnostics. Warnings are cleared area by area afterwards, exactly as +keeptrack did in its `clear the biome warnings in ...` commit series. + +## Sequencing constraint: commit in-flight work first + +The format commit touches 400 of 476 files. `PROJECT_STATE.md` lists several finished but +UNCOMMITTED phases and `git status` shows modified files under `src/`. Land all of that on `dev` +before Phase 2 step 2, or every open branch has to be rebased through a whole-repo reformat. + +Every phase below is its own commit series (or PR) on `dev`. Do not combine phases. + +--- + +## Phase 1: npm to pnpm (about 1 hour) + +Mirrors keeptrack commits `776ab9c2`, `518d7fae`, `49b220fd`, `7ba4c431`. + +1. Add `pnpm-workspace.yaml` copied from keeptrack (`nodeLinker: hoisted`, + `strictPeerDependencies: false`, `autoInstallPeers: true`). Hoisted layout keeps webpack and + later rspack loaders resolving without changes. +2. `package.json`: add `"packageManager": "pnpm@10.33.0"`, `engines` (node >=24, pnpm >=10), + `volta.node = 24.x`. Move npm `overrides` to `pnpm.overrides`. Add + `pnpm.ignoredBuiltDependencies: ["esbuild", "core-js"]`. Decide on `supabase`: its postinstall + downloads the CLI binary. CI already installs with `--ignore-scripts`; locally list it under + `pnpm.onlyBuiltDependencies` if the CLI is wanted, otherwise under `ignoredBuiltDependencies`. +3. `pnpm import` to build `pnpm-lock.yaml` from `package-lock.json`, then delete + `package-lock.json`. `pnpm install --frozen-lockfile` must pass. +4. Scripts: `npm run` becomes `pnpm run`; `npx eslint` / `npx tsx` become `pnpm exec ...` + (keeptrack `ad23f8f5`). Playwright `webServer.command` becomes `pnpm run dev`. +5. Add `.nvmrc` with `24`. +6. CI: rewrite `.github/actions/setup-node-project/action.yml` to keeptrack's `node-setup` + shape (pnpm/action-setup@v4, setup-node with `cache: pnpm`, node 24, + `pnpm install --frozen-lockfile`). `deploy-pipeline.yml` has two inline copies of the old + setup (node 20, `npm ci --ignore-scripts`) in `build-production` and `build-uat`; switch both + to the composite action. `npm audit` becomes `pnpm audit --audit-level=critical`. + `npx playwright install` becomes `pnpm exec playwright install`. +7. Docs: README, CLAUDE.md, CODE_PATTERNS.md, deploy.md, DEPLOYMENT_CONFIGURATION.md, + e2e-attestation workflow text (`npm run test:e2e`). + +Verify: clean clone, `pnpm install --frozen-lockfile`, `pnpm run build`, `pnpm test`, +`pnpm run test:e2e` smoke, Build Pipeline green. + +## Phase 2: ESLint to Biome (half a day for errors; warnings cleared over time) + +Mirrors keeptrack `fc1d40d2`, `0f799c53`, `13228213`, `e0f40a29`, `36bab5f2`, `82c6fbbc`, +`ed285220`, then the per-area warning commits. + +1. **Config commit** `chore(biome): replace ESLint with Biome` + - Copy `biome.json` from keeptrack verbatim, then edit only `files.includes`: + `src/**/*.{js,ts,tsx}`, `test/**/*.{js,ts,tsx}`, `e2e/**/*.ts`, `build/**/*.ts`, + `!src/engine/**` (vendored engine, already ESLint-ignored), `!src/private/**` + (submodule; see gotchas), `!**/*.min.*`. Drop keeptrack's `globals` entries + `settingsManager` and `M`; keep `noUndeclaredVariables: off` so the build-time defines + declared in `src/declaration.d.ts` need no globals list. + - Copy `biome-plugins/require-unicode-regexp.grit` unchanged. + - Dev deps: add `@biomejs/biome` pinned exactly to `2.5.4` (same as keeptrack, no caret). + Remove `eslint`, `eslint-plugin-import`. Delete `.eslintrc` and `eslint.config.mjs`. + - Scripts: `lint: biome check ./src ./test ./e2e ./build`, `lint:fix: biome check --write ...`, + `format: biome format --write ...`. + - `.vscode/settings.json`: replace the ESLint `codeActionsOnSave` and `eslint.execArgv` + entries with keeptrack's Biome block (`[typescript]` default formatter `biomejs.biome`, + `source.fixAll.biome`, `source.organizeImports.biome`, `files.eol: \n`). Add + `.vscode/extensions.json` recommending `biomejs.biome`. +2. **Mechanical commit** `style(biome): format and auto-fix src with Biome` + - `pnpm exec biome check --write ./src ./test ./e2e ./build`. Then `pnpm test` and + `pnpm run typecheck`. No hand edits in this commit. + - Decide separately whether to run `--write --unsafe` for `useBlockStatements` (611), + `useLiteralKeys` (178) and `useTemplate` (25). Keeptrack did apply autofixes broadly + (`e0f40a29`); review the diff for `useLiteralKeys` because bracket access on private + members is sometimes intentional in tests (keeptrack later turned that rule off in tests, + `36bab5f2`, and the copied config already carries that override). +3. **Manual error cleanup** (9 diagnostics): `noCommonJs` (6, `require(` in `src`), + `useGroupedAccessorPairs` (2), `noArguments` (1). Lint must exit 0 here. +4. **Remove the 29 `eslint-disable` comments.** They are dead text under Biome. Where a + suppression is still needed use `// biome-ignore lint//: `. +5. **CI**: lint job runs `pnpm run lint`; update the step summary text. +6. **Warnings, by area, later** (each its own `refactor(): :recycle: clear the biome + warnings ...` commit as keeptrack did): regex `u` flag (53), `noExplicitAny` (85), + `noEmptyBlockStatements` (51), `useIterableCallbackReturn` (48), and the rest. Not a blocker + for anything else in this plan. + +Gotchas: +- `src/private` is a git submodule with its own repo. Biome walks into it unless force-ignored, + and a nested `biome.json` there breaks the parent lint run (keeptrack `82c6fbbc`). Exclude it + here and give `signal-range-private` its own `biome.json` linted by its own CI + (keeptrack `7869f6d2`). +- `scripts/*.js` are CommonJS one-offs (four are jest-era migration scripts that can be + deleted). Keep `scripts/**` out of `files.includes` for now, same as keeptrack lints only + `src/**`. +- Biome does not type-check. `noUnusedVariables` is a warning; the real unused-code gate stays + `noUnusedLocals`/`noUnusedParameters` in tsconfig. + +## Phase 3: tsc to tsgo for typecheck (about 1 hour) + +Mirrors keeptrack `8e175de1`. + +1. Add `@typescript/native-preview` pinned to keeptrack's exact version + (`7.0.0-dev.20260707.2`). Keep `typescript` 5.9 for the IDE and as fallback. +2. tsconfig changes TS 7 requires (see keeptrack `tsconfig.base.json`): + - `moduleResolution: "node"` becomes `"bundler"` (node10 was removed). + - Remove `baseUrl`; make `paths` relative to the config (`"./src/*"`). + - Add `isolatedModules: true` (also required for SWC in Phase 4; it surfaces ambiguous + type re-exports before they break the bundle). + - Raise `target`/`lib` to `es2022` to match keeptrack and the SWC target. + - The emit options (`declaration`, `sourceMap`, `outDir`, `rootDir`, `removeComments`) are + only used by `ts-loader` today and become dead in Phase 4. Keep them until Phase 4, then + drop them. +3. Scripts: rename `type-check` to `typecheck` (keeptrack name, referenced by hooks and CI), + `typecheck: tsgo --noEmit -p tsconfig.json`, `typecheck:tsc: tsc --noEmit -p tsconfig.json`. + Update the CI type-check job and any docs that say `npm run type-check`. +4. Verify `tsgo` honours `tsconfig.performance.json` (`incremental`, `.tsbuildinfo`); if not, + drop the incremental settings, since tsgo is fast enough cold. +5. Optional: `typecheck:test: tsgo --noEmit -p tsconfig.test.json` so the 153 test files get + type-checked in CI. Nothing checks them today. + +## Phase 4: webpack + ts-loader to rspack + SWC (half a day including verification) + +Mirrors keeptrack `4d85b5e4`, `ea6c4002`, `6b9b7444`, `99554f92`. Source of truth for every +option below: `keeptrack-space/build/webpack-manager.ts` (loader rules around lines 260-315, +cache at 326-350). + +signal-range has one edition plus the private flag, so a single `rspack.config.ts` replaces +`webpack.config.js`. Do not port keeptrack's multi-profile `build-manager.ts`. + +Mapping from the current `webpack.config.js`: + +| webpack today | rspack | +|---|---| +| `html-webpack-plugin` (two instances) | `rspack.HtmlRspackPlugin` (`template`, `chunks`, `filename` all supported) | +| `copy-webpack-plugin` | `rspack.CopyRspackPlugin` (same `patterns`/`globOptions.ignore`) | +| `webpack.DefinePlugin` | `rspack.DefinePlugin`, same entries | +| `case-sensitive-paths-webpack-plugin` | no equivalent; drop it. Linux CI builds catch casing errors. | +| `ts-loader` | `builtin:swc-loader` (`jsc.parser.syntax: typescript`, `jsc.target: es2022`) | +| `style-loader` + `css-loader` (with the `url.filter` for `/fonts/`) | keep both, bump to css-loader 7 / style-loader 4 (keeptrack versions); the filter still applies | +| `asset/resource` rules | unchanged | +| `devtool: source-map` | unchanged for dev; keeptrack uses `hidden-source-map` + `SwcJsMinimizerRspackPlugin` + `LightningCssMinimizerRspackPlugin` in prod, adopt those | +| `devServer` (`static`, `port 3000`, `hot`, `historyApiFallback` rewrites, `setupMiddlewares`) | same keys under `@rspack/dev-server`; `devServer.app` is still the Express app the private `dev-middleware.cjs` registers on | +| `dotenv` loading, `IS_PRIVATE` / `IS_AUTHORING` detection | unchanged, config is still Node code | +| no cache | persistent cache in `node_modules/.cache/rspack`, disabled when `process.env.CI` (keeptrack 99554f92) | + +Steps: +1. Dev deps: add `@rspack/core` and `@rspack/cli` (`^2.0.5`). Remove `webpack`, `webpack-cli`, + `webpack-dev-server`, `ts-loader`, `html-webpack-plugin`, `copy-webpack-plugin`, + `case-sensitive-paths-webpack-plugin`, `file-loader` (already unused). +2. Write `rspack.config.ts` (ESM imports; `@rspack/cli` loads TS configs directly). Leave + `package.json` without `"type": "module"` so the CJS scripts keep working. +3. Scripts: `build: cross-env NODE_ENV=production rspack build --mode production`, + `dev: cross-env NODE_ENV=development rspack serve --mode development`. +4. Drop the tsconfig emit options noted in Phase 3. +5. Verify, in this order: + - `pnpm run build`: `dist/index.html`, `dist/auth/callback.html`, hashed `main.*.js` and + `popup-callback.*.js`, `dist/fonts/`, `dist/images/`, `dist/assets/characters/`, + `dist/logo.png` all present. Diff the file list against a webpack build from before. + - Build-time defines: `__APP_VERSION__`, `__GIT_COMMIT_SHA__`, `__IS_PRIVATE__`, + `__AUTHORING__`, `process.env.PUBLIC_*` resolve in the bundle (grep the output). + - `pnpm run dev`: port 3000, `/auth/callback` served from the HTML file, HMR works. + - Private edition (Ted only): authoring middleware still registers under `NODE_ENV=development`. + - Full Playwright suite (it boots the dev server), then a wrangler `deploy --dry-run`. +6. Record before/after build times in this document. + +### Measured results (this machine, 2026-09-12) + +| Step | webpack + ts-loader | rspack + SWC | Change | +|---|---|---|---| +| Production build, cold | 12.9 s | 0.96 s | 13x faster | +| Production build, warm (persistent cache) | n/a (no cache) | 0.20 s | 65x vs webpack cold | +| Dev server first compile | ~11 s | 0.43 s | 25x faster | +| `typecheck` (tsc -> tsgo) | 4.4 s | 0.6 s | 7x faster | + +`dist/` was compared against a webpack build taken before Phase 1. After excluding +the `.d.ts`/`.d.ts.map` files ts-loader used to emit (753 files of pure build noise +that never belonged in `dist/`), the two outputs match file for file, with two +benign differences: the async chunk is renumbered (644 -> 783), and webpack's +`main.js.LICENSE.txt` sidecar is gone because SWC keeps the `/*! */` license +banners inline in the bundle instead of extracting them. No attribution is lost. + +Gotchas: +- SWC strips types without checking them. Type safety is now only `pnpm run typecheck`, which + Phase 5's hooks and the existing CI job enforce. Do Phase 3 before Phase 4. +- `isolatedModules` (Phase 3) is mandatory; SWC cannot see across files, so + `export { SomeType }` without `type` fails at bundle time. +- The private submodule's TypeScript goes through SWC too; if it uses `const enum` or + namespace merging across files it needs fixing there. + +## Phase 5: git hooks (about 1 hour) + +`.husky/` exists with only the `_` runtime; no hooks and no `prepare` script, so nothing runs +today. Copy from keeptrack: + +- `pre-commit`: `pnpm run lint` + `pnpm run typecheck`, silent unless failing. Copy verbatim + (rename the KeepTrack wording in the footer). +- `commit-msg`: conventional-commit subject check. signal-range already uses + `type(scope): :emoji: message` (see git log and the Copilot instructions in + `.vscode/settings.json`), so this only formalises current practice. +- `pre-push`: lint + typecheck + `pnpm run test`. The suite is about 4,700 tests; time it. If it + is more than a couple of minutes locally, keep the hook but consider `vitest run --changed`. +- Skip keeptrack's `post-checkout`/`post-commit`/`post-merge` hooks; they are git-lfs only. +- `package.json`: `"prepare": "husky install"`, `husky` dev dep at keeptrack's `^8` (or 9 with + `"prepare": "husky"`; pick one and match keeptrack when it upgrades). +- Add `.gitattributes` from keeptrack (`* text=auto`, LF for text, binary lists). One + renormalisation commit: `git add --renormalize .`. This pairs with Biome's + `lineEnding: lf` so Windows checkouts stop producing CRLF diffs. + +## Phase 6: vitest config alignment (about 1 hour) + +Already on vitest 4 + `@vitest/coverage-v8` 4, so this is small: + +- `test.deps.inline` is the pre-vitest-1 key; move to `server.deps.inline` and confirm vitest 4 + still needs it for `uuid` and `ootk`. +- Add keeptrack's `maxWorkers: Math.min(12, availableParallelism() - 1)` cap. It exists because + Windows forks OOM on a 32-core box during pre-push runs (keeptrack `0a878970`). +- Coverage: `include: ['src/**/*.ts']`, exclude tests, `reportOnFailure: true`, and + `thresholds` set at current actuals (run `pnpm run test:coverage` once to baseline), then + ratchet upward like keeptrack does. +- Keep tests in `test/` (not colocated); no reason to move them. + +## Phase 7: CI workflow parity (about 1 hour, mostly done in Phases 1-3) + +- `build-pipeline.yml`: lint job runs Biome, type-check job runs `pnpm run typecheck`, test job + unchanged apart from pnpm, security-audit uses `pnpm audit`. Bump `actions/checkout` and + `actions/setup-node` to the majors keeptrack uses (v6). +- `deploy-pipeline.yml`: both build jobs on the composite action (Node 24, pnpm). +- Keep yamllint, TruffleHog, CodeQL, dependency-review, E2E attestation as they are. +- Wrangler deploy is untouched; only `dist/` matters to it. + +## Phase 8: docs and cleanup (about 1 hour) + +- CLAUDE.md: copy keeptrack's "every regex MUST have the `u` flag" section and its build-time + constants note (rspack `DefinePlugin`, then `vitest.config` `define`, then `declaration.d.ts`), + replace `npm` and `eslint` mentions. +- README / CODE_PATTERNS.md / deploy.md: pnpm commands. +- Delete `.tsbuildinfo` from the tree if tsgo does not use it, and the four jest-era + `scripts/fix-*.js` / `migrate-jest-to-vitest.js` one-offs. +- `.deepsource.toml` still lists a React plugin; harmless, leave or delete. + +## Out of scope (deliberate) + +- semantic-release, auto-changelog, `version` script, CITATION updates: signal-range has no + release or npm-publish process. Separate decision. +- typedoc, tsup `build:lib`, SonarQube, coverage merge scripts, multi-profile builds. +- `signal-range-home` (Astro, ESLint 8 + Prettier) and `signal-range-docs` (Astro): Biome only + partially formats `.astro` files, so those repos need their own evaluation. +- `signal-range-private`: gets its own `biome.json` in its own repo after Phase 2. + +## Recommended order and effort + +| # | Phase | Effort | Depends on | +|---|---|---|---| +| 0 | Commit all in-flight work on `dev` | - | - | +| 1 | pnpm | 1 h | 0 | +| 2 | Biome (config, autofix, errors) | 3-4 h | 0, 1 | +| 7 | CI parity for 1-2 | 30 min | 1, 2 | +| 3 | tsgo + tsconfig modernisation | 1 h | 1 | +| 4 | rspack + SWC | 4 h | 3 | +| 5 | hooks + `.gitattributes` | 1 h | 2, 3 | +| 6 | vitest deltas | 1 h | 1 | +| 8 | docs and cleanup | 1 h | all | +| - | Biome warning backlog by area | ongoing | 2 | + +About two working days for everything except the warning backlog. + +## Prior art: keeptrack-space commits to crib from + +``` +776ab9c2 chore(pnpm): migrate package manager from npm to pnpm +518d7fae chore(ci): run CI and release on pnpm +49b220fd chore(pnpm): move pnpm settings to pnpm-workspace.yaml +fc1d40d2 chore(biome): replace ESLint 8 + Prettier with Biome +0f799c53 chore(ci): run lint via Biome in CI +13228213 style(biome): format and auto-fix src with Biome +e0f40a29 style(lint): apply Biome autofixes +36bab5f2 chore(biome): disable useLiteralKeys in test files +82c6fbbc fix(biome): force-ignore plugins-pro so nested config stops breaking lint +7869f6d2 chore(ci): lint plugins-pro with its own Biome config in CI +ed285220 chore(biome): exclude vendored sources and restore the 8-param limit +8e175de1 chore(typescript): run typecheck + declaration emit on TypeScript 7 native (tsgo) +4d85b5e4 perf(build): transpile with rspack SWC instead of ts-loader +ea6c4002 perf(build): drop babel-loader; let rspack SWC transpile node_modules JS +6b9b7444 chore(build): remove unused Babel toolchain +99554f92 perf(build): scope source-map-loader to src + add persistent rspack cache +0a878970 chore(vitest): cap fork concurrency to prevent memory issues +ad23f8f5 refactor(dev-server): replace npx with pnpm exec for commands +``` + +--- + +## Outcome (2026-09-12) + +| Phase | Commit | Result | +|---|---|---| +| 1 pnpm | `8615799` | pnpm 10.33 hoisted, node 24 via volta, CI on pnpm | +| 2 Biome | `6f71d91`, `45bc98e`, `b36db21`, `951b927` | lint gate exits 0 for the first time | +| 3 tsgo | `b245595` | typecheck 4.4s -> 0.6s | +| 4 rspack + SWC | `a01cced` | build 12.9s -> 0.96s (0.20s warm) | +| 5 hooks | `27d364d` | pre-commit / commit-msg / pre-push all live | +| 6 vitest | `ec4ada6` | worker cap, real coverage gate | +| 7 CI parity | `f15dc43` | checkout/setup-node v6 | +| 8 docs + cleanup | `ce81d2a` | CLAUDE.md rules, jest-era files deleted | + +### Where reality differed from the plan + +- **`@rspack/dev-server` has no Express `app`.** The plan assumed `devServer.app` + stayed an Express instance for the private authoring middleware; it is not, and + registering on it crashed the dev server outright. The config now creates its + own Express app and mounts it through `setupMiddlewares`. `express` is an + explicit devDependency again (webpack-dev-server used to supply it). +- **tsgo does not auto-include `@types`.** `types: ["node"]` had to be declared + explicitly or `process` and the `NodeJS` namespace go unresolved. `tsc` never + needed this, so it only shows up once tsgo runs. +- **`isolatedModules` found five real type re-exports**, in the agc-module and + notch-filter-module barrels. These would have broken the Phase 4 SWC bundle, + which is exactly why Phase 3 comes first. +- **Two `CopyRspackPlugin` patterns were already dead.** `public/assets/logo.png` + does not exist and `public/assets/characters` holds only `.png` files that the + pattern's own ignore list excludes. copy-webpack-plugin stayed silent; + CopyRspackPlugin errors. Both kept with `noErrorOnMissing`. +- **No renormalisation commit was needed.** Biome's Phase 2 format pass had + already written every file as LF, so `git add --renormalize .` was a no-op. +- **`deps.inline` is obsolete.** vitest 4 runs the full suite green without the + `uuid`/`ootk` inlining, so it was deleted rather than moved to + `server.deps.inline`. +- **husky is v9, not keeptrack's v8.** The `.husky/_` runtime already in the repo + was the v9 layout, so hooks omit the `_/husky.sh` sourcing line that v10 will + reject. +- **`typecheck:test` exists but is not a CI gate.** The 153 test files have never + been type-checked and currently produce 660 errors. Turning that into a gate is + its own piece of work. + +### Still open + +- Biome warning backlog: 1346 warnings, none of which gate anything. Largest + groups are `useBlockStatements` (622), `useLiteralKeys` (178), the regex `u` + flag plugin (110) and `noExplicitAny` (98). Clear by area, as keeptrack did. +- 660 type errors in `test/**`, gated behind `pnpm run typecheck:test`. +- CI has not run yet; the `actions/*@v6` bump and the pnpm/Biome/tsgo jobs need a + green Build Pipeline to confirm. diff --git a/e2e/fixtures/test-fixtures.ts b/e2e/fixtures/test-fixtures.ts index f243fcf0..205ccbf8 100644 --- a/e2e/fixtures/test-fixtures.ts +++ b/e2e/fixtures/test-fixtures.ts @@ -1,7 +1,7 @@ import { test as base } from '@playwright/test'; import { CampaignSelectionPage } from '../pages/campaign-selection.page'; -import { ScenarioSelectionPage } from '../pages/scenario-selection.page'; import { MissionControlPage } from '../pages/mission-control.page'; +import { ScenarioSelectionPage } from '../pages/scenario-selection.page'; /** * Custom fixtures for SignalRange e2e tests. diff --git a/e2e/pages/base.page.ts b/e2e/pages/base.page.ts index 2752be31..8022fdd6 100644 --- a/e2e/pages/base.page.ts +++ b/e2e/pages/base.page.ts @@ -1,4 +1,4 @@ -import { Page, Locator } from '@playwright/test'; +import { Locator, Page } from '@playwright/test'; /** * Abstract base class for all page objects. @@ -46,9 +46,6 @@ export abstract class BasePage { * Wait for navigation to complete after an action. */ protected async waitForNavigation(action: () => Promise): Promise { - await Promise.all([ - this.page.waitForURL(/.*/), - action(), - ]); + await Promise.all([this.page.waitForURL(/.*/), action()]); } } diff --git a/e2e/pages/campaign-selection.page.ts b/e2e/pages/campaign-selection.page.ts index d67b7373..e3bab970 100644 --- a/e2e/pages/campaign-selection.page.ts +++ b/e2e/pages/campaign-selection.page.ts @@ -1,4 +1,4 @@ -import { Page, Locator, expect } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './base.page'; /** @@ -82,7 +82,7 @@ export class CampaignSelectionPage extends BasePage { * Check if the login warning is visible. */ async isLoginWarningVisible(): Promise { - const display = await this.loginWarning.evaluate(el => getComputedStyle(el).display); + const display = await this.loginWarning.evaluate((el) => getComputedStyle(el).display); return display !== 'none'; } } diff --git a/e2e/pages/mission-control.page.ts b/e2e/pages/mission-control.page.ts index b72ed109..0cc21609 100644 --- a/e2e/pages/mission-control.page.ts +++ b/e2e/pages/mission-control.page.ts @@ -1,4 +1,4 @@ -import { Page, Locator, expect } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './base.page'; /** @@ -196,10 +196,12 @@ export class MissionControlPage extends BasePage { const exactTab = this.tabBar.locator(`.nav-link[data-tab-id="${tabId}"]`); const prefixTab = this.tabBar.locator(`.nav-link[data-tab-id^="${tabId}-"]`); - if (await exactTab.count() > 0) { - await exactTab.click(); + // DOM click: a quiz modal or dialog that surfaces mid-step must not hold a + // pointer click hostage until the test times out (phase 16 S12 finding). + if ((await exactTab.count()) > 0) { + await exactTab.evaluate((el) => (el as HTMLElement).click()); } else { - await prefixTab.first().click(); + await prefixTab.first().evaluate((el) => (el as HTMLElement).click()); } } diff --git a/e2e/pages/scenario-selection.page.ts b/e2e/pages/scenario-selection.page.ts index 7d3e7f14..3d9b7511 100644 --- a/e2e/pages/scenario-selection.page.ts +++ b/e2e/pages/scenario-selection.page.ts @@ -1,4 +1,4 @@ -import { Page, Locator, expect } from '@playwright/test'; +import { expect, Locator, Page } from '@playwright/test'; import { BasePage } from './base.page'; /** diff --git a/e2e/specs/campaign-chrome-identity.spec.ts b/e2e/specs/campaign-chrome-identity.spec.ts new file mode 100644 index 00000000..0b0e858f --- /dev/null +++ b/e2e/specs/campaign-chrome-identity.spec.ts @@ -0,0 +1,210 @@ +import { expect, Page, test } from '@playwright/test'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Chrome identity across all five campaigns. + * + * Every campaign runs the same engine and the same components; what separates + * them is a body class and the CSS hanging off it. That makes the whole thing + * invisible to unit tests and easy to break from a distance - phase 8 shipped + * with the command bar frozen to Campaign 1's grey because a token resolved + * where it was declared instead of where it was used, and screenshots did not + * catch it (a dark grey bar on a dark blue body looks plausible). + * + * So this asserts resolved values and geometry, not appearance: + * + * - `standard` (C1, C2) - identity/clock lead the bar, readable clock, + * hairline panels + * - `sdr` (C3) - same layout, monospace chrome, cyan accent + * - `astro` (C4, C5) - Astro UXDS console: year/day-of-year clock, + * classification bands top and bottom, Roboto chrome + * (not condensed), hairline panels, identity left + * + * Two things stay put in every variant and are asserted as such: the asset tree + * on the left and the tab strip above the canvas. Both were mirrored/moved in + * earlier revisions and both read as a broken build rather than a different + * system - they are navigation, and novelty there costs orientation. + * + * C4 and C5 must agree on every structural value and differ only in hue; C1 + * and C2 likewise. That relationship is the point of the feature, so it is + * asserted directly at the end rather than left to inspection. + */ + +interface ChromeProbe { + bodyClasses: string[]; + /** Asset tree stays left of the canvas in every variant */ + sidebarIsLeftOfCanvas: boolean; + /** Identity/clock block sits to the right of the timers (tactical) */ + commandBarMirrored: boolean; + /** Tab strip sits below the canvas content */ + tabsBelowContent: boolean; + /** Satellites section is painted above the ground stations section */ + satellitesAboveStations: boolean; + clock: string; + objectiveTimerLabel: string; + accent: string; + barBg: string; + faceplateBg: string; + chromeFont: string; + panelBorderWidth: string; + classificationTop: string | null; + classificationBottom: string | null; + classificationBg: string; +} + +const CAMPAIGNS = { + nats: { scenario: 'nats-sandbox', variant: 'standard' }, + 'nats-eu': { scenario: 'nats-eu-sandbox', variant: 'standard' }, + 'ham-sdr': { scenario: 'ham-sdr-sandbox', variant: 'sdr' }, + ccs: { scenario: 'ccs-scenario1', variant: 'astro' }, + 'signal-hunter': { scenario: 'signal-hunter-sandbox', variant: 'astro' }, +} as const; + +type CampaignId = keyof typeof CAMPAIGNS; + +async function probeChrome(page: Page): Promise { + return page.evaluate(() => { + const box = (selector: string): DOMRect | null => document.querySelector(selector)?.getBoundingClientRect() ?? null; + + const sidebar = box('#asset-tree-sidebar-container'); + const canvas = box('#tabbed-canvas-container'); + const tabBar = box('#tab-bar'); + const content = box('#canvas-content'); + const satellites = box('.asset-group-satellites'); + const stations = box('.asset-group-stations'); + const barLeft = box('.command-bar-left'); + const barRight = box('.command-bar-right'); + + const styles = getComputedStyle(document.body); + const read = (name: string): string => styles.getPropertyValue(name).trim(); + + const header = document.querySelector('.app-shell-header'); + const card = document.querySelector('.card'); + + // The classification bands are ::before/::after on the page, so there is + // no element to query - only the pseudo-element's computed style. + const page = document.querySelector('.app-shell-page') as Element; + const top = getComputedStyle(page, '::before'); + const bottom = getComputedStyle(page, '::after'); + + return { + bodyClasses: [...document.body.classList], + sidebarIsLeftOfCanvas: !!sidebar && !!canvas && sidebar.x < canvas.x, + commandBarMirrored: !!barLeft && !!barRight && barLeft.x > barRight.x, + tabsBelowContent: !!tabBar && !!content && tabBar.y > content.y, + satellitesAboveStations: !!satellites && !!stations && satellites.y < stations.y, + clock: document.querySelector('#utc-clock')?.textContent?.trim() ?? '', + objectiveTimerLabel: document.querySelector('#objective-timer-display .timer-label')?.textContent?.trim() ?? '', + accent: read('--mc-accent-red'), + barBg: header ? getComputedStyle(header).backgroundColor : '', + faceplateBg: read('--mc-equip-panel-raised'), + chromeFont: header ? getComputedStyle(header).fontFamily : '', + panelBorderWidth: card ? getComputedStyle(card).borderTopWidth : '', + classificationTop: top.content === 'none' ? null : top.content, + classificationBottom: bottom.content === 'none' ? null : bottom.content, + classificationBg: bottom.backgroundColor, + }; + }); +} + +const probes = new Map(); + +test.describe('Campaign chrome identity', () => { + test.describe.configure({ mode: 'serial' }); + test.setTimeout(120000); + + for (const [campaignId, { scenario, variant }] of Object.entries(CAMPAIGNS)) { + test(`${campaignId} wears the ${variant} chrome`, async ({ page }) => { + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + await page.goto(`/campaigns/${campaignId}/scenarios/${scenario}`); + await waitForSimulationReady(page); + await dismissDialogIfPresent(page); + + const probe = await probeChrome(page); + probes.set(campaignId as CampaignId, probe); + + expect(probe.bodyClasses).toContain(`campaign-${campaignId}`); + expect(probe.bodyClasses).toContain(`chrome-${variant}`); + + // Accent and faceplates must actually resolve - an unset custom property + // reads as an empty string, which is how a whole theme goes missing. + expect(probe.accent).toMatch(/^#[0-9a-f]{6}$/i); + expect(probe.faceplateBg).toMatch(/^#[0-9a-f]{6}$/i); + expect(probe.barBg).not.toBe('rgba(0, 0, 0, 0)'); + + // Navigation does not move between variants: rail left, tabs on top. + expect(probe.sidebarIsLeftOfCanvas).toBe(true); + expect(probe.tabsBelowContent).toBe(false); + + if (variant === 'astro') { + // The TASK/MSN clocks lead the bar (time remaining first - the same + // operational ordering that puts satellites above stations), so the + // identity block paints to the right of the timers. + expect(probe.commandBarMirrored).toBe(true); + expect(probe.satellitesAboveStations).toBe(true); + // `2027 074 22:05:15` - year, day-of-year, UTC + expect(probe.clock).toMatch(/^\d{4} \d{3} \d{2}:\d{2}:\d{2}$/); + expect(probe.objectiveTimerLabel).toBe('TASK'); + expect(probe.chromeFont).toContain('Roboto'); + expect(probe.chromeFont).not.toContain('Roboto Condensed'); + expect(probe.panelBorderWidth).toBe('1px'); + // Banded top and bottom in ladder colors with deliberately fictional + // wording (nothing here is a real marking): C4 wears the Top Secret + // style as "G14 CLASSIFIED", C5 the Secret style as "SPECIAL + // PROGRAM". The color must not follow the campaign accent. + const marking = campaignId === 'ccs' ? { text: 'G14 CLASSIFIED', bg: 'rgb(255, 140, 0)' } : { text: 'SPECIAL PROGRAM', bg: 'rgb(200, 16, 46)' }; + + expect(probe.classificationTop).toContain(marking.text); + expect(probe.classificationBottom).toContain(marking.text); + expect(probe.classificationBg).toBe(marking.bg); + } else { + expect(probe.commandBarMirrored).toBe(false); + expect(probe.satellitesAboveStations).toBe(false); + // `15 MAR 2027 22:05:15` + expect(probe.clock).toMatch(/^\d{2} [A-Z]{3} \d{4} \d{2}:\d{2}:\d{2}$/); + expect(probe.objectiveTimerLabel).toBe('OBJECTIVE'); + expect(probe.panelBorderWidth).toBe('1px'); + expect(probe.classificationTop).toBeNull(); + expect(probe.classificationBottom).toBeNull(); + } + + if (variant === 'sdr') { + expect(probe.chromeFont).toMatch(/mono/i); + } + }); + } + + test('siblings share structure and differ only in hue', () => { + const structural = (p: ChromeProbe) => ({ + commandBarMirrored: p.commandBarMirrored, + satellitesAboveStations: p.satellitesAboveStations, + objectiveTimerLabel: p.objectiveTimerLabel, + chromeFont: p.chromeFont, + panelBorderWidth: p.panelBorderWidth, + // The wording is campaign flavor (G14 CLASSIFIED vs SPECIAL PROGRAM); + // what is structural is that the bands exist at all. + hasClassificationBands: p.classificationTop !== null && p.classificationBottom !== null, + }); + + const nats = probes.get('nats')!; + const natsEu = probes.get('nats-eu')!; + const ccs = probes.get('ccs')!; + const signalHunter = probes.get('signal-hunter')!; + + // Two facilities of one operator + expect(structural(natsEu)).toEqual(structural(nats)); + expect(natsEu.accent).not.toBe(nats.accent); + + // Two squadrons of one service + expect(structural(signalHunter)).toEqual(structural(ccs)); + expect(signalHunter.accent).not.toBe(ccs.accent); + + // ...and the two pairs are not the same system + expect(structural(ccs)).not.toEqual(structural(nats)); + }); +}); diff --git a/e2e/specs/campaign-flow.spec.ts b/e2e/specs/campaign-flow.spec.ts index 6859953b..eeb20cf5 100644 --- a/e2e/specs/campaign-flow.spec.ts +++ b/e2e/specs/campaign-flow.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/test-fixtures'; +import { expect, test } from '../fixtures/test-fixtures'; test.describe('Campaign Flow', () => { test.describe('Campaign Selection', () => { @@ -25,10 +25,7 @@ test.describe('Campaign Flow', () => { }); test.describe('Scenario Selection', () => { - test('should show scenarios after selecting NATS campaign', async ({ - campaignSelectionPage, - scenarioSelectionPage, - }) => { + test('should show scenarios after selecting NATS campaign', async ({ campaignSelectionPage, scenarioSelectionPage }) => { await campaignSelectionPage.goto(); await campaignSelectionPage.selectCampaign('nats'); @@ -82,10 +79,7 @@ test.describe('Campaign Flow', () => { }); test.describe('Scenario Navigation', () => { - test('should navigate to mission control when starting scenario', async ({ - scenarioSelectionPage, - page, - }) => { + test('should navigate to mission control when starting scenario', async ({ scenarioSelectionPage, page }) => { await scenarioSelectionPage.gotoCampaign('nats'); await scenarioSelectionPage.startScenario('nats-scenario1'); diff --git a/e2e/specs/ccs-scenario2-full-completion.spec.ts b/e2e/specs/ccs-scenario2-full-completion.spec.ts new file mode 100644 index 00000000..e86de1a3 --- /dev/null +++ b/e2e/specs/ccs-scenario2-full-completion.spec.ts @@ -0,0 +1,243 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { + advanceMissionClockToElapsed, + answerStatusCheck, + debugObjective, + expectNoMissionFail, + keyUpString, + missionElapsedS, + readAntennaPosition, + resetFault, + selectTxModem, + setHpaEnabled, + slewAntenna, +} from '../utils/ccs-helpers'; +import { objectiveItem, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ccs Scenario 2 "Failover" - full completion. + * + * SANDSTORM (SS-01) denies the COBALT-4 X-band service uplink and holds the + * effect through two scheduled exciter trips (hardwareFaultEvents): JAM-A + * (modem 1) at mission T+480 s and JAM-B (modem 2) at T+840 s. Both strings + * share one BUC/HPA, so every recovery leaves the HPA up and changes strings. + * + * Clock handling: the trips fire on the mission clock (missionNowMs), which + * window.advanceClock jumps, so each trip is brought forward with an + * explicit jump to just past its schedule. The maintain windows (120 / 120 / + * 180 s) tick on REAL time and are waited out on the EA Assessment tab, which + * is the observationTab every jamming-effective condition requires. The + * first hold has to finish before T+480 so the trip does not lapse it, and + * the second before T+840 - the elapsed-time budget is asserted rather than + * assumed. + * + * hold-to-recall is the last REQUIRED objective; cease-fire is isOptional and + * needs the HPA disabled, which would lapse the hold, so it is never driven. + * The Mission Complete modal pops when hold-to-recall completes and freezes + * the checklist, so the last step waits on the modal and asserts cease-fire + * is still open under it. A protected-band hit (8175-8225 MHz) would raise + * #objective-failed-modal; the spec asserts it never appears. + */ +test.describe('ccs Scenario 2 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + /** Wall-clock stamp of scenario boot, for mission-elapsed estimates */ + + const JAM_A_TRIP_S = 480; + const JAM_B_TRIP_S = 840; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ccs', 'ccs-scenario2'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[acknowledge-tasking] reads the shift package and confirms the ROE', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerStatusCheck(page, 'Any jam waveform overlapping 8175-8225 MHz'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Acknowledge the Tasking Order'); + }); + + test('[coordinate-apertures] slews the monitor aperture onto COBALT-4', async () => { + await missionControl.selectGroundStation('SS-01'); + await missionControl.dismissDialogIfPresent(); + + // Monitor aperture (antenna 1) parks at az 90 / el 10; the jam dish is + // already on the bird. Stage +85 az / +40 el and APPLY. + await slewAntenna(page, missionControl, 1, 85, 40); + + // 3 m pedestal slews at 3 deg/s: ~30 s to close 85 deg of azimuth + await expect + .poll( + async () => { + const pos = await readAntennaPosition(page, 1); + return Math.abs(pos.az - 175) <= 2 && Math.abs(pos.el - 50) <= 2; + }, + { timeout: 90000, intervals: [1000] } + ) + .toBe(true); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Coordinate the Apertures', 30000); + }); + + test('[establish-denial] enables the HPA, keys JAM-A, and drives the link to DENIED', async () => { + await setHpaEnabled(page, missionControl, true); + await selectTxModem(page, missionControl, 1); + await keyUpString(page, missionControl, true); + + // Both jamming conditions require observation on the EA Assessment tab + await missionControl.selectTab('ea-assessment'); + await missionControl.dismissDialogIfPresent(); + await expect(page.locator('#ea-status-badge')).toContainText('DENIED', { timeout: 30000 }); + + await expectNoMissionFail(page); + await waitForObjectiveComplete(missionControl, 'Establish the Denial Effect', 30000); + }); + + test('[hold-primary] holds the blackout for 120 s on JAM-A', async () => { + // The first hold must end before the JAM-A trip at T+480 or the trip + // lapses it. Budget: elapsed now + 120 s window + slack. + const elapsed = await missionElapsedS(page); + expect(elapsed + 120, 'hold-primary must finish before the T+480 trip').toBeLessThan(JAM_A_TRIP_S - 20); + + await missionControl.selectTab('ea-assessment'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Hold the Blackout', 160000); + await expectNoMissionFail(page); + }); + + test('[detect-first-trip] JAM-A trips at T+480 and the lapse is recognised', async () => { + await advanceMissionClockToElapsed(page, JAM_A_TRIP_S); + await missionControl.dismissDialogIfPresent(); + + // Still on the EA tab: the badge leaves DENIED once modem 1 stops radiating + await expect(page.locator('#ea-status-badge')).not.toContainText('DENIED', { timeout: 20000 }); + await waitForObjectiveComplete(missionControl, 'Recognise the Lapse', 30000); + }); + + test('[failover-backup] isolates the exciter fault and keys JAM-B with the HPA still up', async () => { + await answerStatusCheck(page, 'The JAM-A exciter tripped; the amplifier is healthy'); + await missionControl.dismissDialogIfPresent(); + + // HPA stays enabled; select the backup string and key it + await selectTxModem(page, missionControl, 2); + await keyUpString(page, missionControl, true); + await expect(page.locator('#hpa-enable')).toBeChecked(); + + await missionControl.selectTab('ea-assessment'); + await missionControl.dismissDialogIfPresent(); + await expect(page.locator('#ea-status-badge')).toContainText('DENIED', { timeout: 30000 }); + + await expectNoMissionFail(page); + try { + await waitForObjectiveComplete(missionControl, 'Diagnose and Fail Over', 30000); + } catch (err) { + console.log('[failover-backup] debugObjective:', JSON.stringify(await debugObjective(page, 'failover-backup'))); + throw err; + } + }); + + test('[hold-backup] holds the blackout for 120 s on JAM-B', async () => { + const elapsed = await missionElapsedS(page); + expect(elapsed + 120, 'hold-backup must finish before the T+840 trip').toBeLessThan(JAM_B_TRIP_S - 20); + + await missionControl.selectTab('ea-assessment'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Hold on the Backup', 160000); + await expectNoMissionFail(page); + }); + + test('[detect-second-trip] JAM-B trips at T+840', async () => { + await advanceMissionClockToElapsed(page, JAM_B_TRIP_S); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#ea-status-badge')).not.toContainText('DENIED', { timeout: 20000 }); + await waitForObjectiveComplete(missionControl, 'Second Trip', 30000); + }); + + test('[recover-string] resets the cooled JAM-A exciter, re-keys it, and restores DENIED', async () => { + const t0 = Date.now(); + const stamp = (label: string) => console.log(`[recover-string] +${((Date.now() - t0) / 1000).toFixed(1)}s ${label}`); + + await answerStatusCheck(page, 'run FAULT RESET with it un-keyed'); + await missionControl.dismissDialogIfPresent(); + stamp('quiz answered'); + + // JAM-A has been un-keyed since its own trip: select it, clear the latched + // fault, then key it behind the still-enabled HPA + await selectTxModem(page, missionControl, 1); + await resetFault(page, missionControl); + stamp('fault reset'); + await keyUpString(page, missionControl, true); + await expect(page.locator('#hpa-enable')).toBeChecked(); + stamp('JAM-A keyed'); + + await missionControl.selectTab('ea-assessment'); + await missionControl.dismissDialogIfPresent(); + await expect(page.locator('#ea-status-badge')).toContainText('DENIED', { timeout: 30000 }); + stamp('DENIED'); + + await expectNoMissionFail(page); + try { + await waitForObjectiveComplete(missionControl, 'Reset a String and Recover', 30000); + } catch (err) { + console.log('[recover-string] debugObjective:', JSON.stringify(await debugObjective(page, 'recover-string'))); + throw err; + } + }); + + test('[hold-to-recall] carries the effect 180 s to the recall and completes the mission', async () => { + await missionControl.selectTab('ea-assessment'); + await missionControl.dismissDialogIfPresent(); + + // Last REQUIRED objective: its completion pops the Mission Complete modal, + // which freezes the checklist, so wait on the modal rather than the row. + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 230000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + expect(parseInt((await totalScore.textContent()) || '0', 10)).toBeGreaterThan(0); + }); + + test('[cease-fire] optional stand-down never gated completion', async () => { + // cease-fire needs the HPA disabled, which would lapse hold-to-recall, so + // it is left untouched: the frozen checklist must still show it open. + const ceaseFire = objectiveItem(missionControl, 'Cease Fire'); + await expect(ceaseFire).toHaveCount(1); + await expect(ceaseFire).not.toHaveClass(/completed/); + }); + + test('protected-band interlock never tripped', async () => { + await expectNoMissionFail(page); + }); +}); diff --git a/e2e/specs/ccs-scenario3-full-completion.spec.ts b/e2e/specs/ccs-scenario3-full-completion.spec.ts new file mode 100644 index 00000000..356ca9e7 --- /dev/null +++ b/e2e/specs/ccs-scenario3-full-completion.spec.ts @@ -0,0 +1,185 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { + advanceMissionClockToElapsed, + answerStatusCheck, + expectNoMissionFail, + keyUpString, + readAntennaPosition, + selectTxModem, + setHpaEnabled, + slewAntenna, +} from '../utils/ccs-helpers'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { closeWorkingDocumentIfOpen, sendCommandAndExpectAck, setTxModemFrequency } from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ccs Scenario 3 "First Shift" - full completion (phase 18 E). + * + * The contractor's first shift: TT&C backup for TALON-2, the unit's own + * X-band relay. Jam strings confirmed cold, monitor aperture slewed from its + * park (az 90 / el 10) onto TALON-2 (az 166 / el 50), telemetry acquired on + * the new Telemetry tab, each subsystem read in band, the state-of-health + * decision graded on fresh frames inside limits, modem 1 retuned to the 1200 + * MHz TT&C IF, HK-DUMP ACKed inside the T+10:00 .. T+40:00 window, the chain + * safed, the contact logged. + * + * Objective flow (12): + * 1. read-the-shift-package 2. jam-strings-cold 3. point-the-monitor + * 4. acquire-telemetry 5. read-the-power-bus 6. read-the-thermal + * 7. read-attitude-and-comm 8. call-state-of-health 9. arm-the-uplink + * 10. housekeeping-dump 11. safe-the-uplink 12. log-and-hand-back + */ +test.describe('ccs Scenario 3 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const readFrames = async (): Promise => parseInt((await page.locator('#tlm-frames').textContent()) ?? '0', 10); + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('ccs', 'ccs-scenario3'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(180000); + }); + + test('[read-the-shift-package] reads the package and confirms the role', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerStatusCheck(page, 'Backup TT&C: telemetry on the monitor aperture'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Shift Package'); + }); + + test('[jam-strings-cold] reads the TX chain cold', async () => { + await missionControl.selectGroundStation('SS-01'); + await missionControl.dismissDialogIfPresent(); + await missionControl.selectTab('tx-chain'); + await expect(page.locator('#hpa-enable')).not.toBeChecked(); + await expect(page.locator('#tx-transmit-switch')).not.toBeChecked(); + await page.waitForTimeout(3000); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Confirm the Jam Strings Are Cold'); + }); + + test('[point-the-monitor] slews the monitor aperture onto TALON-2', async () => { + // Park az 90 / el 10; TALON-2 at az 166.0 / el 49.6. Stage +76 / +40 and APPLY. + await slewAntenna(page, missionControl, 1, 76, 40); + await expect + .poll( + async () => { + const pos = await readAntennaPosition(page, 1); + return Math.abs(pos.az - 166) <= 2 && Math.abs(pos.el - 50) <= 2; + }, + { timeout: 90000, intervals: [1000] } + ) + .toBe(true); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Point the Monitor at TALON-2', 30000); + }); + + test('[acquire-telemetry] the stream goes LIVE and the frame counter climbs', async () => { + await missionControl.selectTab('telemetry'); + await expect(page.locator('#tlm-link-badge')).toHaveText('LIVE', { timeout: 20000 }); + await expect.poll(readFrames, { timeout: 30000 }).toBeGreaterThanOrEqual(10); + await expect(page.locator('#tlm-soh-badge')).toHaveText('NOMINAL'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Acquire Telemetry'); + }); + + test('[read-the-power-bus] reads EPS in band', async () => { + await missionControl.selectTab('telemetry'); + await expect(page.locator('tr[data-channel-id="bus-v"] .tlm-band')).toHaveText('NOMINAL'); + await expect(page.locator('tr[data-channel-id="batt-soc"] .tlm-band')).toHaveText('NOMINAL'); + await page.waitForTimeout(3000); + await answerStatusCheck(page, 'The array is carrying the load and topping the battery'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Power Subsystem'); + }); + + test('[read-the-thermal] reads TCS in band', async () => { + await missionControl.selectTab('telemetry'); + await expect(page.locator('tr[data-channel-id="batt-t"] .tlm-band')).toHaveText('NOMINAL'); + await page.waitForTimeout(3000); + await answerStatusCheck(page, 'The battery: its limits are tight'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Thermal Picture'); + }); + + test('[read-attitude-and-comm] reads ADCS and COMM in band', async () => { + await missionControl.selectTab('telemetry'); + await expect(page.locator('tr[data-channel-id="wheel-rpm"] .tlm-band')).toHaveText('NOMINAL'); + await page.waitForTimeout(3000); + await answerStatusCheck(page, 'Stored momentum: a wheel spins to hold the bird still'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read Attitude and Comms'); + }); + + test('[call-state-of-health] reads the whole panel and calls NOMINAL', async () => { + await missionControl.selectTab('telemetry'); + await expect(page.locator('#tlm-soh-badge')).toHaveText('NOMINAL'); + await expect(page.locator('#tlm-link-badge')).toHaveText('LIVE'); + await page.waitForTimeout(3000); + await answerDecision(page, 'Nominal - every channel inside yellow limits on fresh frames', ['State of health read on Telemetry']); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Call the State of Health'); + }); + + test('[arm-the-uplink] retunes modem 1 to the 1200 MHz TT&C IF', async () => { + await selectTxModem(page, missionControl, 1); + await setTxModemFrequency(page, missionControl, 1200); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Arm the TT&C Uplink'); + }); + + test('[housekeeping-dump] HPA up, modem 1 keyed, HK-DUMP ACKs inside the window', async () => { + await advanceMissionClockToElapsed(page, 600); + await setHpaEnabled(page, missionControl, true); + await keyUpString(page, missionControl, true); + await sendCommandAndExpectAck(page, missionControl, 'HK-DUMP'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Housekeeping Dump'); + }); + + test('[safe-the-uplink] un-keys modem 1 and disables the HPA', async () => { + await keyUpString(page, missionControl, false); + await setHpaEnabled(page, missionControl, false); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[log-and-hand-back] logs the contact and completes', async () => { + await answerStatusCheck(page, '0210Z contact SANDSTORM (backup)'); + await missionControl.dismissDialogIfPresent(); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expectNoMissionFail(page); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/ccs-scenario4-full-completion.spec.ts b/e2e/specs/ccs-scenario4-full-completion.spec.ts new file mode 100644 index 00000000..8714fc23 --- /dev/null +++ b/e2e/specs/ccs-scenario4-full-completion.spec.ts @@ -0,0 +1,161 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToElapsed, answerStatusCheck, expectNoMissionFail, keyUpString, selectTxModem, setHpaEnabled } from '../utils/ccs-helpers'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { closeWorkingDocumentIfOpen, sendCommandAndExpectAck } from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ccs Scenario 4 "State of Health" - full completion (phase 18 E). + * + * Second TALON-2 backup contact. Telemetry acquired with the battery green; + * the wheel transient (T+240..300) read in yellow and named a transient; the + * battery trend (heater stuck from T+420, yellow from ~T+680) read with the + * heater state beside it; the DEGRADED call graded on soh-yellow-limit; + * TCS-HTR-OFF ACKed inside the window; the verification read back in green + * once the recovery ramp (240 s) has run; safe, log, complete. + * + * Clock: excursions and the window run on the mission clock, which + * advanceMissionClockToElapsed jumps; the telemetry manager delivers a fresh + * frame on the next tick after a jump, so every read waits a beat. + * + * Objective flow (10): + * 1. read-the-shift-package 2. acquire-telemetry 3. the-transient + * 4. spot-the-trend 5. call-state-of-health 6. arm-the-uplink + * 7. heater-off 8. verify-the-command 9. safe-the-uplink + * 10. log-and-hand-back + */ +test.describe('ccs Scenario 4 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const band = (channelId: string) => page.locator(`tr[data-channel-id="${channelId}"] .tlm-band`); + const readFrames = async (): Promise => parseInt((await page.locator('#tlm-frames').textContent()) ?? '0', 10); + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('ccs', 'ccs-scenario4'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(180000); + }); + + test('[read-the-shift-package] reads the package and what an ACK verifies', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerStatusCheck(page, 'That the bird received and accepted the frame'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Shift Package'); + }); + + test('[acquire-telemetry] the stream goes LIVE with the battery green', async () => { + await missionControl.selectGroundStation('SS-01'); + await missionControl.dismissDialogIfPresent(); + await missionControl.selectTab('telemetry'); + await expect(page.locator('#tlm-link-badge')).toHaveText('LIVE', { timeout: 20000 }); + await expect.poll(readFrames, { timeout: 30000 }).toBeGreaterThanOrEqual(10); + await expect(band('batt-t')).toHaveText('NOMINAL'); + await page.waitForTimeout(3000); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Acquire Telemetry'); + }); + + test('[the-transient] wheel 1 reads yellow at T+4:00 and is named a transient', async () => { + await advanceMissionClockToElapsed(page, 245, 0); + await missionControl.selectTab('telemetry'); + await expect(band('wheel-rpm')).toHaveText('YELLOW', { timeout: 15000 }); + await page.waitForTimeout(3000); + await answerStatusCheck(page, 'A transient: a momentum bump the wheel absorbed'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Transient on Wheel 1'); + }); + + test('[spot-the-trend] the battery reads yellow with the heater ON beside it', async () => { + await advanceMissionClockToElapsed(page, 720, 0); + await missionControl.selectTab('telemetry'); + await expect(band('batt-t')).toHaveText('YELLOW', { timeout: 15000 }); + await expect(band('htr-state')).toHaveText('YELLOW'); + await expect(band('wheel-rpm')).toHaveText('NOMINAL'); // the transient is gone + await page.waitForTimeout(3000); + await answerStatusCheck(page, 'A stuck heater: the eclipse-exit heater should have cycled off'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Spot the Trend'); + }); + + test('[call-state-of-health] calls DEGRADED with the channel named', async () => { + await missionControl.selectTab('telemetry'); + await expect(page.locator('#tlm-soh-badge')).toHaveText('YELLOW'); + await page.waitForTimeout(3000); + await answerDecision(page, 'Degraded - battery temperature in yellow and climbing', ['Battery temperature read on Telemetry']); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Call the State of Health'); + }); + + test('[arm-the-uplink] HPA up and modem 1 keyed on the TT&C IF', async () => { + await selectTxModem(page, missionControl, 1); + await setHpaEnabled(page, missionControl, true); + await keyUpString(page, missionControl, true); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Arm the Uplink'); + }); + + test('[heater-off] TCS-HTR-OFF ACKs inside the window', async () => { + await sendCommandAndExpectAck(page, missionControl, 'TCS-HTR-OFF'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Command the Heater Off'); + }); + + test('[verify-the-command] the heater reads OFF and the battery comes back inside limits', async () => { + await missionControl.selectTab('telemetry'); + await page.waitForTimeout(2500); + await expect(band('htr-state')).toHaveText('NOMINAL', { timeout: 15000 }); + // The recovery ramp is 240 s of mission time: jump past it. + await advanceMissionClockToElapsed(page, 1100, 0); + await missionControl.selectTab('telemetry'); + await expect(band('batt-t')).toHaveText('NOMINAL', { timeout: 20000 }); + await page.waitForTimeout(3000); + await answerStatusCheck(page, 'Both, in order: the heater state changing proves the command executed'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Verify the Command'); + }); + + test('[safe-the-uplink] un-keys modem 1 and disables the HPA', async () => { + await keyUpString(page, missionControl, false); + await setHpaEnabled(page, missionControl, false); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[log-and-hand-back] logs the contact and completes', async () => { + await answerStatusCheck(page, '0205Z contact SANDSTORM (backup). Telemetry LIVE. T+4:00 wheel 1 transient'); + await missionControl.dismissDialogIfPresent(); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expectNoMissionFail(page); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/ccs-scenario5-full-completion.spec.ts b/e2e/specs/ccs-scenario5-full-completion.spec.ts new file mode 100644 index 00000000..cb781352 --- /dev/null +++ b/e2e/specs/ccs-scenario5-full-completion.spec.ts @@ -0,0 +1,159 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToElapsed, answerStatusCheck, domClick, expectNoMissionFail, keyUpString, selectTxModem, setHpaEnabled } from '../utils/ccs-helpers'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { closeWorkingDocumentIfOpen, loadEphemeris } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ccs Scenario 5 "Ranging Pass" - full completion (phase 18 E). + * + * Third TALON-2 backup contact: telemetry LIVE and nominal, uplink armed, + * three ranging tones before the 02:14Z (T+840) station-keeping burn, one + * after it (the range steps ~1.5 km with pointing steady), the post-burn set + * loaded from the Pass Schedule inbox, two more tones for the six-tone arc, + * the hand-off, safe, log. + * + * Objective flow (10): + * 1. read-the-shift-package 2. acquire-telemetry 3. arm-the-uplink + * 4. pre-burn-arc 5. watch-the-burn 6. load-the-ephemeris + * 7. post-burn-arc 8. od-handoff 9. safe-the-uplink + * 10. log-and-hand-back + */ +test.describe('ccs Scenario 5 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const readFrames = async (): Promise => parseInt((await page.locator('#tlm-frames').textContent()) ?? '0', 10); + const readRangeCount = async (): Promise => parseInt((await page.locator('#cmd-range-count').textContent()) ?? '0', 10); + const readLastRangeKm = async (): Promise => parseFloat(((await page.locator('#cmd-range-last').textContent()) ?? '').replace(/[^\d.]/g, '')); + + const sendTone = async (): Promise => { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-window-badge')).toHaveText('OPEN', { timeout: 10000 }); + const before = await readRangeCount(); + await domClick(page, '#cmd-range-tone'); + await expect.poll(readRangeCount, { timeout: 5000 }).toBe(before + 1); + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('ccs', 'ccs-scenario5'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(180000); + }); + + test('[read-the-shift-package] reads the package and why an arc, not a range', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerStatusCheck(page, 'A single range is one number on one line of sight'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Shift Package'); + }); + + test('[acquire-telemetry] LIVE and nominal', async () => { + await missionControl.selectGroundStation('SS-01'); + await missionControl.dismissDialogIfPresent(); + await missionControl.selectTab('telemetry'); + await expect(page.locator('#tlm-link-badge')).toHaveText('LIVE', { timeout: 20000 }); + await expect.poll(readFrames, { timeout: 30000 }).toBeGreaterThanOrEqual(10); + await expect(page.locator('#tlm-soh-badge')).toHaveText('NOMINAL'); + await page.waitForTimeout(3000); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Acquire Telemetry'); + }); + + test('[arm-the-uplink] HPA up and modem 1 keyed', async () => { + await selectTxModem(page, missionControl, 1); + await setHpaEnabled(page, missionControl, true); + await keyUpString(page, missionControl, true); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Arm the Uplink'); + }); + + test('[pre-burn-arc] three tones inside the window before the burn', async () => { + await advanceMissionClockToElapsed(page, 320, 0); + await sendTone(); + await sendTone(); + await sendTone(); + await expect(page.locator('#cmd-log-body')).toContainText('range'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Pre-Burn Arc'); + }); + + test('[watch-the-burn] the fourth tone steps by about 1.5 km after 02:14Z', async () => { + const preBurnKm = await readLastRangeKm(); + await advanceMissionClockToElapsed(page, 900, 0); + await sendTone(); + const postBurnKm = await readLastRangeKm(); + const stepKm = Math.abs(postBurnKm - preBurnKm); + expect(stepKm, `range step ${stepKm.toFixed(2)} km`).toBeGreaterThan(0.5); + expect(stepKm).toBeLessThan(5); + await expect(page.locator('#tlm-link-badge')).toBeHidden(); // still on the TT&C tab + await answerStatusCheck(page, 'The burn: the bird moved along its orbit'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Watch the Burn'); + }); + + test('[load-the-ephemeris] loads the post-burn set from the inbox', async () => { + await loadEphemeris(page, missionControl, 'TALON2-SK'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Load the Post-Burn Set'); + }); + + test('[post-burn-arc] two more tones make six', async () => { + await sendTone(); + await sendTone(); + await expect.poll(readRangeCount).toBeGreaterThanOrEqual(6); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Post-Burn Arc'); + }); + + test('[od-handoff] hands the arc to LANTERN', async () => { + await answerStatusCheck(page, 'Six timed ranges from SANDSTORM'); + await missionControl.dismissDialogIfPresent(); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Hand Off the Solution'); + }); + + test('[safe-the-uplink] un-keys modem 1 and disables the HPA', async () => { + await keyUpString(page, missionControl, false); + await setHpaEnabled(page, missionControl, false); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[log-and-hand-back] logs the contact and completes', async () => { + await answerStatusCheck(page, '0205Z contact SANDSTORM (backup). Telemetry LIVE, SOH NOMINAL. Ranging arc'); + await missionControl.dismissDialogIfPresent(); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expectNoMissionFail(page); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/equipment-interaction.spec.ts b/e2e/specs/equipment-interaction.spec.ts index 07daa64e..c529541c 100644 --- a/e2e/specs/equipment-interaction.spec.ts +++ b/e2e/specs/equipment-interaction.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/test-fixtures'; +import { expect, test } from '../fixtures/test-fixtures'; import { waitForSimulationReady } from '../utils/simulation-helpers'; test.describe('Equipment Interaction', () => { @@ -32,9 +32,7 @@ test.describe('Equipment Interaction', () => { await missionControlPage.waitForSimulationReady(); // Should have at least one ground station item - const gsItems = missionControlPage.assetTreeSidebar.locator( - '[data-asset-type="ground-station"], .ground-station-item, .asset-tree-item' - ); + const gsItems = missionControlPage.assetTreeSidebar.locator('[data-asset-type="ground-station"], .ground-station-item, .asset-tree-item'); await expect(gsItems.first()).toBeVisible(); }); @@ -42,9 +40,7 @@ test.describe('Equipment Interaction', () => { await missionControlPage.waitForSimulationReady(); // Look for expand/collapse controls - const expandControls = missionControlPage.assetTreeSidebar.locator( - '.expand-icon, .collapse-icon, .tree-toggle, [data-expanded]' - ); + const expandControls = missionControlPage.assetTreeSidebar.locator('.expand-icon, .collapse-icon, .tree-toggle, [data-expanded]'); // Should have some expand/collapse functionality const count = await expandControls.count(); diff --git a/e2e/specs/ham-sdr-scenario1-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario1-full-completion.spec.ts new file mode 100644 index 00000000..b9f3db37 --- /dev/null +++ b/e2e/specs/ham-sdr-scenario1-full-completion.spec.ts @@ -0,0 +1,156 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, domClick, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 1 "First Light" - full completion. + * + * The campaign opener on the fixed QFH: the rig boots as Riley left it after + * an FM session - VFO parked at 137.170 MHz (70 kHz above the bird) on a + * 15 kHz voice channel that cannot fit the 34 kHz APT signal. The player has + * to click-to-tune onto the stripe, open the channel to 50 kHz, lock, and + * then hold the lock hands-off for 45 s (VHF Doppler ~+/-3 kHz stays inside + * the channel, so hands-off holds). RF envelope live-verified in phase 1 + * (AOS T+3.0, max el 55.1 at T+10.6, LOS T+18.3, start 2027-06-19 16:00 UTC). + * + * Also asserts the sign-up funnel: this run is unauthenticated, so the + * Mission Complete modal must offer Sign Up / Log In (progress only persists + * to an account; the CTA is how an anonymous player keeps the completion). + */ +test.describe('ham-sdr Scenario 1 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario1'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(240000); + }); + + test('[review-mission-brief] reads the note and answers the beamwidth quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'Its beam is enormous'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[check-observations] opens the Observations list', async () => { + await missionControl.selectGroundStation('BKYD-QFH'); + await missionControl.selectTab('pass-schedule'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Check the Observations List'); + }); + + test('[detect-apt] sees the APT downlink appear on the waterfall', async () => { + // Jump into the pass - well past AOS, toward the strong segment + await advanceClock(page, 5); + + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // Detection is watching the waterfall - the demodulator is still + // mis-tuned and too narrow, and that must not matter here + await waitForObjectiveComplete(missionControl, 'See First Light', 90000); + }); + + test('[tune-apt] clicks the stripe to pull the VFO onto the bird', async () => { + // The APT stripe sits at 137.100 MHz - the exact center of the 200 kHz + // view (speca center 137.1, span 200 kHz), while the VFO is parked 70 kHz + // high. Click-to-tune at the horizontal center of the display stack. + // DOM-dispatched with coordinates: the draggable checklist can float over + // the console and swallow real pointer clicks (S2 lesson). + await page.locator('#sdr-display-stack').evaluate((el) => { + const rect = el.getBoundingClientRect(); + el.dispatchEvent( + new MouseEvent('click', { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + bubbles: true, + }) + ); + }); + + // Fallback: if display padding skewed the click outside +/-5 kHz, the + // WXSAT-19 bookmark tunes exactly + const tuned = await page + .waitForFunction( + () => { + const item = [...document.querySelectorAll('.objective-item')].find((el) => el.textContent?.includes('Put the VFO on the Bird')); + return item?.classList.contains('completed') ?? false; + }, + { timeout: 10000 } + ) + .then(() => true) + .catch(() => false); + + if (!tuned) { + await domClick(page, '.sdr-bookmark'); + } + + await waitForObjectiveComplete(missionControl, 'Put the VFO on the Bird'); + }); + + test('[lock-apt] opens the channel to 50 kHz and locks', async () => { + // On frequency but still no decode: the 15 kHz voice channel clips the + // 34 kHz APT signal. Open it to 50 kHz; lock follows on its own. + const bwInput = page.locator('#sdr-bw-input'); + await bwInput.fill('50'); + await bwInput.dispatchEvent('change'); + + await waitForObjectiveComplete(missionControl, 'Open the Channel and Lock', 90000); + }); + + test('[hold-the-picture] holds the lock hands-off for 45 seconds', async () => { + await missionControl.dismissDialogIfPresent(); + + // Real-time maintain window - no sim jumps, no corrections, hands off + const everLocked = await rideUntilObjectiveComplete(page, missionControl, "Don't Touch What's Working", { maxMs: 120_000, correct: false }); + expect(everLocked).toBe(true); + }); + + test('[first-light-log] answers the Doppler-contrast quiz and completes the mission', async () => { + await answerRileyQuiz(page, 'the 50 kHz channel swallows it'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); + + test('[signup-funnel] unauthenticated completion offers Sign Up / Log In', async () => { + // This run is signed out, so the completion cannot be saved to an account + // yet - the modal must show the sign-up funnel instead of losing it silently + const signupSection = page.locator('#complete-signup-section'); + await expect(signupSection).toBeVisible(); + await expect(signupSection).toContainText('Create a free account'); + + // The CTA opens the login/sign-up modal on top of the completion modal + await domClick(page, '#signup-save-btn'); + await expect(page.locator('#modal-login')).toBeVisible({ timeout: 10000 }); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario2-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario2-full-completion.spec.ts new file mode 100644 index 00000000..60f1207c --- /dev/null +++ b/e2e/specs/ham-sdr-scenario2-full-completion.spec.ts @@ -0,0 +1,91 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, engageTrack, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 2 "The Slippery Bird" - full completion. + * + * This is also the campaign's live yagi verification (phase-1 retro debt): the + * CUBEHOP-1 leg - program-track on the crossed yagi, 435.25 MHz FM downlink, + * manual Doppler chase - had only ever been unit-tested before this spec. + * + * Pass geometry (start 2027-06-19 16:14 UTC): AOS T+4.0 min, max el 48.2 deg + * at T+10.2, LOS T+16.5 (locked by test/campaigns/ham-sdr.test.ts). + * + * The chase objective holds the lock 120 REAL seconds with AFC off, flown by + * polling the offset readout and clicking the tune buttons like an operator. + */ +test.describe('ham-sdr Scenario 2 Full Completion (live yagi leg)', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario2'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] reads the note and answers the Doppler quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'Starts ~10 kHz HIGH'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[track-cubehop] tracks CUBEHOP-1 from the SDR console rotator panel', async () => { + await missionControl.selectGroundStation('BKYD-YAGI'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // Jump close to AOS (T+4.0) - real time has already burned ~1 min + await advanceClock(page, 2.5); + await engageTrack(page, '63002'); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Put the Yagi on the Bird', 120000); + }); + + test('[chase-by-hand] holds the lock 120 s by hand through the drift', async () => { + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + const everLocked = await rideUntilObjectiveComplete(page, missionControl, 'Ride the VFO'); + + expect(everLocked).toBe(true); + await waitForObjectiveComplete(missionControl, 'Ride the VFO', 30000); + }); + + test('[slippery-log] answers the closest-approach quiz and completes the mission', async () => { + await answerRileyQuiz(page, 'the range rate passes through zero'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario3-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario3-full-completion.spec.ts new file mode 100644 index 00000000..97077d17 --- /dev/null +++ b/e2e/specs/ham-sdr-scenario3-full-completion.spec.ts @@ -0,0 +1,121 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, domClick, engageTrack, readOffsetHz, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 3 "Wrong-Handed" - full completion. + * + * The yagi STARTS on LHCP (the phase-1 retro note) against CUBEHOP-1's RHCP + * downlink: ~18 dB in the hole. The spec verifies the diagnosis arc live - + * signal visible-but-weak while wrong-handed, the C/N swing when the feed is + * flipped (the new antenna-polarization-set condition), then a >= 10 dB lock + * held 60 s to prove the fix. + * + * Pass geometry (start 2027-06-20 16:24 UTC): AOS T+4.3 min, max el 63.4 deg + * at T+10.6, LOS T+17.0 (locked by test/campaigns/ham-sdr.test.ts). + */ +test.describe('ham-sdr Scenario 3 Full Completion (handedness diagnosis)', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario3'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] reads the note and answers the reflection quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'reflection reverses the handedness'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[track-cubehop] tracks the pass and sees the weak wrong-handed signal', async () => { + await missionControl.selectGroundStation('BKYD-YAGI'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // The scenario boots with the feed harness on LHCP + await expect(page.locator('#sdr-pol-lhcp')).toHaveClass(/active/); + + await advanceClock(page, 3); + await engageTrack(page, '63002'); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Track the Pass', 120000); + }); + + test('[fix-the-feed] flips the feed to RHCP and the signal jumps', async () => { + // Get onto the carrier first so the C/N readout means something + await advanceClock(page, 2); + const offsetHz = await readOffsetHz(page); + if (Number.isFinite(offsetHz)) { + // A few fine clicks is plenty; the swing is the point here + const btn = offsetHz > 0 ? '#sdr-tune-up-fine' : '#sdr-tune-dn-fine'; + for (let i = 0; i < Math.min(9, Math.round(Math.abs(offsetHz) / 1000)); i++) { + await domClick(page, btn); + } + } + + const cnBefore = parseFloat(((await page.locator('#sdr-cn-readout').textContent()) ?? '').replace(/[^\d.-]/g, '')); + + await domClick(page, '#sdr-pol-rhcp'); + await expect(page.locator('#sdr-pol-rhcp')).toHaveClass(/active/); + await page.waitForTimeout(3000); + + const cnAfter = parseFloat(((await page.locator('#sdr-cn-readout').textContent()) ?? '').replace(/[^\d.-]/g, '')); + // The handedness swing: whatever was readable before, RHCP is dramatically + // stronger after (full 18 dB when both readings exist) + if (Number.isFinite(cnBefore) && Number.isFinite(cnAfter)) { + expect(cnAfter - cnBefore).toBeGreaterThan(10); + } + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Find the Switch', 30000); + }); + + test('[prove-the-link] holds a >= 10 dB lock for 60 s', async () => { + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + const everLocked = await rideUntilObjectiveComplete(page, missionControl, 'Prove It with a Lock', { maxMs: 180_000 }); + + expect(everLocked).toBe(true); + await waitForObjectiveComplete(missionControl, 'Prove It with a Lock', 30000); + }); + + test('[wrong-handed-log] answers the symptom-pattern quiz and completes the mission', async () => { + await answerRileyQuiz(page, 'Polarization mismatch'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario4-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario4-full-completion.spec.ts new file mode 100644 index 00000000..d607faf0 --- /dev/null +++ b/e2e/specs/ham-sdr-scenario4-full-completion.spec.ts @@ -0,0 +1,111 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, domClick, engageTrack, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 4 "Set and Forget" - full completion. + * + * The AFC A/B in one pass: fly the first half by hand (90 s manual maintain + * with receiver-afc-enabled { afcEnabled: false }), flip #sdr-afc-toggle, then + * hands OFF while the loop flies the outbound drift (120 s maintain with lock, + * C/N >= 10 and AFC on - the ride helper runs with correct: false to prove no + * human tuning is involved). + * + * Pass geometry (start 2027-06-21 16:34 UTC): AOS T+4.8 min, max el 83.1 deg + * at T+11.2, LOS T+17.5 (locked by test/campaigns/ham-sdr.test.ts). + */ +test.describe('ham-sdr Scenario 4 Full Completion (AFC discovery)', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario4'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] reads the note and answers the AFC quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'offset from the center of the channel'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[track-cubehop] tracks the overhead pass', async () => { + await missionControl.selectGroundStation('BKYD-YAGI'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + await advanceClock(page, 3.5); + await engageTrack(page, '63002'); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Track the Overhead Pass', 120000); + }); + + test('[manual-first-half] flies 90 s by hand with AFC off', async () => { + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // AFC must be off (fresh modem: unchecked). The maintain window is real + // time, so ride the inbound drift with the tune buttons. + await expect(page.locator('#sdr-afc-toggle')).not.toBeChecked(); + + const everLocked = await rideUntilObjectiveComplete(page, missionControl, 'Fly the First Half by Hand'); + + expect(everLocked).toBe(true); + await waitForObjectiveComplete(missionControl, 'Fly the First Half by Hand', 30000); + }); + + test('[engage-afc] takes the tape off the checkbox', async () => { + await domClick(page, '#sdr-afc-toggle'); + await expect(page.locator('#sdr-afc-toggle')).toBeChecked(); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Take the Tape Off', 30000); + }); + + test('[hands-off] the loop holds lock and C/N for 120 s untouched', async () => { + // correct: false - the whole point is that no human tuning happens + const everLocked = await rideUntilObjectiveComplete(page, missionControl, 'Hands Off the Dial', { + maxMs: 220_000, + correct: false, + }); + + expect(everLocked).toBe(true); + await waitForObjectiveComplete(missionControl, 'Hands Off the Dial', 30000); + }); + + test('[graduation-log] answers the AFC-limits quiz and completes the mission', async () => { + await answerRileyQuiz(page, 'carrier disappears from the passband'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario5-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario5-full-completion.spec.ts new file mode 100644 index 00000000..e4729629 --- /dev/null +++ b/e2e/specs/ham-sdr-scenario5-full-completion.spec.ts @@ -0,0 +1,137 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 5 "The Noise Bump" - full completion. + * + * Security-half opener on the GPS patch: detect the L1 spread-spectrum hump + * (NAVSTAR-77 is overhead all scenario - no pass to catch), then survive a + * GPS spoofing attack. The spoofer is an E1 terrestrial emitter (narrow strong + * carrier on 1575.42, no Doppler) paired with the gnssThreat clock walk; the + * defense is the E4 REF control (GPS -> HOLDOVER -> back to GPS). + * + * Spoof window is mission-elapsed 420-900 s, so the spec crosses it with + * advanceClock (the sky and the mission schedule move together). The + * go-holdover maintain window (60 s) ticks on REAL time and is waited out. + */ +test.describe('ham-sdr Scenario 5 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario5'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(240000); + }); + + test('[review-mission-brief] reads the note and answers the spread-spectrum quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'correlates against that code'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[find-the-hump] detects L1 on the waterfall and answers the detect-vs-demod quiz', async () => { + await missionControl.selectGroundStation('BKYD-GPS'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + await answerRileyQuiz(page, 'no despreading code'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Find the Noise Bump', 90000); + }); + + test('[spot-the-spoofer] the terrestrial L1 carrier appears after the spoof window opens', async () => { + // Cross the spoofStartS=420 threshold on the mission clock + await advanceClock(page, 8); + + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Something New on L1', 90000); + }); + + test('[read-the-tell] CLK deltaT walks while SATS stay healthy; quiz names the spoof', async () => { + // E4 assertion: the timing-offset readout is actually walking + await expect + .poll( + async () => { + const text = (await page.locator('#sdr-clk-offset-readout').textContent()) ?? ''; + return parseFloat(text.replace(/[^\d.+-]/g, '')); + }, + { timeout: 30000 } + ) + .toBeGreaterThan(1); + await expect(page.locator('#sdr-ref-readout')).toContainText('GPS'); + + await answerRileyQuiz(page, 'walks your clock'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Clock'); + }); + + test('[go-holdover] flips REF to holdover and holds it for the 60 s window', async () => { + await domClick(page, '#sdr-ref-toggle'); + await expect(page.locator('#sdr-ref-readout')).toContainText('HOLDOVER', { timeout: 10000 }); + + // Offset must FREEZE in holdover: sample twice across several seconds + const readOffset = async () => { + const text = (await page.locator('#sdr-clk-offset-readout').textContent()) ?? ''; + return parseFloat(text.replace(/[^\d.+-]/g, '')); + }; + const before = await readOffset(); + await page.waitForTimeout(6000); + const after = await readOffset(); + expect(after - before).toBeLessThan(1); + + // maintainDuration 60 ticks on real time + await waitForObjectiveComplete(missionControl, 'Stop Trusting GPS', 120000); + }); + + test('[all-clear] rides out the spoof, verifies the environment, returns to GPS', async () => { + // Cross spoofEndS=900: the spoofer leaves the air and the walk stops + await advanceClock(page, 8); + await missionControl.dismissDialogIfPresent(); + + // Back to GPS (5 s reacquisition inside the GPSDO core) + await domClick(page, '#sdr-ref-toggle'); + await expect(page.locator('#sdr-ref-readout')).toContainText('GPS', { timeout: 15000 }); + + await answerRileyQuiz(page, 'stopped growing'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Ride It Out, Then Come Back', 60000); + }); + + test('[noise-bump-log] logs the principle and completes the mission', async () => { + await answerRileyQuiz(page, 'physics were wrong'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario6-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario6-full-completion.spec.ts new file mode 100644 index 00000000..0636df01 --- /dev/null +++ b/e2e/specs/ham-sdr-scenario6-full-completion.spec.ts @@ -0,0 +1,119 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, domClick, engageTrack, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 6 "The Network Wants Vermont" - full completion. + * + * Tampered-TLE arc: catch the WXSAT-19 pass (network request #1), discover + * the CUBEHOP-1 element file is bad (the network's 16:59 window is missing + * from Observations, and the E3 panel flags TLE SUSPECT), fetch fresh + * elements, and catch the recovered 16:59 pass (request #2). The scenario's + * CUBEHOP is tampered at load via spaceEvents[].initialTle (RAAN +60 deg - + * empty sky all afternoon) and restored by the panel's fetch button. + * + * Both jumps use advanceClock (sim + mission clocks together): the + * WXSAT pass starts at T+10 min and the CUBEHOP pass at T+2:19. + */ +test.describe('ham-sdr Scenario 6 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario6'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] reads the request sheet and answers the supply-chain quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'volunteer mirror site'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Read the Observation Requests'); + }); + + test('[catch-wxsat] locks the 14:50 weather pass on the QFH', async () => { + // Jump to just inside the WXSAT pass (AOS T+10 min) + await advanceClock(page, 12); + + await missionControl.selectGroundStation('BKYD-QFH'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Network Request #1: WXSAT-19', 90000); + }); + + test('[spot-the-discrepancy] Observations disagree with the request sheet (E3 reskin)', async () => { + await missionControl.selectGroundStation('BKYD-YAGI'); + await missionControl.selectTab('pass-schedule'); + await missionControl.dismissDialogIfPresent(); + + // E3 amateur-voice reskin assertions + await expect(page.locator('.pass-schedule-title')).toContainText('Observations'); + await expect(page.locator('.ephemeris-badge-stale')).toContainText('TLE SUSPECT', { timeout: 15000 }); + await expect(page.locator('.btn-ephemeris')).toContainText('Fetch Fresh Elements'); + + await answerRileyQuiz(page, 'stale or tampered'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Two Sources, One Sky'); + }); + + test('[fetch-elements] fetches fresh elements from the panel', async () => { + await domClick(page, '[data-ephemeris-event="CUBEHOP-TLE"]'); + + // The corrected prediction appears in the pass table + await expect(page.locator('.ephemeris-badge-updated')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#pass-schedule-rows')).toContainText('CUBEHOP-1', { timeout: 15000 }); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Fetch Fresh Elements'); + }); + + test('[catch-cubehop] tracks and locks the recovered 16:59 pass', async () => { + // From ~T+13 min to just before the 16:59 AOS (T+139 min) + await advanceClock(page, 125); + + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + await engageTrack(page, '63002'); + // AFC is earned by now - let the loop chase the UHF Doppler + await domClick(page, '#sdr-afc-toggle'); + + const everLocked = await rideUntilObjectiveComplete(page, missionControl, 'Network Request #2: CUBEHOP-1', { maxMs: 300_000, correct: true }); + expect(everLocked).toBe(true); + }); + + test('[supply-chain-log] logs the cross-check habit and completes the mission', async () => { + await answerRileyQuiz(page, 'Comparing two independent sources'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario7-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario7-full-completion.spec.ts new file mode 100644 index 00000000..5b3edcaf --- /dev/null +++ b/e2e/specs/ham-sdr-scenario7-full-completion.spec.ts @@ -0,0 +1,110 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceClock, answerRileyQuiz, domClick, engageTrack, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 7 "Margin Call" - full completion. + * + * RFI foxhunt + marginal pass on the yagi rig. The E1 terrestrial hash + * (435.36 MHz, 1.2 km due east) comes up at T+60 s; the player DFs it with + * the new MAN AZ control, notches it with the new FILTER section, narrows + * the IF filter to 100 kHz, and locks CUBEHOP-1 through the 18.4-degree pass + * (AOS T+20 min, crossed with advanceClock). + */ +test.describe('ham-sdr Scenario 7 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario7'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] reads the note and answers the link-margin quiz', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerRileyQuiz(page, 'comes straight out of the decode margin'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[hear-the-hash] the terrestrial hash appears on the waterfall', async () => { + // RFI comes up at T+60 s on the mission clock + await advanceClock(page, 2); + + await missionControl.selectGroundStation('BKYD-YAGI'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Confirm the Intruder', 60000); + }); + + test('[df-the-source] sweeps the yagi onto the emitter bearing with MAN AZ', async () => { + // Sanity: the hash should be louder ON the bearing than off it. Slew to + // due east via the new manual control and let the rotator arrive. + await page.locator('#sdr-rot-az-input').fill('90'); + await domClick(page, '#sdr-rot-go'); + + await waitForObjectiveComplete(missionControl, 'Foxhunt: Take a Bearing', 90000); + }); + + test('[notch-it] carves the hash out with the one-knob notch', async () => { + const notchFreq = page.locator('#sdr-notch-freq'); + await notchFreq.fill('435.36'); + await notchFreq.dispatchEvent('change'); + await domClick(page, '#sdr-notch-enable'); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Notch the Hash', 30000); + }); + + test('[narrow-and-catch] narrows the IF filter and locks the 18-degree pass', async () => { + // Jump to just before the 15:35 AOS (T+20 min) + await advanceClock(page, 15); + + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // 100 kHz front-end filter (index 5): the noise gate drops 3 dB + await page.locator('#sdr-if-filter-select').selectOption('5'); + + await engageTrack(page, '63002'); + await domClick(page, '#sdr-afc-toggle'); + + const everLocked = await rideUntilObjectiveComplete(page, missionControl, 'Make the Margin, Catch the Pass', { maxMs: 300_000, correct: true }); + expect(everLocked).toBe(true); + }); + + test('[margin-log] logs the audit and completes the mission', async () => { + await answerRileyQuiz(page, 'ground signals hold still'); + await missionControl.dismissDialogIfPresent(); + + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/ham-sdr-scenario8-full-completion.spec.ts b/e2e/specs/ham-sdr-scenario8-full-completion.spec.ts new file mode 100644 index 00000000..b61928f8 --- /dev/null +++ b/e2e/specs/ham-sdr-scenario8-full-completion.spec.ts @@ -0,0 +1,157 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, answerRileyQuiz, domClick, engageTrack, rideUntilObjectiveComplete, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * ham-sdr Scenario 8 "Callsign" - full completion. The campaign finale on the + * TX-capable yagi rig (E2) plus the weather rig: + * 1. Riley's license exam (two status-check quizzes), + * 2. pirate carrier relayed by CUBEHOP's V/U transponder (pass 15:55, 28.9 deg), + * 3. first TX: uplink 435.900, key, lock own downlink at 435.290 (pass 17:31), + * 4. fake WXSAT-19 beacon on the QFH at ~17:47 with a provably empty sky. + * + * Clock jumps use advanceMissionClockToUtc (sim + mission together) - the + * interference envelopes run on the mission clock. + */ +test.describe('ham-sdr Scenario 8 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + await missionControl.gotoScenario('ham-sdr', 'ham-sdr-scenario8'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] reads the note', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, "Read Riley's Note"); + }); + + test('[exam] passes both license-exam questions', async () => { + await answerRileyQuiz(page, 'all three have to be true'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Exam, Part One'); + + await answerRileyQuiz(page, 'self-policing'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Exam, Part Two'); + }); + + test('[hear-the-pirate] tracks the afternoon pass and hears the pirate downlink', async () => { + await missionControl.selectGroundStation('BKYD-YAGI'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // The TRANSMIT section exists on this rig (E2) - and only on this rig + await expect(page.locator('#sdr-tx-key')).toBeVisible(); + await expect(page.locator('#sdr-tx-status')).toHaveText('STANDBY'); + + // Jump to just before the 15:55:47 AOS; the pirate keyed up at T+20 min + await advanceMissionClockToUtc(page, '2027-06-26T15:54:30Z'); + await engageTrack(page, '63002'); + + await waitForObjectiveComplete(missionControl, 'Somebody Is Already on the Bird', 180000); + }); + + test('[pirate-ethics] explains what the transponder could NOT do', async () => { + await answerRileyQuiz(page, 'authorization lives in licenses'); + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'What Makes a Transmission Authorized'); + }); + + test('[set-uplink] dials the TX to 435.900', async () => { + const txFreq = page.locator('#sdr-tx-freq'); + await txFreq.fill('435.900'); + await txFreq.dispatchEvent('change'); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Dial In the Uplink'); + }); + + test('[first-contact] keys the transmitter and locks its own transponded downlink', async () => { + // Jump to just before the 17:31:29 AOS (the pirate is long gone) + await advanceMissionClockToUtc(page, '2027-06-26T17:30:30Z'); + + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + await engageTrack(page, '63002'); + + // Key the transmitter: the ON AIR pill and the red key confirm the E2 chain + await domClick(page, '#sdr-tx-key'); + await expect(page.locator('#sdr-tx-status')).toHaveText('ON AIR'); + + // Tune the receiver from the 435.25 beacon up to the 435.29 return + // (4 coarse clicks = +40 kHz), then let AFC ride the downlink Doppler + for (let i = 0; i < 4; i++) { + await domClick(page, '#sdr-tune-up-coarse'); + } + await domClick(page, '#sdr-afc-toggle'); + + // The objective's receiver-signal-locked condition IS the lock proof; + // the ride loop's own lock-indicator poll (every 4 s) can miss a fast + // lock entirely, so assert the checklist rather than the side-channel + await rideUntilObjectiveComplete(page, missionControl, 'Work Yourself Through the Bird', { maxMs: 300_000, correct: true }); + await waitForObjectiveComplete(missionControl, 'Work Yourself Through the Bird', 10000); + + // And the E2 chain's physical readout: the brick amp is putting out real watts + await expect(page.locator('#sdr-tx-pa-readout')).toHaveText('1.3 W'); + }); + + test('[clear-the-channel] unkeys after the contact', async () => { + await domClick(page, '#sdr-tx-key'); + await expect(page.locator('#sdr-tx-status')).toHaveText('STANDBY'); + + await missionControl.dismissDialogIfPresent(); + await waitForObjectiveComplete(missionControl, 'Clear the Channel'); + }); + + test('[unmask-the-beacon] catches the fake WXSAT beacon on the weather rig and completes', async () => { + // Fake beacon on the air at T+8200 s (~17:46:40); WXSAT is below the horizon + await advanceMissionClockToUtc(page, '2027-06-26T17:47:30Z'); + + await missionControl.selectGroundStation('BKYD-QFH'); + await missionControl.selectTab('sdr-console'); + await missionControl.dismissDialogIfPresent(); + + // The QFH rig is RX-only: the TRANSMIT section renders the stub, not a key + await expect(page.locator('#sdr-tx-key')).toHaveCount(0); + + await answerRileyQuiz(page, 'the real bird is below the horizon'); + await missionControl.dismissDialogIfPresent(); + + // The FINAL objective is asserted through the Mission Complete modal, not + // the checklist: the checklist box stops its 1 s refresh when the + // completion flow takes over, so the last row keeps its "In Progress" + // class even though the objective state is completed (cosmetic, and it + // sits behind the modal). Same pattern as the S5/S6/S7 specs. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 90000 }); + const modalTitle = page.locator('#level-complete-modal .complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/nats-eu-scenario1-full-completion.spec.ts b/e2e/specs/nats-eu-scenario1-full-completion.spec.ts new file mode 100644 index 00000000..dbcc5a9c --- /dev/null +++ b/e2e/specs/nats-eu-scenario1-full-completion.spec.ts @@ -0,0 +1,210 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 1 "First Light Over Galway" - full completion (phase 16). + * + * The whole shift, not just the pass: a station sweep and receiver set-up in + * the fifteen minutes before AOS, the SAR-1 pass, the retune between contacts, + * the SAR-2 pass, and the shift log. Sim clock starts 2027-03-15 13:48:00Z: + * MERIDIAN-SAR-1 AOS 14:03:10Z (5 deg mask) max el 28.0 at 14:06:45Z LOS 14:10:18Z + * MERIDIAN-SAR-2 AOS 14:18:42Z max el 25.0 at 14:22:10Z LOS 14:25:40Z + * + * Pre-pass objectives carry 1.5-4 min timers that run on real time, so each + * pre-pass step below completes well inside its window. The specs jump with + * advanceMissionClockToUtc (sim + mission clock together) to each pass + * segment; the dev hook does not decrement objective timers. + * + * Objective flow (15): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. downconversion-plan 5. tune-receiver 6. analyzer-on-beacon + * 7. review-pass-schedule 8. preposition-for-aos 9. acquire-sar1 + * 10. lock-the-downlink 11. read-the-culmination 12. retune-for-sar2 + * 13. acquire-sar2 14. decode-sar2 15. log-first-light + */ + +/** Stage repeated fine-adjust clicks on one axis, then apply. */ +async function jogAxis(page: Page, axisPrefix: 'az-fine' | 'el-fine', delta: number, clicks: number): Promise { + const selector = `[id^="${axisPrefix}"] .btn-fine[data-delta="${delta}"]`; + await expect(page.locator(selector).first()).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < clicks; i++) { + await domClick(page, selector); + } +} + +test.describe('nats-eu Scenario 1 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation (bypasses the nats-level-8-night-shift prerequisite card lock) + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario1'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] opens the shift brief and starts the sweep', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + await answerSystemQuiz(page, 'Starting the pre-pass sweep'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Shift Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + // Station tabs render only after GW-01 is selected in the asset tree + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO locked and out of holdover', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[downconversion-plan] reads the LNB LO and does the IF arithmetic', async () => { + await missionControl.selectTab('rx-analysis'); + await answerSystemQuiz(page, '1414 MHz'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Ku Downconversion Plan'); + }); + + test('[tune-receiver] retunes modem 1 from the test carrier to the imagery downlink', async () => { + await missionControl.selectTab('rx-analysis'); + const freq = page.locator('#frequency-input'); + await expect(freq).toBeVisible({ timeout: 10000 }); + await freq.fill('1414'); + await page.locator('#bandwidth-input').fill('36'); + await page.locator('#modulation-select').selectOption('QPSK'); + await page.locator('#fec-select').selectOption('3/4'); + await domClick(page, '#apply-btn'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune the Receiver for SAR-1'); + }); + + test('[analyzer-on-beacon] centres the analyzer on the beacon with a Doppler-wide span', async () => { + await missionControl.selectTab('rx-analysis'); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await answerSystemQuiz(page, 'Doppler walks the beacon'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Analyzer on the SAR-1 Beacon'); + }); + + test('[review-pass-schedule] reads AOS off the schedule', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, '14:03, azimuth 000'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Contact Schedule'); + }); + + test('[preposition-for-aos] slews the stowed tracker to the rise azimuth', async () => { + await missionControl.selectTab('acu-control'); + // Stowed at az 180 / el 3; the rise is az 000 / el 5 (tolerance 3 deg). + await jogAxis(page, 'az-fine', -10, 18); + await jogAxis(page, 'el-fine', 1, 2); + await domClick(page, '[id$="apply-changes-btn"]'); + await dismissDialogIfPresent(page); + // 180 deg at 20 deg/s is nine seconds of slew + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:03:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[lock-the-downlink] holds RX lock with C/N above 8 dB', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:05:40Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Lock the Imagery Downlink', 60000); + }); + + test('[read-the-culmination] reads C/N at the top of the pass and the Doppler crossing', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:06:40Z'); + await answerSystemQuiz(page, 'Approach raises the RF'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Pass at Culmination', 60000); + }); + + test('[retune-for-sar2] retunes the receiver and analyzer after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:11:00Z'); + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1397'); + await answerSystemQuiz(page, 'Imagery 1370 MHz, beacon 1397 MHz; AOS 14:18'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retune for SAR-2'); + }); + + test('[acquire-sar2] retargets the tracker and acquires SAR-2', async () => { + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-03-15T14:19:20Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[decode-sar2] decodes the second contact above 8 dB', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:21:40Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Second Contact', 60000); + }); + + test('[log-first-light] writes the shift log and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'peak ~11 dB at 28 degrees'); + // Last objective: its completion pops the Mission Complete modal, which + // freezes the checklist, so wait on the modal rather than the row. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario10-full-completion.spec.ts b/e2e/specs/nats-eu-scenario10-full-completion.spec.ts new file mode 100644 index 00000000..cc78cf07 --- /dev/null +++ b/e2e/specs/nats-eu-scenario10-full-completion.spec.ts @@ -0,0 +1,242 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + commitLinkWithMargin, + computeLinkBudget, + disableHpa, + enableDopplerComp, + enableHpa, + expectDashboardAlarm, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + setBucGain, + setHpaBackOff, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 10 "Priority Tasking" - full completion (phase 16). + * + * One 18 deg MERIDIAN-SAR-1 pass, worked as a whole tasking: the sweep, the + * reference, the link-budget worksheet, the EIRP plan as steps (BUC gain + * restored from 18 to 23 dB, HPA back-off 10 -> 4 dB, half a watt proven once + * the amplifier is up), the uplink modem retuned from SAR-2's 1435 to 1405 + * MHz with Doppler comp engaged, the tracker parked on the rise azimuth, + * program-track at AOS, the tasking command inside the window, the imagery + * pulled down with about 2 dB of margin, then the chain safed, the back-off + * restored, the log and the customer. Clock starts 2027-03-18 15:18:00Z: + * MERIDIAN-SAR-1 AOS 15:34:59Z max el 17.8 deg at 15:39:31Z LOS 15:44:03Z + * command window mission T+1039 s .. T+1543 s + * measured (phase-c harness): peak C/N 8.11 dB at culmination, ~150 s at or + * above 7 dB, ~40 s at or above 8 dB + * + * Objective flow (15): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. budget-the-low-pass 5. restore-the-buc-gain 6. raise-the-eirp + * 7. tune-the-uplink 8. preposition-for-aos 9. acquire-low + * 10. task-the-collect 11. pull-the-imagery 12. safe-the-uplink + * 13. restore-the-back-off 14. log-the-tasking 15. report-to-customer + */ +test.describe('nats-eu Scenario 10 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario9 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario10'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the tasking and the low-pass geometry', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Slant range at max elevation is about 1040 km'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Tasking'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO and the BUC reference', async () => { + for (const tab of ['gps-timing', 'tx-chain']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[budget-the-low-pass] computes the worksheet and reads the atmospheric loss', async () => { + await computeLinkBudget(page, missionControl, { + eirpDbm: 28, + fsplDb: 174.1, + rxGainDbi: 51.8, + noiseTempK: 88, + bandwidthMHz: 36, + miscLossDb: 1.2, + }); + await answerSystemQuiz(page, 'The atmospheric path at 18 degrees'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Budget the Low Pass'); + }); + + test('[restore-the-buc-gain] puts the BUC back to 23 dB and reads the chain arithmetic', async () => { + await setBucGain(page, missionControl, 23); + await page.waitForTimeout(2500); // buc-not-saturated latches on TX chain + await answerSystemQuiz(page, 'The EIRP of a 9 dB back-off'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Restore the BUC Gain'); + }); + + test('[raise-the-eirp] takes the HPA back-off to 4 dB and stays linear', async () => { + await setHpaBackOff(page, missionControl, 4); + await expect(page.locator('#hpa-overdrive-status')).toHaveText('Normal'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'Intermodulation products spill'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Raise Uplink EIRP Without Overdriving'); + }); + + test('[tune-the-uplink] retunes the TX modem to 1405 MHz and engages Doppler comp', async () => { + await setTxModemFrequency(page, missionControl, 1405); + await enableDopplerComp(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune the Uplink'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 027 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 27, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-low] program-tracks SAR-1 and sees the beacon at the horizon', async () => { + await advanceMissionClockToUtc(page, '2027-03-18T15:34:30Z'); + await programTrack(page, missionControl, '61701'); + await page.waitForTimeout(2000); + + // Early in the pass the beacon clears -130 dBm at RX IF; observe it on + // RX Analysis so signal-detected latches + await advanceMissionClockToUtc(page, '2027-03-18T15:36:30Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire at the Horizon', 60000); + }); + + test('[task-the-collect] keys the uplink in order, proves the power and gets the tasking ACK', async () => { + // Modem FIRST, then the HPA: enabling the HPA with no drive trips the + // noise-amplification invariant and fails the mission + await setTxModemOnAir(page, missionControl); + await enableHpa(page, missionControl); + await page.waitForTimeout(2500); // hpa-output-power-set is observed on TX chain + + await sendCommandAndExpectAck(page, missionControl, 'SAR-TASK-URGENT'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Task the Collect'); + }); + + test('[pull-the-imagery] holds the downlink 30 s and commits with margin at max elevation', async () => { + test.setTimeout(180000); + + // Observe on RX Analysis BEFORE the jump so lock and the 6 dB floor latch + // on the first tick above threshold (C/N >= 7 dB from about 15:38:15) + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-18T15:38:45Z'); + await dismissDialogIfPresent(page); + + // The 30 s maintain window ticks on real time + await page.waitForTimeout(32000); + + // Max elevation is 15:39:31Z. Commit with the live C/N at least 7 dB + // (6 dB threshold + 1 dB required margin). + await commitLinkWithMargin(page, missionControl, 7); + await answerSystemQuiz(page, 'On a low pass the bird never gets close'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pull the Imagery With the Margin You Have', 60000); + }); + + test('[safe-the-uplink] brings the chain down in order after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-03-18T15:44:30Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[restore-the-back-off] returns the HPA to the 10 dB station standard', async () => { + await setHpaBackOff(page, missionControl, 10); + await answerSystemQuiz(page, '4 dB was the EIRP for one 18 degree geometry'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Restore the Back-Off'); + }); + + test('[log-the-tasking] writes the tasking log', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The command and its ACK time'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Log the Tasking'); + }); + + test('[report-to-customer] tells Erik what he got and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Captured and usable'); + await dismissDialogIfPresent(page); + + // FINAL objective: assert through the Mission Complete modal, since the + // checklist stops repainting once the completion flow takes over + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario11-full-completion.spec.ts b/e2e/specs/nats-eu-scenario11-full-completion.spec.ts new file mode 100644 index 00000000..9422a61a --- /dev/null +++ b/e2e/specs/nats-eu-scenario11-full-completion.spec.ts @@ -0,0 +1,212 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, loadEphemeris, programTrack, setSpecaRbw } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 11 "LEOP: Launch Day" - full completion (phase 16). + * + * MERIDIAN-SAR-3's first pass over a commercial station, done as a readiness + * sweep (board, reference, ACU beacon plan, analyzer framed for a beacon + * search, uplink confirmed cold), the refined elements loaded and the pass + * read back, then program-track, the beacon found on the analyzer, the RBW + * narrowed and the Doppler sign read, ACU beacon lock, the beacon level, the + * SOH call, the close-out and the handover. Clock starts 2027-03-22 08:50:00Z: + * MERIDIAN-SAR-3 (refined) AOS 09:08:58Z max el 31.6 deg at 09:13:44Z LOS 09:18:33Z + * SAR-3 beacon 11785 MHz -> IF 1315 MHz (LNB LO 13100) + * + * The refined set fires at mission T+45 s (the ephemeris panel flags STALE). + * The TRR's beacon-frequency condition is met by selecting SAR-3 as the + * program-track target, which copies the target's beacon frequency into the + * ACU field (the ACU beacon input itself is hidden unless step-track is on). + * + * Objective flow (15): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. test-readiness 5. analyzer-on-the-beacon 6. uplink-cold-check + * 7. load-refined-elements 8. read-the-refined-pass 9. first-acquisition + * 10. narrow-the-analyzer 11. acu-beacon-lock 12. beacon-level + * 13. state-of-health 14. secure-after-los 15. leop-handover + */ +test.describe('nats-eu Scenario 11 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario10 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario11'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the LEOP card and the acquisition risk', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'At AOS the bird can be a few degrees from prediction'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the LEOP Card'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO and reads why Rotterdam wants it logged', async () => { + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'The Doppler curve is SOH evidence'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[test-readiness] configures the ACU for SAR-3 and closes the TRR', async () => { + // Picking SAR-3 as the program-track target copies its 11785 MHz beacon + // into the ACU beacon field + await programTrack(page, missionControl, '61703'); + await page.waitForTimeout(1000); + await answerSystemQuiz(page, 'reference disciplined, injection set loaded'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Test Readiness Review'); + }); + + test('[analyzer-on-the-beacon] frames the beacon search at 1315 MHz, 2 MHz span', async () => { + await missionControl.selectTab('rx-analysis'); + await fillAndChange(page, '#sa-center-freq', '1315'); + await fillAndChange(page, '#sa-span', '2'); + await answerSystemQuiz(page, 'At 60 MHz span a CW tone is one pixel'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Analyzer on the Beacon'); + }); + + test('[uplink-cold-check] confirms the chain is cold under the flight rule', async () => { + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'Modem off air, BUC muted, HPA output disabled'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Uplink Cold Check'); + }); + + test('[load-refined-elements] loads the ranging solution once the panel flags SAR-3', async () => { + // The refined set fires at mission T+45 s; move the clocks a minute past + // the start so it has reached the console + await advanceMissionClockToUtc(page, '2027-03-22T08:51:00Z'); + await loadEphemeris(page, missionControl, 'SAR3-INJ'); + await answerSystemQuiz(page, 'Origin: one is a launch-provider estimate'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Load the Refined Elements'); + }); + + test('[read-the-refined-pass] reads AOS, peak and LOS off the refined schedule', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, 'azimuth 143, 31.6 degrees'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Refined Pass'); + }); + + test('[first-acquisition] program-tracks SAR-3 on the refined set and finds the beacon', async () => { + // Just before the 09:08:58Z AOS: re-point on the refined set + await advanceMissionClockToUtc(page, '2027-03-22T09:08:30Z'); + await programTrack(page, missionControl, '61703'); + await page.waitForTimeout(3000); + + // Rising leg: the beacon latches on RX Analysis + await advanceMissionClockToUtc(page, '2027-03-22T09:10:30Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'First Acquisition', 60000); + }); + + test('[narrow-the-analyzer] drops the RBW to 10 kHz and reads the Doppler sign', async () => { + await setSpecaRbw(page, missionControl, '0.01'); + await answerSystemQuiz(page, 'Yes: the high-side LNB LO inverts'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Narrow the Analyzer'); + }); + + test('[acu-beacon-lock] confirms ACU beacon lock and logs it', async () => { + await missionControl.selectTab('acu-control'); + await expect(page.locator('[id$="beacon-lock-status"]')).toHaveText('LOCKED', { timeout: 30000 }); + await answerSystemQuiz(page, "The pedestal's own receiver"); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'ACU Beacon Lock', 60000); + }); + + test('[beacon-level] reads the beacon level near culmination and logs it', async () => { + await advanceMissionClockToUtc(page, '2027-03-22T09:13:00Z'); + await missionControl.selectTab('rx-analysis'); + await answerSystemQuiz(page, 'A level against the prediction is the transmitter'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Beacon Level', 60000); + }); + + test('[state-of-health] makes the SOH call for Rotterdam', async () => { + await answerSystemQuiz(page, 'no anomaly; recommend proceeding to command checkout'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Initial State of Health'); + }); + + test('[secure-after-los] confirms the uplink stayed cold and closes the contact', async () => { + await advanceMissionClockToUtc(page, '2027-03-22T09:19:00Z'); + await closeWorkingDocumentIfOpen(page); + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'The injection set is superseded'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Secure After LOS'); + }); + + test('[leop-handover] explains why nothing went up and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The flight rules sequence LEOP'); + await dismissDialogIfPresent(page); + + // FINAL objective: assert through the Mission Complete modal, since the + // checklist stops repainting once the completion flow takes over + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario12-full-completion.spec.ts b/e2e/specs/nats-eu-scenario12-full-completion.spec.ts new file mode 100644 index 00000000..b542f0c4 --- /dev/null +++ b/e2e/specs/nats-eu-scenario12-full-completion.spec.ts @@ -0,0 +1,292 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + commitLinkWithMargin, + computeLinkBudget, + disableHpa, + enableDopplerComp, + enableHpa, + expectDashboardAlarm, + fillAndChange, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 12 "LEOP: Commissioning" - full completion (phase 16). + * + * SAR-3 payload acceptance done as the test card: readiness (board, + * reference, prediction, crypto both ways, TX modem retuned from SAR-1's 1405 + * to SAR-3's 1465 MHz with Doppler comp, analyzer framed on 1330 MHz, tracker + * parked on az 140), acquire with the chain cold, chain up in order inside + * the window (carrier into the muted BUC, unmute, HPA), PLD-ON then + * PLD-TEST-PATTERN, HPA off before the decode, RX modem to 1340 MHz, lock and + * commit with 2 dB of margin, chain down, verdict, delivery. Clock starts + * 2027-03-24 09:48:00Z: + * MERIDIAN-SAR-3 AOS 10:06:00Z max el 27.9 deg at 10:10:45Z LOS 10:15:29Z + * command window mission T+1102 s .. T+1628 s + * worksheet truth 10.9 dB; threshold 6 dB + 2 dB required margin + * + * Objective flow (20): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. predict-acceptance 5. crypto-both-ways 6. tune-the-uplink + * 7. analyzer-on-sar3 8. preposition-for-aos 9. acquire-sar3 + * 10. carrier-into-muted-buc 11. unmute-the-buc 12. enable-the-hpa + * 13. payload-power-on 14. payload-test-pattern 15. call-the-return + * 16. secure-for-decode 17. first-video 18. chain-down-after-los + * 19. sign-the-card 20. deliver-to-customer + * + * call-the-return (phase 18) is the first shipped `decision` condition: the + * 1290 MHz transponded return is graded against live evidence facts (no + * interference event, no injected fault, crypto intact) so "own uplink" is the + * only correct call here; the e2e asserts both evidence latches before it + * answers, per the decision e2e rule. + */ +test.describe('nats-eu Scenario 12 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario11 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario12'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the test plan and why the order matters', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Each step is the evidence for the next'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Test Plan'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO and the BUC reference', async () => { + for (const tab of ['gps-timing', 'tx-chain']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[predict-acceptance] computes the acceptance C/N from the survey numbers', async () => { + await computeLinkBudget(page, missionControl, { + eirpDbm: 28, + fsplDb: 171.5, + rxGainDbi: 51.8, + noiseTempK: 88, + bandwidthMHz: 36, + miscLossDb: 1, + }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Predict the Acceptance C/N'); + }); + + test('[crypto-both-ways] records both key states on the card', async () => { + for (const tab of ['tx-chain', 'rx-analysis']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await answerSystemQuiz(page, 'Command key for PLD-ON and the pattern going up'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Crypto Both Ways'); + }); + + test('[tune-the-uplink] retunes the TX modem to 1465 MHz and engages Doppler comp', async () => { + await setTxModemFrequency(page, missionControl, 1465); + await enableDopplerComp(page, missionControl); + await answerSystemQuiz(page, '1465 MHz (RF minus LO'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune the Uplink'); + }); + + test('[analyzer-on-sar3] frames the SAR-3 plan on the analyzer', async () => { + await missionControl.selectTab('rx-analysis'); + await fillAndChange(page, '#sa-center-freq', '1330'); + await answerSystemQuiz(page, '1290 transponded return of your own uplink'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Analyzer on the SAR-3 Plan'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 140 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 140, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar3] program-tracks SAR-3 and sees the 1315 MHz beacon with the chain cold', async () => { + await advanceMissionClockToUtc(page, '2027-03-24T10:05:30Z'); + await programTrack(page, missionControl, '61703'); + await page.waitForTimeout(2000); + + // Early in the pass, with the command window already open + await advanceMissionClockToUtc(page, '2027-03-24T10:07:00Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-3', 60000); + }); + + test('[carrier-into-muted-buc] puts the modem on air into the muted BUC', async () => { + await setTxModemOnAir(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Carrier Into the Muted BUC'); + }); + + test('[unmute-the-buc] unmutes the BUC and reads the order', async () => { + await setSwitch(page, '#buc-mute', false); + await answerSystemQuiz(page, 'Every stage sees a signal before it sees gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Unmute the BUC'); + }); + + test('[enable-the-hpa] enables the HPA and confirms it is linear', async () => { + await enableHpa(page, missionControl); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Enable the HPA'); + }); + + test('[payload-power-on] sends PLD-ON and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'PLD-ON'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Payload Power On'); + }); + + test('[payload-test-pattern] sends PLD-TEST-PATTERN and records the checkout', async () => { + await sendCommandAndExpectAck(page, missionControl, 'PLD-TEST-PATTERN'); + await answerSystemQuiz(page, 'PLD-ON then PLD-TEST-PATTERN, both ACKed'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Payload Test Pattern'); + }); + + test('[call-the-return] looks at the HPA and the key, then calls the 1290 MHz carrier as its own uplink', async () => { + // Evidence first: the HPA state on TX Chain, the RX key status on RX Analysis. + // Each requiresObservation latch needs the tab on screen for the dwell. + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(3000); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(3000); + + // Decisions are graded on state, so the helper refuses to click until both + // evidence items show latched in the modal. + await answerDecision(page, 'Own uplink transponded back', ['HPA state read on TX Chain', 'RX key status read on RX Analysis']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Return'); + }); + + test('[secure-for-decode] disables the HPA and reads the transponded return', async () => { + // With the HPA up, SAR-3's TT&C transponder returns the station's own + // 14065 MHz carrier at 11810 MHz (IF 1290) some 50 dB above the pattern; + // the RX AGC drops and the 1340 MHz pattern reads ~3 dB instead of ~11. + await disableHpa(page, missionControl); + await expect(page.locator('#hpa-enable')).not.toBeChecked(); + await page.waitForTimeout(3000); // TX Chain stays on screen so the condition latches + await answerSystemQuiz(page, "SAR-3's command transponder returns"); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Secure the Uplink for the Decode'); + }); + + test('[first-video] retunes to 1340 MHz, locks the pattern, commits with 2 dB margin and records it', async () => { + // The RX modem is still on SAR-1's 1414 MHz from the morning + await setRxModemFrequency(page, missionControl, 1340); + + // Observe on RX Analysis BEFORE the jump so lock and C/N >= 8 dB latch as + // soon as the bird is near max elevation (10:10:45Z) + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-24T10:10:15Z'); + await dismissDialogIfPresent(page); + await page.waitForTimeout(4000); + + // Commit with the live C/N at least 8 dB (6 dB threshold + 2 dB margin) + await commitLinkWithMargin(page, missionControl, 8); + await answerSystemQuiz(page, 'The measured C/N beside the 10.9 dB prediction'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'First Imagery Decode', 60000); + }); + + test('[chain-down-after-los] mutes the BUC and drops the carrier after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-03-24T10:16:00Z'); + await closeWorkingDocumentIfOpen(page); + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Down After LOS'); + }); + + test('[sign-the-card] records the verdict', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'PLD-SAFE not exercised'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Sign the Card'); + }); + + test('[deliver-to-customer] confirms what acceptance transfers and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'SAR-3 enters the tasking pool'); + await dismissDialogIfPresent(page); + + // FINAL objective: assert through the Mission Complete modal, since the + // checklist stops repainting once the completion flow takes over + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario13-full-completion.spec.ts b/e2e/specs/nats-eu-scenario13-full-completion.spec.ts new file mode 100644 index 00000000..d35cf622 --- /dev/null +++ b/e2e/specs/nats-eu-scenario13-full-completion.spec.ts @@ -0,0 +1,238 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + assignContact, + closeWorkingDocumentIfOpen, + computeLinkBudget, + fillAndChange, + parkAntenna, + programTrack, + setRxModemFrequency, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 13 "The Numbers Don't Lie" - full completion (phase 16). + * + * The 89 deg zenith pass flown and ridden through the keyhole (lock lost at + * the top, held 100 s on the descending leg), the survey budget computed and + * SAR-1 measured 4 dB short at the survey geometry on a degraded LNB, the + * ephemeris and pointing ruled out, the LNB read, Shetland proven clean, the + * customer's SAR-3 collect reallocated there and flown FROM SH-02, and the + * trend report filed. Sim clock starts 2027-03-29 13:00:00Z: + * GW-01 MERIDIAN-SAR-2 AOS 13:12:00Z max el 89.5 at 13:16:56Z LOS 13:21:56Z + * GW-01 MERIDIAN-SAR-1 AOS 13:30:01Z max el 28.0 at 13:34:46Z LOS 13:39:30Z + * SH-02 MERIDIAN-SAR-3 AOS 13:52:00Z max el 31.9 at 13:56:46Z LOS 14:01:35Z + * + * The keyhole hold counts real seconds of continuous receiver lock, so that + * step waits ~2 min of wall time after the jump past culmination. + * + * Objective flow (17): + * 1. review-mission-brief 2. galway-pre-pass-sweep 3. tune-for-the-zenith-pass + * 4. preposition-for-sar2 5. fly-the-zenith-pass 6. ride-through-the-keyhole + * 7. record-anomaly-1 8. retune-for-the-survey-pass 9. compute-the-survey-budget + * 10. measure-sar1-at-galway 11. rule-out-the-ephemeris 12. read-the-galway-lnb + * 13. prove-shetland-is-clean 14. reallocate-the-collect 15. arm-shetland-for-sar3 + * 16. fly-the-collect-from-shetland 17. file-the-trend-report + */ +test.describe('nats-eu Scenario 13 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario12 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario13'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test("[review-mission-brief] reads the week's log", async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'You do not know yet'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, "Read the Week's Log"); + }); + + test('[galway-pre-pass-sweep] sweeps the Galway board, reference and LNB LO', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Pre-Pass Sweep'); + }); + + test('[tune-for-the-zenith-pass] tunes for SAR-2 and predicts the keyhole', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await fillAndChange(page, '#sa-span', '2'); + await answerSystemQuiz(page, 'Swing almost 180 degrees'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune for the Zenith Pass'); + }); + + test('[preposition-for-sar2] parks the tracker on the AOS azimuth', async () => { + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 163, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for SAR-2'); + }); + + test('[fly-the-zenith-pass] program-tracks SAR-2 and locks on the ascending leg', async () => { + await advanceMissionClockToUtc(page, '2027-03-29T13:12:30Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-29T13:15:50Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Fly the Zenith Pass', 60000); + }); + + test('[ride-through-the-keyhole] loses the bird at the top and holds lock 100 s on the way down', async () => { + test.setTimeout(240000); + await missionControl.selectTab('rx-analysis'); + await answerSystemQuiz(page, 'The azimuth keyhole'); + await dismissDialogIfPresent(page); + // Past culmination: the pedestal swings ~180 deg of azimuth, drops lock, + // re-locks on the descending leg, and the 100 s hold runs on real time. + await advanceMissionClockToUtc(page, '2027-03-29T13:17:15Z'); + await missionControl.selectTab('rx-analysis'); + await waitForObjectiveComplete(missionControl, 'Ride Through the Keyhole', 200000); + }); + + test('[record-anomaly-1] writes the keyhole into the report as an operations note', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'An operations note to the planners'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Record the First Anomaly'); + }); + + test('[retune-for-the-survey-pass] retunes the receiver and analyzer for SAR-1', async () => { + await advanceMissionClockToUtc(page, '2027-03-29T13:22:30Z'); + await closeWorkingDocumentIfOpen(page); + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retune for the Survey Pass'); + }); + + test('[compute-the-survey-budget] enters the survey worksheet', async () => { + await computeLinkBudget(page, missionControl, { eirpDbm: 28, fsplDb: 171.4, rxGainDbi: 51.8, noiseTempK: 88, bandwidthMHz: 36, miscLossDb: 1 }); + await answerSystemQuiz(page, 'The survey number'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Compute the Survey Budget'); + }); + + test('[measure-sar1-at-galway] measures SAR-1 four dB short at the survey geometry', async () => { + await advanceMissionClockToUtc(page, '2027-03-29T13:30:30Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-29T13:34:00Z'); + await answerSystemQuiz(page, '1 dB over the 6 dB threshold'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Measure SAR-1 at Galway', 60000); + }); + + test('[rule-out-the-ephemeris] reads the ephemeris panel and names the remaining candidate', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, 'The receive chain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Rule Out the Ephemeris'); + }); + + test('[read-the-galway-lnb] reads the LNB and records the second anomaly', async () => { + await missionControl.selectTab('rx-analysis'); + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The LNA is the first stage'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Galway LNB'); + }); + + test('[prove-shetland-is-clean] reads the Shetland LNB and reference', async () => { + await closeWorkingDocumentIfOpen(page); + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Prove Shetland Is Clean'); + }); + + test('[reallocate-the-collect] moves the SAR-3 collect to Shetland', async () => { + await missionControl.selectTab('contact-schedule'); + await assignContact(page, 'T-SAR3-SH', 'SH-02'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await answerSystemQuiz(page, 'Derate GW-01 by 4 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reallocate the Collect'); + }); + + test('[arm-shetland-for-sar3] tunes Shetland to the SAR-3 plan and parks it', async () => { + await setRxModemFrequency(page, missionControl, 1340); + await fillAndChange(page, '#sa-center-freq', '1315'); + await fillAndChange(page, '#sa-span', '2'); + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 139, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm Shetland for SAR-3'); + }); + + test('[fly-the-collect-from-shetland] decodes SAR-3 from the SH-02 console', async () => { + await advanceMissionClockToUtc(page, '2027-03-29T13:52:30Z'); + await programTrack(page, missionControl, '61703'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-29T13:55:30Z'); + await answerSystemQuiz(page, 'No: 20 log(762 / 694)'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Fly the Collect from Shetland', 60000); + }); + + test('[file-the-trend-report] files the recommendation and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Rotterdam: the keyhole is an operations note'); + await dismissDialogIfPresent(page); + + // The FINAL objective is asserted through the Mission Complete modal, not + // the checklist: the checklist stops repainting when the completion flow + // takes over, so its last row never shows the completed class. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario14-full-completion.spec.ts b/e2e/specs/nats-eu-scenario14-full-completion.spec.ts new file mode 100644 index 00000000..2960e86a --- /dev/null +++ b/e2e/specs/nats-eu-scenario14-full-completion.spec.ts @@ -0,0 +1,233 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, assignContact, closeWorkingDocumentIfOpen, fillAndChange, parkAntenna, programTrack, setRxModemFrequency, setSwitch } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 14 "Atlantic Low" - full completion (phase 16). + * + * Real Ku rain fade on Galway for the priority collect: the fade read on the + * board, the collect reallocated to Shetland and flown FROM SH-02, Galway + * armed to watch its own pass drown (C/N at or below 5 dB in 30 mm/h), the + * feed heater on before the front turns to sleet, the P2 window kept at + * Galway on measured margin and ridden out on a clean feed. + * Sim clock starts 2027-04-02 11:00:00Z: + * rain at GW-01 11:01 - 11:26 (30 mm/h), sleet 11:26 - 12:11 + * SH-02 MERIDIAN-SAR-2 AOS 11:10:42Z max el 34.1 at 11:15:35Z LOS 11:20:25Z + * GW-01 MERIDIAN-SAR-2 AOS 11:12:53Z max el 19.1 at 11:17:30Z LOS 11:22:03Z + * GW-01 MERIDIAN-SAR-1 AOS 11:44:59Z max el 29.6 at 11:49:43Z LOS 11:54:31Z + * + * The rain rate runs on the mission clock (advanceMissionClockToUtc), so the + * fade is present after the jump; ice accumulates on real frames and the + * heater is on before the sleet, so the ride-out sees a clean feed. + * + * Objective flow (17): + * 1. review-mission-brief 2. read-the-fade-on-the-board 3. galway-cold-chain + * 4. reallocate-the-collect 5. arm-galway-to-watch-the-fade 6. shetland-weather-sweep + * 7. preposition-shetland-for-sar2 8. acquire-sar2-from-shetland 9. decode-the-collect-from-shetland + * 10. observe-the-galway-fade 11. log-the-collect 12. protect-the-feed + * 13. decide-the-ride-out 14. retune-galway-for-sar1 15. ride-it-out + * 16. log-the-ride-out 17. close-the-weather-log + */ +test.describe('nats-eu Scenario 14 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + /** Enable the feed heater on the selected station's ACU tab (per-instance id suffix). */ + async function enableFeedHeater(): Promise { + await missionControl.selectTab('acu-control'); + const heater = page.locator('input[id$="heater-switch"]:visible').first(); + await expect(heater).toBeVisible({ timeout: 10000 }); + if (!(await heater.isChecked())) { + await heater.evaluate((el) => (el as HTMLElement).click()); + } + await expect(heater).toBeChecked(); + } + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario13 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario14'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the weather brief and the fade magnitude', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Several dB - about 7'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Weather Brief'); + }); + + test('[read-the-fade-on-the-board] reads the rain fade and rules out the heater for rain', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await answerSystemQuiz(page, 'No. Rain attenuation is in the air'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Fade on the Board'); + }); + + test('[galway-cold-chain] confirms the reference and puts the transmit chain cold', async () => { + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(1500); + await setSwitch(page, '#buc-mute', true); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Cold Chain'); + }); + + test('[reallocate-the-collect] gives the collect to Shetland and validates the plan', async () => { + await missionControl.selectTab('contact-schedule'); + await assignContact(page, 'W-SAR2-SH', 'SH-02'); + await assignContact(page, 'W-SAR2-GW', 'GW-01'); + await assignContact(page, 'W-SAR1-GW', 'GW-01'); + await assignContact(page, 'W-SAR1-SH', 'SH-02'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await answerSystemQuiz(page, 'A P1 collect gets the site that can close it'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reallocate the Collect'); + }); + + test('[arm-galway-to-watch-the-fade] retunes Galway and arms program-track on SAR-2', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await programTrack(page, missionControl, '61702'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm Galway to Watch the Fade'); + }); + + test('[shetland-weather-sweep] sweeps SH-02 and tunes it for SAR-2', async () => { + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'SH-02 Weather Sweep'); + }); + + test('[preposition-shetland-for-sar2] parks the Shetland tracker on the AOS azimuth', async () => { + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 23, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position Shetland for SAR-2'); + }); + + test('[acquire-sar2-from-shetland] program-tracks SAR-2 on the SH-02 pedestal', async () => { + await advanceMissionClockToUtc(page, '2027-04-02T11:12:00Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-2 from Shetland', 60000); + }); + + test('[decode-the-collect-from-shetland] decodes the collect in dry sky', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-02T11:14:00Z'); + await answerSystemQuiz(page, 'That the collect is captured'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Collect from Shetland', 60000); + }); + + test('[observe-the-galway-fade] reads the rain-faded C/N on the armed Galway pass', async () => { + await advanceMissionClockToUtc(page, '2027-04-02T11:17:00Z'); + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('rx-analysis'); + await answerSystemQuiz(page, 'Rain: about 7 dB of attenuation'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Observe the Galway Fade', 60000); + }); + + test('[log-the-collect] logs the collect and the measurement', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'faded to about 2 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Log the Collect'); + }); + + test('[protect-the-feed] turns the Galway feed heater on before the sleet', async () => { + await advanceMissionClockToUtc(page, '2027-04-02T11:23:00Z'); + await enableFeedHeater(); + await answerSystemQuiz(page, 'On before the sleet'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Protect the Feed'); + }); + + test('[decide-the-ride-out] keeps the P2 window at Galway', async () => { + await answerSystemQuiz(page, 'Keep it at Galway'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decide the Ride-Out'); + }); + + test('[retune-galway-for-sar1] retunes Galway for the ride-out', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retune Galway for SAR-1'); + }); + + test('[ride-it-out] decodes SAR-1 through the sleet on a clean feed', async () => { + await advanceMissionClockToUtc(page, '2027-04-02T11:45:30Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-02T11:48:00Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Ride It Out', 60000); + }); + + test('[log-the-ride-out] logs the ride-out with the feed loss it avoided', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Decoded at ~11 dB on zero feed loss'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Log the Ride-Out'); + }); + + test('[close-the-weather-log] writes the standing rule and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Heater on at the first precipitation warning'); + await dismissDialogIfPresent(page); + + // The FINAL objective is asserted through the Mission Complete modal, not + // the checklist: the checklist stops repainting when the completion flow + // takes over, so its last row never shows the completed class. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario15-full-completion.spec.ts b/e2e/specs/nats-eu-scenario15-full-completion.spec.ts new file mode 100644 index 00000000..b947a386 --- /dev/null +++ b/e2e/specs/nats-eu-scenario15-full-completion.spec.ts @@ -0,0 +1,256 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + assignContact, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + fillAndChange, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 15 "Rotation Day" - full completion (phase 16). + * + * The fleet key rotation between passes: the order acknowledged in the audit + * log, the day plan built, SAR-1 received on the retiring key with no + * commands, the command key rotated on the TT&C console, the fleet traffic + * key roll (E3, 07:27) found as a Mismatch and followed with the payload + * re-key, then SAR-2 decoded cold under the new traffic key, the chain up in + * order, KEY-VERIFY acknowledged under the new command key, and the chain + * secured. Sim clock starts 2027-04-06 07:00:00Z: + * GW-01 MERIDIAN-SAR-1 AOS 07:15:59Z max el 27.0 at 07:20:47Z LOS 07:25:31Z + * traffic key rolls 07:27:00Z (mission clock) + * GW-01 MERIDIAN-SAR-2 AOS 07:33:58Z max el 24.9 at 07:38:40Z LOS 07:43:25Z + * command window 07:34:18Z - 07:43:05Z + * + * Objective flow (18): + * 1. review-mission-brief 2. acknowledge-the-order 3. build-the-day-plan + * 4. galway-reference-and-keys 5. tune-for-sar1 6. acquire-sar1 + * 7. receive-on-the-retiring-key 8. log-the-receive-pass 9. rotate-the-command-key + * 10. read-the-traffic-key 11. re-key-the-payload-crypto 12. tune-for-sar2-and-the-uplink + * 13. acquire-sar2 14. decode-on-the-new-traffic-key 15. chain-up-for-the-proof + * 16. prove-the-new-key-on-orbit 17. safe-the-uplink 18. close-the-comsec-record + */ +test.describe('nats-eu Scenario 15 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario14 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario15'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the rotation order', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Any command: a key in rotation'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Rotation Order'); + }); + + test('[acknowledge-the-order] signs off the audit trail and flags the order', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-mark-reviewed')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await domClick(page, 'button[data-event-id="evt-rotation-order"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acknowledge the Order'); + }); + + test('[build-the-day-plan] allocates four contacts with both Galway halves on GW-01', async () => { + await missionControl.selectTab('contact-schedule'); + await assignContact(page, 'R-SAR1-SH', 'SH-02'); + await assignContact(page, 'R-SAR1-GW', 'GW-01'); + await assignContact(page, 'R-SAR2-GW', 'GW-01'); + await assignContact(page, 'R-SAR2-SH', 'SH-02'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await answerSystemQuiz(page, 'The command originates from the TT&C console'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Build the Day Plan'); + }); + + test('[galway-reference-and-keys] reads the reference and both keys before anything moves', async () => { + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(2500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'Traffic key: downlink, rolled by Rotterdam at 07:27'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Reference and Keys'); + }); + + test('[tune-for-sar1] retunes the receiver and parks on the AOS azimuth', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 22, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune for SAR-1'); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-06T07:16:30Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[receive-on-the-retiring-key] decodes on the old key and sends nothing', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-06T07:19:30Z'); + await answerSystemQuiz(page, 'The order retires KEY-2027-Q1'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Receive on the Retiring Key', 60000); + }); + + test('[log-the-receive-pass] writes the retire line', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Last contact on KEY-2027-Q1'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Log the Receive Pass'); + }); + + test('[rotate-the-command-key] begins and completes the command key rotation', async () => { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-begin-rotation')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#cmd-begin-rotation'); + await page.waitForTimeout(300); + await domClick(page, '#cmd-complete-rotation'); + await expect(page.locator('#cmd-key-badge')).toHaveText(/Valid/i, { timeout: 5000 }); + await answerSystemQuiz(page, 'Been rejected as key-invalid'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Rotate the Command Key'); + }); + + test('[read-the-traffic-key] finds the fleet traffic key roll as a Mismatch', async () => { + // The fleet traffic key rolls at 07:27 on the mission clock + await advanceMissionClockToUtc(page, '2027-04-06T07:27:30Z'); + await missionControl.selectTab('rx-analysis'); + await expect(page.locator('#rx-payload-dec-key-status').first()).toHaveText(/Mismatch/i, { timeout: 15000 }); + await answerSystemQuiz(page, 'Rotterdam rolled the fleet traffic key'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Traffic Key', 60000); + }); + + test('[re-key-the-payload-crypto] loads the new traffic key', async () => { + await missionControl.selectTab('tx-chain'); + await domClick(page, '#tx-payload-rekey-btn'); + await expect(page.locator('#tx-payload-enc-key-status').first()).toHaveText(/Valid/i, { timeout: 5000 }); + await page.waitForTimeout(1500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Re-key the Payload Crypto', 60000); + }); + + test('[tune-for-sar2-and-the-uplink] retunes the receiver, the analyzer and the transmit modem', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await setTxModemFrequency(page, missionControl, 1435); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune for SAR-2 and the Uplink'); + }); + + test('[acquire-sar2] program-tracks SAR-2 and observes its beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-06T07:34:30Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[decode-on-the-new-traffic-key] proves the traffic key with the chain cold', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-06T07:37:00Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode on the New Traffic Key', 60000); + }); + + test('[chain-up-for-the-proof] engages Doppler comp and brings the chain up in order', async () => { + await enableDopplerComp(page, missionControl); + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up for the Proof'); + }); + + test('[prove-the-new-key-on-orbit] sends KEY-VERIFY under the new key', async () => { + await sendCommandAndExpectAck(page, missionControl, 'KEY-VERIFY'); + await answerSystemQuiz(page, 'That the spacecraft authenticated a command under Q2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Prove the New Key on Orbit'); + }); + + test('[safe-the-uplink] chains down in mirror order', async () => { + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[close-the-comsec-record] writes the evidence chain and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The order acknowledged, the last Q1'); + await dismissDialogIfPresent(page); + + // The FINAL objective is asserted through the Mission Complete modal, not + // the checklist: the checklist stops repainting when the completion flow + // takes over, so its last row never shows the completed class. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario16-full-completion.spec.ts b/e2e/specs/nats-eu-scenario16-full-completion.spec.ts new file mode 100644 index 00000000..12fbfd6b --- /dev/null +++ b/e2e/specs/nats-eu-scenario16-full-completion.spec.ts @@ -0,0 +1,268 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + assignContact, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + fillAndChange, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 16 "Cascade" - full completion (phase 16, Phase 2 capstone). + * + * Three problems, one operator: the slipped SAR-2 pass resolved on the board, + * Galway readied to command, the Shetland BUC cooled the right way (carrier + * off, muted, powered; E3 cooling fault at 12:01:30), Shetland armed to take + * SAR-2 on its own, the SAR-1 command contact flown through a GNSS outage + * (E3, 12:18:40 - 12:27:00) with both commands acknowledged on holdover, + * Shetland's unattended pass checked before LOS, Galway secured, the + * reference confirmed re-locked, the alarm closed and the report written. + * Sim clock starts 2027-04-09 12:00:00Z: + * GW-01 MERIDIAN-SAR-1 AOS 12:17:59Z max el 29.8 at 12:22:49Z LOS 12:27:35Z + * command window 12:18:20Z - 12:27:20Z + * SH-02 MERIDIAN-SAR-2 AOS 12:21:16Z max el 28.0 at 12:26:05Z LOS 12:30:51Z + * + * Objective flow (20): + * 1. review-mission-brief 2. resolve-the-slipped-pass 3. galway-reference-check + * 4. galway-crypto-check 5. galway-uplink-setup 6. preposition-galway-for-sar1 + * 7. shetland-alarm-sweep 8. remove-the-drive 9. confirm-shetland-buc-cooling + * 10. arm-shetland-for-sar2 11. acquire-sar1-at-galway 12. chain-up-for-the-command + * 13. command-the-playback 14. command-on-holdover 15. check-the-shetland-pass + * 16. secure-galway 17. confirm-galway-reference-recovered + * 18. close-the-shetland-alarm 19. customer-impact 20. actions-and-open-items + */ +test.describe('nats-eu Scenario 16 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario15 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario16'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] takes the shift and orders the work', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'The plan first'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Take the Shift'); + }); + + test('[resolve-the-slipped-pass] keeps the command contact on Galway and covers SAR-2 from Shetland', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('contact-schedule'); + await assignContact(page, 'C-SAR1-GW', 'GW-01'); + await assignContact(page, 'C-SAR2-SH', 'SH-02'); + await expect(page.locator('#cs-conflict-count')).toHaveText('0'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await answerSystemQuiz(page, 'It overlaps the command contact'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Resolve the Slipped Pass'); + }); + + test('[galway-reference-check] reads the Galway reference before the window', async () => { + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Reference Check'); + }); + + test('[galway-crypto-check] reads both keys', async () => { + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(2500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Crypto Check'); + }); + + test('[galway-uplink-setup] retunes the transmit modem, engages Doppler comp and sets the analyzer', async () => { + await setTxModemFrequency(page, missionControl, 1405); + await enableDopplerComp(page, missionControl); + await missionControl.selectTab('rx-analysis'); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Uplink Setup'); + }); + + test('[preposition-galway-for-sar1] parks the Galway tracker on the AOS azimuth', async () => { + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 21, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position Galway for SAR-1'); + }); + + test('[shetland-alarm-sweep] reads the Shetland BUC alarm', async () => { + // The SH-02 BUC cooling fault trips at 12:01:30 on the mission clock + await advanceMissionClockToUtc(page, '2027-04-09T12:04:00Z'); + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('dashboard'); + await answerSystemQuiz(page, 'The drive. A BUC dissipates heat'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'SH-02 Alarm Sweep'); + }); + + test('[remove-the-drive] takes the carrier off and mutes the Shetland BUC', async () => { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#tx-transmit-switch', false); + await setSwitch(page, '#buc-mute', true); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Remove the Drive from the Shetland BUC'); + }); + + test('[confirm-shetland-buc-cooling] watches the BUC come down under 70 degC', async () => { + test.setTimeout(240000); + await missionControl.selectTab('tx-chain'); + await answerSystemQuiz(page, 'Twice wrong'); + await dismissDialogIfPresent(page); + // Thermal time constant ~5.5 min: from ~74 degC toward a 55 degC muted target. + await waitForObjectiveComplete(missionControl, 'Confirm the Shetland BUC Cooling', 200000); + }); + + test('[arm-shetland-for-sar2] retunes Shetland and arms program-track on SAR-2', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await programTrack(page, missionControl, '61702'); + await answerSystemQuiz(page, 'The contact is receive-only'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm Shetland for SAR-2'); + }); + + test('[acquire-sar1-at-galway] returns to Galway and program-tracks SAR-1', async () => { + await advanceMissionClockToUtc(page, '2027-04-09T12:18:30Z'); + await missionControl.selectGroundStation('GW-01'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-1 at Galway', 60000); + }); + + test('[chain-up-for-the-command] brings the Galway chain up in order', async () => { + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up for the Command'); + }); + + test('[command-the-playback] sends REC-PLAYBACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'REC-PLAYBACK'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Command the Playback'); + }); + + test('[command-on-holdover] reads the holdover and sends the watchdog reset on it', async () => { + // The GW-01 GNSS outage trips at 12:18:40 on the mission clock + await advanceMissionClockToUtc(page, '2027-04-09T12:20:30Z'); + await missionControl.selectTab('gps-timing'); + // Both stations' GPS Timing panels stay mounted; only GW-01 is in holdover. + await expect(page.locator('#gpsdo-holdover-badge').filter({ hasText: /^ACTIVE$/u })).toHaveCount(1, { timeout: 15000 }); + await answerSystemQuiz(page, 'Yes. A GNSS outage with the switch up'); + await sendCommandAndExpectAck(page, missionControl, 'OBC-WDT-RESET'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Command on Holdover', 60000); + }); + + test('[check-the-shetland-pass] finds Shetland tracking and decoding SAR-2 on its own', async () => { + await advanceMissionClockToUtc(page, '2027-04-09T12:24:40Z'); + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Check the Shetland Pass', 60000); + }); + + test('[secure-galway] chains Galway down in mirror order', async () => { + await missionControl.selectGroundStation('GW-01'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Secure Galway'); + }); + + test('[confirm-galway-reference-recovered] sees the GNSS return and logs the contacts', async () => { + // The outage clears at 12:27:00 on the mission clock + await advanceMissionClockToUtc(page, '2027-04-09T12:27:40Z'); + await missionControl.selectTab('gps-timing'); + await expect(page.locator('#gpsdo-holdover-badge').filter({ hasText: /^ACTIVE$/u })).toHaveCount(0, { timeout: 15000 }); + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Shetland unattended on a muted BUC'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Galway Reference Recovered', 60000); + }); + + test('[close-the-shetland-alarm] confirms the Shetland BUC normal and closes the alarm', async () => { + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'The contact was receive-only and the BUC is transmit only'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Close the Shetland Alarm', 60000); + }); + + test('[customer-impact] writes the impact line', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'None he can see'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Customer Impact'); + }); + + test('[actions-and-open-items] writes the actions line and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Maintenance: GW-01 GNSS antenna, SH-02 BUC fan'); + await dismissDialogIfPresent(page); + + // The FINAL objective is asserted through the Mission Complete modal, not + // the checklist: the checklist stops repainting when the completion flow + // takes over, so its last row never shows the completed class. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario17-full-completion.spec.ts b/e2e/specs/nats-eu-scenario17-full-completion.spec.ts new file mode 100644 index 00000000..5d2fe16f --- /dev/null +++ b/e2e/specs/nats-eu-scenario17-full-completion.spec.ts @@ -0,0 +1,215 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, parkAntenna, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 17 "Unusual Activity" - full completion (Gray Zone 1/8). + * + * Night shift: read the overnight log as a set and flag the spray, fly the + * SAR-1 receive pass, characterise the 1410 MHz carrier that appears after + * culmination and does not Doppler, call it (the first Gray Zone decision, + * graded on live evidence facts), open the incident, flag the live + * configuration export, disable the legacy account, fly SAR-2, close the + * record. Clock starts 2027-04-12 02:00:00Z: + * MERIDIAN-SAR-1 AOS 02:20:00Z max el 30.0 deg at 02:24:46Z LOS 02:29:34Z + * carrier on 02:25:30Z .. 02:34:00Z (terrestrial, Galway only) + * export 02:33:00Z in the audit log + * MERIDIAN-SAR-2 AOS 02:40:01Z max el 26.3 deg at 02:44:46Z LOS 02:49:30Z + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. rx-chain-ready 5. read-the-overnight-log 6. flag-the-spray + * 7. preposition-for-aos 8. acquire-sar1 9. decode-sar1 + * 10. characterise-the-carrier 11. call-the-carrier 12. open-the-incident + * 13. flag-the-config-export 14. harden-the-service-account + * 15. retune-for-sar2 16. decode-sar2 17. close-the-shift + */ + +/** Set an account's status on the Security console access-control panel. */ +async function setAccountStatus(page: Page, accountId: string, status: 'active' | 'disabled' | 'expired'): Promise { + const select = page.locator(`select.sec-account-select[data-account-id="${accountId}"]`); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption({ value: status }); +} + +test.describe('nats-eu Scenario 17 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario17'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the night brief and the order of the rule', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Acting first destroys the record'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Night Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[rx-chain-ready] retunes the modem to 1414 MHz and frames a 40 MHz span', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1414'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + }); + + test('[read-the-overnight-log] signs off the trail and names the spray', async () => { + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-mark-reviewed')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await answerSystemQuiz(page, 'A spray: one source trying a few passwords'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Overnight Log'); + }); + + test('[flag-the-spray] flags the legacy-account entry and notes the time', async () => { + await domClick(page, 'button[data-event-id="evt-spray-legacy"]'); + await answerSystemQuiz(page, 'The flag and the time seen are the first lines'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Flag the Spray'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 187 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 187, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-12T02:20:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[decode-sar1] holds RX lock above 8 dB before the carrier arrives', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-12T02:23:00Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Routine Downlink', 60000); + }); + + test('[characterise-the-carrier] frames the 1410 MHz carrier and reads the no-Doppler tell', async () => { + // The carrier comes on at 02:25:30, after culmination. + await advanceMissionClockToUtc(page, '2027-04-12T02:26:00Z'); + await missionControl.selectTab('rx-analysis'); + await fillAndChange(page, '#sa-center-freq', '1410'); + await fillAndChange(page, '#sa-span', '2'); + await page.waitForTimeout(3000); // RX Analysis stays on screen so the carrier read latches + await answerSystemQuiz(page, 'It is not on the bird'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Characterise the Carrier', 60000); + }); + + test('[call-the-carrier] reads the carrier and the key, then calls it as interference', async () => { + // Both evidence latches read on RX Analysis: the carrier and the RX key status. + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(3000); + + // Graded on live facts: the scripted carrier is radiating, no fault is + // injected, the key reads Valid. The helper refuses to click until both + // evidence items show latched. + await answerDecision(page, 'External interference - characterise it and report it', ['Carrier read on RX Analysis', 'RX key status read on RX Analysis']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Carrier'); + }); + + test('[open-the-incident] opens the record with both indicators and no cause', async () => { + await answerSystemQuiz(page, 'Both, as seen'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Open the Incident'); + }); + + test('[flag-the-config-export] flags the live export after it lands at 02:33', async () => { + await advanceMissionClockToUtc(page, '2027-04-12T02:33:30Z'); + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-config-export"]'); + await answerSystemQuiz(page, 'Enough to plan against the station'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Flag the Configuration Export'); + }); + + test('[harden-the-service-account] disables the legacy account', async () => { + await setAccountStatus(page, 'svc-legacy', 'disabled'); + await answerSystemQuiz(page, 'Because the account is a way in that still works'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Disable the Legacy Account'); + }); + + test('[retune-for-sar2] retunes the modem and analyzer for SAR-2', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retune for SAR-2'); + }); + + test('[decode-sar2] retargets to SAR-2 and decodes it clean', async () => { + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-04-12T02:40:40Z'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-12T02:43:00Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Second Downlink', 60000); + }); + + test('[close-the-shift] writes the debrief line and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'A decommissioned account was still active'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario18-full-completion.spec.ts b/e2e/specs/nats-eu-scenario18-full-completion.spec.ts new file mode 100644 index 00000000..a0815117 --- /dev/null +++ b/e2e/specs/nats-eu-scenario18-full-completion.spec.ts @@ -0,0 +1,246 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, parkAntenna, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 18 "Dirty Spectrum" - full completion (Gray Zone 2/8). + * + * Decode SAR-2 clean, read the cadence of the carrier that comes back after + * culmination (60 s on / 30 s off, no Doppler, Galway only), call it - the + * decision is graded on the interference *envelope* fact, so an off phase + * does not flip the answer - notch it and keep the pass, log event 1, retune + * for SAR-1, notch the carrier again when it follows the plan into SAR-1's + * band, record the implication, file the regulator package, debrief. + * Clock starts 2027-04-14 10:00:00Z: + * MERIDIAN-SAR-2 AOS 10:18:00Z max el 31.8 deg at 10:22:49Z LOS 10:27:34Z + * carrier 1 10:23:30Z .. 10:31:30Z on 1374 MHz IF, on 60 / off 30 + * MERIDIAN-SAR-1 AOS 10:40:00Z max el 24.0 deg at 10:44:45Z LOS 10:49:29Z + * carrier 2 10:41:30Z .. 10:48:30Z on 1410 MHz IF, same cadence + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. rx-chain-ready-sar2 5. preposition-for-aos 6. acquire-sar2 + * 7. decode-sar2 8. read-the-cycle 9. call-the-cycle + * 10. notch-the-carrier 11. log-the-first-event 12. rx-chain-ready-sar1 + * 13. acquire-sar1 14. decode-sar1-under-it 15. it-followed-the-plan + * 16. file-the-regulator-report 17. close-and-debrief + */ + +/** Configure one IF notch on the RX Analysis tab (the C1 S21 helper, verbatim). */ +async function configureNotchFilter(page: Page, config: { centerFrequency: number; bandwidth: number; depth: number; notchIndex: number }): Promise { + const prefix = `notch-${config.notchIndex}`; + + const powerSwitch = page.locator('#notch-power'); + await expect(powerSwitch).toBeVisible({ timeout: 5000 }); + if (!(await powerSwitch.isChecked())) { + await powerSwitch.click(); + await expect(powerSwitch).toBeChecked(); + await page.waitForTimeout(300); + } + + const enableSwitch = page.locator(`#${prefix}-enabled`); + await expect(enableSwitch).toBeVisible(); + if (!(await enableSwitch.isChecked())) { + await enableSwitch.click(); + await expect(enableSwitch).toBeChecked(); + await page.waitForTimeout(200); + } + + for (const [suffix, value] of [ + ['freq', config.centerFrequency], + ['bw', config.bandwidth], + ['depth', config.depth], + ] as const) { + const input = page.locator(`#${prefix}-${suffix}`); + await expect(input).toBeVisible(); + await input.fill(value.toString()); + await input.press('Tab'); + await page.waitForTimeout(100); + } + + const applyBtn = page.locator('#notch-apply-btn'); + if (await applyBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await applyBtn.click(); + await page.waitForTimeout(300); + } + await page.waitForTimeout(500); +} + +test.describe('nats-eu Scenario 18 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario18'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the day brief and the posture', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'The record: a second appearance is evidence of persistence'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Day Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[rx-chain-ready-sar2] retunes the modem to 1370 MHz and frames a 40 MHz span', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-2'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 23 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 23, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar2] program-tracks SAR-2 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-14T10:18:40Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[decode-sar2] holds RX lock above 8 dB before the carrier arrives', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-14T10:20:40Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode SAR-2 Clean', 60000); + }); + + test('[read-the-cycle] frames the 1374 MHz carrier in its first on-window and reads the cadence', async () => { + // First on-window 10:23:30 .. 10:24:30. + await advanceMissionClockToUtc(page, '2027-04-14T10:23:40Z'); + await missionControl.selectTab('rx-analysis'); + await fillAndChange(page, '#sa-center-freq', '1374'); + await fillAndChange(page, '#sa-span', '2'); + await page.waitForTimeout(3000); // RX Analysis stays on screen so the carrier read latches + await answerSystemQuiz(page, 'Intent: accidents are continuous or erratic'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Cycle', 60000); + }); + + test('[call-the-cycle] reads the carrier and the key in an on-window, then calls it', async () => { + // Second on-window 10:25:00 .. 10:26:00: the carrier-seen latch needs the + // carrier present; the decision itself is graded on the envelope. + await advanceMissionClockToUtc(page, '2027-04-14T10:25:05Z'); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(3000); + await answerDecision(page, 'Deliberate interference - notch it, keep the pass', ['Carrier read on RX Analysis', 'RX key status read on RX Analysis']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call It'); + }); + + test('[notch-the-carrier] notches 1374 MHz and reads why', async () => { + await missionControl.selectTab('rx-analysis'); + await configureNotchFilter(page, { centerFrequency: 1374, bandwidth: 2, depth: 30, notchIndex: 0 }); + await answerSystemQuiz(page, 'The video is where it is'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Notch the Carrier', 60000); + }); + + test('[log-the-first-event] records event 1 after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-04-14T10:28:00Z'); + await answerSystemQuiz(page, '10:23:30 carrier 11726 MHz RF'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Log Event 1'); + }); + + test('[rx-chain-ready-sar1] retunes the modem and analyzer for SAR-1', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1414'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + }); + + test('[acquire-sar1] retargets program-track to SAR-1', async () => { + await programTrack(page, missionControl, '61701'); + await advanceMissionClockToUtc(page, '2027-04-14T10:40:40Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[decode-sar1-under-it] notches 1410 MHz and holds C/N above 7 dB', async () => { + // Carrier 2 is on from 10:41:30; notch it, then ride the high-elevation segment. + await advanceMissionClockToUtc(page, '2027-04-14T10:41:40Z'); + await missionControl.selectTab('rx-analysis'); + await configureNotchFilter(page, { centerFrequency: 1410, bandwidth: 2, depth: 30, notchIndex: 1 }); + await advanceMissionClockToUtc(page, '2027-04-14T10:43:20Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode SAR-1 Under It', 60000); + }); + + test('[it-followed-the-plan] records the implication', async () => { + await answerSystemQuiz(page, 'Whoever runs it knows the frequency plan'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'It Followed the Plan'); + }); + + test('[file-the-regulator-report] assembles the package after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-04-14T10:50:00Z'); + await answerSystemQuiz(page, 'Victim and interferer parameters'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'File the Regulator Package'); + }); + + test('[close-and-debrief] writes the debrief line and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The export is live'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario19-full-completion.spec.ts b/e2e/specs/nats-eu-scenario19-full-completion.spec.ts new file mode 100644 index 00000000..2a9107f9 --- /dev/null +++ b/e2e/specs/nats-eu-scenario19-full-completion.spec.ts @@ -0,0 +1,229 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + expectDashboardAlarm, + fillAndChange, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + sendCommandAndExpectNak, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 19 "Frequency Agility" - full completion (Gray Zone 3/8). + * + * The SAR-2 commanding pass: chain up, HK-DUMP ACKs on the fixed carrier, + * then a jammer on the 14035 MHz uplink at the bird from 13:22:30 and + * PLD-STATUS NAKs "uplink denied" with the key Valid, Doppler on, window open. + * Call it (the decision is graded on the uplink-jammed fact), key the hop set, + * go to hopping, SYNC LOCKED, resend and ACK through the jamming, chain down, + * log it, decode SAR-1 untouched, debrief. Clock starts 2027-04-16 13:00:00Z: + * MERIDIAN-SAR-2 AOS 13:20:01Z max el 27.9 deg at 13:24:43Z LOS 13:29:29Z + * command window 13:20:21Z .. 13:29:09Z; jammer 13:22:30Z .. 13:29:10Z + * MERIDIAN-SAR-1 AOS 13:42:01Z max el 25.2 deg at 13:46:46Z LOS 13:51:29Z + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. tune-the-uplink 5. key-check 6. rx-chain-ready + * 7. preposition-for-aos 8. acquire-sar2 9. chain-up + * 10. first-command 11. call-the-nak 12. go-to-hopping + * 13. ride-through 14. safe-the-uplink 15. log-the-denial + * 16. decode-sar1 17. close-and-debrief + */ +test.describe('nats-eu Scenario 19 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario19'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the brief and why the waveform is still fixed', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Hopping is a response, not a default'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO and the BUC reference', async () => { + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(3000); + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(3000); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[tune-the-uplink] retunes the TX modem to 1435 MHz and engages Doppler comp', async () => { + await setTxModemFrequency(page, missionControl, 1435); + await enableDopplerComp(page, missionControl); + await answerSystemQuiz(page, '1435 MHz (RF minus LO'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune the Uplink'); + }); + + test('[key-check] reads the command key on the TX chain', async () => { + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(3000); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Key Check'); + }); + + test('[rx-chain-ready] retunes the receiver and frames a 40 MHz span on 1370', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-2'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 138 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 138, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar2] program-tracks SAR-2 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-16T13:20:40Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[chain-up] brings the chain up in order inside the window', async () => { + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await page.waitForTimeout(3000); // TX Chain on screen so the HPA read latches + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up in Order'); + }); + + test('[first-command] sends HK-DUMP on the fixed carrier and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'HK-DUMP'); + await answerSystemQuiz(page, 'That the bird heard this command'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Housekeeping Dump'); + }); + + test('[call-the-nak] sends PLD-STATUS into the jammer, reads the key and the console, and calls uplink denial', async () => { + // The jammer opens at 13:22:30; the NAK reason is the console's own word for it. + await advanceMissionClockToUtc(page, '2027-04-16T13:22:45Z'); + await sendCommandAndExpectNak(page, missionControl, 'PLD-STATUS', 'Uplink denied - carrier jammed'); + + // Evidence: the command key on TX Chain, the TT&C console itself. + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(3000); + await missionControl.selectTab('commanding'); + await page.waitForTimeout(3000); + + // Graded on the uplink-jammed fact with the crypto intact. + await answerDecision(page, 'Uplink denial - key the hop set at both ends', ['Command key read on TX Chain', 'TT&C console read']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the NAK'); + }); + + test('[go-to-hopping] loads the hop-set key, switches the waveform, and gets sync', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, '#sec-transec-load-key'); + await expect(page.locator('#sec-transec-key-badge')).toHaveText('LOADED', { timeout: 10000 }); + await page.locator('#sec-transec-mode').selectOption({ value: 'hopping' }); + await expect(page.locator('#sec-transec-sync-badge')).toHaveText('SYNC LOCKED', { timeout: 10000 }); + await answerSystemQuiz(page, 'The hop set is the shared secret'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Go to Hopping'); + }); + + test('[ride-through] resends PLD-STATUS under TRANSEC and gets the ACK through the jamming', async () => { + await sendCommandAndExpectAck(page, missionControl, 'PLD-STATUS'); + await answerSystemQuiz(page, 'It moved your carrier out from under the jammer'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Ride It Through'); + }); + + test('[safe-the-uplink] chains down in mirror order after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-04-16T13:29:40Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[log-the-denial] records the window', async () => { + await answerSystemQuiz(page, '13:21 HK-DUMP ACK fixed'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Log the Denial'); + }); + + test('[decode-sar1] retunes, retargets and decodes SAR-1 untouched', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await programTrack(page, missionControl, '61701'); + await advanceMissionClockToUtc(page, '2027-04-16T13:42:40Z'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-16T13:45:00Z'); + await page.waitForTimeout(4000); + await answerSystemQuiz(page, 'A jammer reaches what it can reach'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode SAR-1 Untouched', 60000); + }); + + test('[close-and-debrief] writes the debrief line and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'A hop set used on the air is a hop set observed'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario2-full-completion.spec.ts b/e2e/specs/nats-eu-scenario2-full-completion.spec.ts new file mode 100644 index 00000000..14a197a1 --- /dev/null +++ b/e2e/specs/nats-eu-scenario2-full-completion.spec.ts @@ -0,0 +1,182 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, commitLinkWithMargin, computeLinkBudget, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 2 "Proving the Link" - full completion (phase 16). + * + * Acceptance shift: uplink confirmed cold, reference and LNB checked, survey + * inputs read off the rack, worksheet computed, path loss checked at the mask, + * then the SAR-1 pass measured on the rising leg, at culmination (Commit Link) + * and on the setting leg, the card filled in, and the SAR-2 regression set up. + * Sim clock starts 2027-03-15 13:45:00Z: + * MERIDIAN-SAR-1 AOS 14:03:10Z max el 28.0 at 14:06:45Z LOS 14:10:18Z + * Settled C/N: el 15 deg ~7.2 dB, culmination 10.9 dB (harness numbers). + * + * The optional regression pass (regression-sar2) is deliberately NOT driven: + * the card completes without it, which is the optional-objective gate check. + * + * Objective flow (16, one optional): + * 1. review-mission-brief 2. tx-cold-check 3. reference-and-lnb + * 4. read-the-rack 5. open-link-analysis 6. compute-link-budget + * 7. path-loss-check 8. review-pass-schedule 9. track-for-acceptance + * 10. rising-leg 11. commit-the-link 12. setting-leg + * 13. record-measurements 14. set-up-regression 15. regression-sar2 (optional) + * 16. sign-the-test-card + */ +test.describe('nats-eu Scenario 2 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + // Direct navigation bypasses the nats-eu-scenario1 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario2'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reviews the test card', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'The prediction'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Test Card'); + }); + + test('[tx-cold-check] confirms the HPA disabled and BUC muted', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('tx-chain'); + await answerSystemQuiz(page, 'breaks flight rules'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Uplink is Cold'); + }); + + test('[reference-and-lnb] observes the GPSDO and LNB', async () => { + for (const tab of ['gps-timing', 'rx-analysis']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference and LNB Check'); + }); + + test('[read-the-rack] reads the LNB gain and accounts for Tsys', async () => { + await missionControl.selectTab('rx-analysis'); + await answerSystemQuiz(page, 'Sky and atmosphere at 28 degrees'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Survey Inputs Off the Rack'); + }); + + test('[open-link-analysis] opens the worksheet', async () => { + await missionControl.selectTab('link-budget'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Open the Link Analysis Console'); + }); + + test('[compute-link-budget] predicts the downlink C/N', async () => { + await computeLinkBudget(page, missionControl, { eirpDbm: 28, fsplDb: 171.4, rxGainDbi: 51.8, noiseTempK: 88, bandwidthMHz: 36, miscLossDb: 1 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Predict the Downlink C/N'); + }); + + test('[path-loss-check] works the path loss at the mask', async () => { + await answerSystemQuiz(page, '180.9 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Check the Path Loss at the Mask'); + }); + + test('[review-pass-schedule] confirms culmination as the measurement point', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, '14:06:45, 28 degrees'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Measurement Point'); + }); + + test('[track-for-acceptance] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:03:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Track MERIDIAN-SAR-1', 60000); + }); + + test('[rising-leg] reads C/N on the way up', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:04:50Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Measure on the Rising Leg', 60000); + }); + + test('[commit-the-link] commits the link at culmination with margin', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:06:20Z'); + await page.waitForTimeout(2500); // receiver-signal-locked latches on RX analysis + await commitLinkWithMargin(page, missionControl, 9.5); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Measure and Commit the Link', 60000); + }); + + test('[setting-leg] reads C/N on the way down', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:08:40Z'); + await answerSystemQuiz(page, 'Same elevation, same slant range'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Measure on the Setting Leg', 60000); + }); + + test('[record-measurements] fills in the measurement section', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:10:30Z'); + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'margin 4.9 dB over 6 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Record the Measurements'); + }); + + test('[set-up-regression] retunes for SAR-2', async () => { + await closeWorkingDocumentIfOpen(page); + await setRxModemFrequency(page, missionControl, 1370); + await answerSystemQuiz(page, 'A second bird on a second geometry'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Regression Run'); + }); + + test('[sign-the-test-card] signs the card and completes without the optional regression', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'proves the station performs as designed'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete and the optional regression stayed open', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const regression = page.locator('.objective-item', { hasText: 'Regression: MERIDIAN-SAR-2' }); + await expect(regression).toHaveCount(1); + await expect(regression).not.toHaveClass(/completed/); + await expect(regression).toContainText('Optional'); + }); +}); diff --git a/e2e/specs/nats-eu-scenario20-full-completion.spec.ts b/e2e/specs/nats-eu-scenario20-full-completion.spec.ts new file mode 100644 index 00000000..d47ab511 --- /dev/null +++ b/e2e/specs/nats-eu-scenario20-full-completion.spec.ts @@ -0,0 +1,257 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + expectDashboardAlarm, + fillAndChange, + parkAntenna, + programTrack, + setRxModemFrequency, + setSwitch, +} from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 20 "False Time" - full completion (Gray Zone 4/8). + * + * A GNSS spoofer local to Galway from 05:08 to 05:36 walks the GNSS vs REF + * delta-T at 2 us/s while the constellation panel stays LOCKED with a full satellite count. + * Shetland reads +0.0. Call it (decision graded on timing-drifting AND + * gnss-constellation-healthy), GNSS switch down, fly SAR-1 in holdover, flag + * the skew entry and report, probe the sky after LOS (still walking - back + * down), probe again after 05:36 (still - stays up), SAR-2 on true time, + * debrief. Clock starts 2027-04-19 05:00:00Z: + * MERIDIAN-SAR-1 AOS 05:21:59Z max el 29.2 deg at 05:26:45Z LOS 05:31:28Z + * MERIDIAN-SAR-2 AOS 05:44:00Z max el 27.0 deg at 05:48:44Z LOS 05:53:29Z + * spoofer 05:08:00Z .. 05:36:00Z (GW-01 only) + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. timing-baseline + * 4. rx-chain-ready 5. preposition-for-aos 6. spot-the-walk + * 7. cross-check-sh02 8. call-the-lie 9. go-to-holdover + * 10. acquire-sar1 11. decode-on-holdover 12. report-the-attack + * 13. probe-the-sky 14. all-clear 15. acquire-sar2 + * 16. decode-sar2 17. close-and-debrief + */ +test.describe('nats-eu Scenario 20 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + /** GNSS vs REF delta-T as the GPS Timing tab currently shows it, in microseconds. */ + const readOffsetUs = async (): Promise => { + const text = (await page.locator('#gpsdo-time-offset').textContent()) ?? ''; + return parseFloat(text.replace(/[^\d.+-]/g, '')); + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario20'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the brief and the spoof signature', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'A healthy constellation - LOCKED, a full satellite count'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[timing-baseline] observes the GPSDO and reads a zero delta-T', async () => { + await missionControl.selectTab('gps-timing'); + await expect(page.locator('#gpsdo-lock-badge')).toHaveText('LOCKED', { timeout: 15000 }); + expect(Math.abs(await readOffsetUs())).toBeLessThan(0.5); + await page.waitForTimeout(3000); + await answerSystemQuiz(page, '+0.0 us - the GNSS timing solution'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Timing Baseline'); + }); + + test('[rx-chain-ready] retunes the receiver and frames a 40 MHz span on 1414', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1414'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 6 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 6, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[spot-the-walk] the delta-T walks past 20 us while the constellation stays healthy', async () => { + // Cross the spoofStartS=480 threshold; 2 us/s puts the read past 20 us within seconds. + await advanceMissionClockToUtc(page, '2027-04-19T05:08:30Z'); + await missionControl.selectTab('gps-timing'); + await expect.poll(readOffsetUs, { timeout: 30000 }).toBeGreaterThan(20); + await expect(page.locator('#gpsdo-gnss-badge')).toHaveText(/\d+ SATS/); + await expect(page.locator('#gpsdo-satellite-count')).not.toHaveText('0'); + await answerSystemQuiz(page, 'A full satellite count and LOCKED'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Spot the Walk'); + }); + + test('[cross-check-sh02] Shetland reads zero under the same sky', async () => { + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('gps-timing'); + await expect(page.locator('#gpsdo-gnss-badge')).toHaveText(/\d+ SATS/, { timeout: 15000 }); + expect(Math.abs(await readOffsetUs())).toBeLessThan(0.5); + await page.waitForTimeout(3000); + await answerSystemQuiz(page, '+0.0 us - same sky, no walk'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Cross-Check Shetland'); + }); + + test('[call-the-lie] reads the offset and the constellation on GW-01 and calls the spoof', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('gps-timing'); + await expect.poll(readOffsetUs, { timeout: 15000 }).toBeGreaterThan(20); + await page.waitForTimeout(3000); + await answerDecision(page, 'GNSS spoof - the constellation is healthy and the time is walking', ['Offset read on GPS Timing', 'Constellation read on GPS Timing']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Lie'); + }); + + test('[go-to-holdover] takes the GNSS switch down and the offset freezes', async () => { + await missionControl.selectTab('gps-timing'); + await setSwitch(page, '#gpsdo-gnss-switch', false); + await expect(page.locator('#gpsdo-holdover-badge').first()).toHaveText('ACTIVE', { timeout: 10000 }); + + // Frozen: sample twice across several seconds of the spoofer still being up. + const before = await readOffsetUs(); + await page.waitForTimeout(5000); + expect(Math.abs((await readOffsetUs()) - before)).toBeLessThan(0.5); + + await answerSystemQuiz(page, 'It froze the reference where the lie had walked it'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + // maintainDuration 20 ticks on real time + await waitForObjectiveComplete(missionControl, 'Stop Trusting GNSS', 60000); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-19T05:22:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[decode-on-holdover] locks SAR-1 with the reference free-running', async () => { + await advanceMissionClockToUtc(page, '2027-04-19T05:25:00Z'); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(4000); + await answerSystemQuiz(page, 'The modem needs a stable 10 MHz, not a true one'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode on the Oscillator', 60000); + }); + + test('[report-the-attack] flags the timestamp-skew entry and reports in progress', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-ts-skew"]'); + await answerSystemQuiz(page, 'Time of onset, the delta-T reading and its rate'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Report It'); + }); + + test('[probe-the-sky] GNSS back up after LOS: the offset resumes walking, switch back down', async () => { + await advanceMissionClockToUtc(page, '2027-04-19T05:32:00Z'); + await missionControl.selectTab('gps-timing'); + const frozen = await readOffsetUs(); + await setSwitch(page, '#gpsdo-gnss-switch', true); + await expect(page.locator('#gpsdo-lock-badge')).toHaveText('LOCKED', { timeout: 15000 }); + + // Spoofer still up: the walk resumes from where it froze. + await expect.poll(readOffsetUs, { timeout: 20000 }).toBeGreaterThan(frozen + 2); + await page.waitForTimeout(2000); + await answerDecision(page, 'Still walking - the spoofer is up', ['GNSS re-selected', 'Offset read on GPS Timing']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Probe the Sky'); + + await setSwitch(page, '#gpsdo-gnss-switch', false); + await expect(page.locator('#gpsdo-holdover-badge').first()).toHaveText('ACTIVE', { timeout: 10000 }); + }); + + test('[all-clear] second probe after 05:36: the offset holds still on GNSS, leave it up', async () => { + await advanceMissionClockToUtc(page, '2027-04-19T05:37:00Z'); + await missionControl.selectTab('gps-timing'); + const frozen = await readOffsetUs(); + await setSwitch(page, '#gpsdo-gnss-switch', true); + await expect(page.locator('#gpsdo-lock-badge')).toHaveText('LOCKED', { timeout: 15000 }); + await expect(page.locator('#gpsdo-gnss-badge')).toHaveText(/\d+ SATS/, { timeout: 15000 }); + + // Spoofer gone: still, with the receiver listening. + await page.waitForTimeout(5000); + expect(Math.abs((await readOffsetUs()) - frozen)).toBeLessThan(0.5); + + await answerSystemQuiz(page, 'The delta-T stopped moving with GNSS re-selected'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Ride It Out, Then Come Back', 60000); + }); + + test('[acquire-sar2] retunes and retargets for SAR-2', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-04-19T05:44:40Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[decode-sar2] locks SAR-2 on true time', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-04-19T05:47:00Z'); + await page.waitForTimeout(4000); + await answerSystemQuiz(page, 'Their timestamps are true again'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode SAR-2 on True Time', 60000); + }); + + test('[close-and-debrief] writes the debrief line and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'GNSS is an input, not the truth'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario21-full-completion.spec.ts b/e2e/specs/nats-eu-scenario21-full-completion.spec.ts new file mode 100644 index 00000000..86688b4e --- /dev/null +++ b/e2e/specs/nats-eu-scenario21-full-completion.spec.ts @@ -0,0 +1,240 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerPendingQuizFrom, + answerSystemQuiz, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + expectDashboardAlarm, + fillAndChange, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 21 "Knocking on the Door" - full completion (Gray Zone 5/8). + * + * The SAR-2 commanding pass. HK-DUMP ACKs at 09:25; at 09:27 the log shows + * the bird rejecting three replayed copies of it (Rotterdam's monitoring + * entry, then the KG-01 unit log). Every key badge stays Valid. Call it a + * replay against an intact key (decision graded on crypto-intact), rotate to + * Q2b out of cycle, PLD-STATUS ACKs under + * the new key, chain down, retain the retired key and the unit log (the second + * decision; the zeroize branch destroys the log S22 needs), file the report. + * Clock starts 2027-04-21 09:00:00Z: + * MERIDIAN-SAR-2 AOS 09:23:58Z max el 30.6 deg at 09:28:44Z LOS 09:33:27Z + * command window 09:24:18Z .. 09:33:07Z; knock in the log at 09:27:00Z + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. key-baseline + * 4. audit-baseline 5. arm-the-uplink 6. rx-chain-ready + * 7. preposition-for-aos 8. acquire-sar2 9. chain-up + * 10. first-command 11. spot-the-knock 12. call-the-knock + * 13. rotate-on-order 14. second-command 15. safe-the-uplink + * 16. retain-the-evidence 17. close-and-debrief + */ +test.describe('nats-eu Scenario 21 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario21'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the brief and what the counter does to a replay', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'The bird rejects it: every authenticated frame carries a counter'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[key-baseline] reads both keys Valid', async () => { + await missionControl.selectTab('tx-chain'); + await expect(page.locator('#tx-payload-enc-key-status').first()).toHaveText(/Valid/i, { timeout: 10000 }); + await page.waitForTimeout(3000); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(3000); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Key Baseline'); + }); + + test('[audit-baseline] signs off the 09:00 log', async () => { + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-mark-reviewed')).toBeVisible({ timeout: 10000 }); + await expect(page.locator('#sec-audit-body')).not.toContainText('KG-01 unit log'); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await answerSystemQuiz(page, 'KEY-2027-Q2b staged as an inactive spare'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Log Before the Pass'); + }); + + test('[arm-the-uplink] retunes the TX modem to 1435 MHz and engages Doppler comp', async () => { + await setTxModemFrequency(page, missionControl, 1435); + await enableDopplerComp(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm the Uplink'); + }); + + test('[rx-chain-ready] retunes the receiver and frames a 40 MHz span on 1370', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-2'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 8 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 8, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar2] program-tracks SAR-2 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-21T09:24:40Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[chain-up] brings the chain up in order inside the window', async () => { + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await page.waitForTimeout(3000); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up in Order'); + }); + + test('[first-command] sends HK-DUMP and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'HK-DUMP'); + await answerSystemQuiz(page, 'Anyone with a receiver under the uplink footprint'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Housekeeping Dump'); + }); + + test('[spot-the-knock] the counter-reject entry appears at 09:27 and is flagged', async () => { + await advanceMissionClockToUtc(page, '2027-04-21T09:27:20Z'); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('authentication counter stale', { timeout: 15000 }); + await domClick(page, 'button[data-event-id="evt-rotterdam-counter"]'); + await answerSystemQuiz(page, 'Someone sent the bird command frames that authenticated under the live key'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Spot the Knock'); + }); + + test('[call-the-knock] reads the key, flags the unit log, and calls a replay against an intact key', async () => { + await missionControl.selectTab('tx-chain'); + await expect(page.locator('#tx-payload-enc-key-status').first()).toHaveText(/Valid/i); + await page.waitForTimeout(3000); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('KG-01 unit log', { timeout: 15000 }); + await domClick(page, 'button[data-event-id="evt-kg-replay-log"]'); + await page.waitForTimeout(3000); + await answerDecision(page, 'A replay against an intact key', ['Command key read on TX Chain', 'KG-01 unit log flagged on the Security console']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Knock'); + }); + + test('[rotate-on-order] rotates the command key to Q2b on the TT&C console', async () => { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-begin-rotation')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#cmd-begin-rotation'); + await expect(page.locator('#cmd-key-badge')).toHaveText(/Pending/i, { timeout: 5000 }); + await domClick(page, '#cmd-complete-rotation'); + await expect(page.locator('#cmd-key-badge')).toHaveText(/Valid/i, { timeout: 5000 }); + await answerSystemQuiz(page, 'Because they hold recorded traffic under it'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Rotate Out of Cycle'); + }); + + test('[second-command] sends PLD-STATUS under the new key and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'PLD-STATUS'); + await answerSystemQuiz(page, 'They fail twice over'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Payload Status Under the New Key'); + }); + + test('[safe-the-uplink] chains down in mirror order after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-04-21T09:33:40Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Safe the Uplink'); + }); + + test('[retain-the-evidence] re-reads the unit log and keeps the key and the log under seal', async () => { + await missionControl.selectTab('security-console'); + // Still in the log: the earlier call did not destroy it. + await expect(page.locator('#sec-audit-body')).toContainText('KG-01 unit log'); + await page.waitForTimeout(3000); + await answerDecision(page, 'Retain both under seal for CSIRT', ['KG-01 unit log re-read on the Security console']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retain or Zeroize'); + }); + + test('[close-and-debrief] files the incident, writes the debrief line and completes', async () => { + // Two status-checks in one objective: the quiz manager picks the order. + const answers = [ + { questionHint: 'COMSEC incident report', answerText: '09:25 HK-DUMP ACK under Q2' }, + { questionHint: 'debrief line', answerText: 'Every command frame on the air is a frame they can keep' }, + ]; + await answerPendingQuizFrom(page, answers); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await answerPendingQuizFrom(page, answers); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario22-full-completion.spec.ts b/e2e/specs/nats-eu-scenario22-full-completion.spec.ts new file mode 100644 index 00000000..2e28f0f7 --- /dev/null +++ b/e2e/specs/nats-eu-scenario22-full-completion.spec.ts @@ -0,0 +1,215 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, parkAntenna, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 22 "Connecting the Dots" - full completion (Gray Zone 6/8). + * + * The attribution report. Open the Campaign Record (S17-S21's Working + * Documents, as filed in a fresh browser), read the carried-forward audit + * chain, flag the 13 April export as the origin, build the timeline, fly a + * routine SAR-1 pass in the middle of it, make the attribution call (graded on + * evidence-chain-intact: with no S21 record in this browser nothing was + * destroyed, so the KG-01 line is present and "one actor" is right), write + * the referral, continuity, board, notifications, header and debrief lines. + * Clock starts 2027-04-22 14:00:00Z: + * MERIDIAN-SAR-1 AOS 14:30:00Z max el 23.8 deg at 14:34:49Z LOS 14:39:34Z + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. open-the-record + * 4. read-the-chain 5. flag-the-origin 6. build-the-timeline + * 7. rx-chain-ready 8. preposition-for-aos 9. acquire-sar1 + * 10. decode-sar1 11. attribute-the-actor 12. the-referral + * 13. continuity-plan 14. board-summary 15. notifications + * 16. close-the-record 17. close-and-debrief + */ +test.describe('nats-eu Scenario 22 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const closeCampaignRecordIfOpen = async (): Promise => { + const box = page.locator('#draggable-html-box-campaign-record'); + if (await box.isVisible({ timeout: 1000 }).catch(() => false)) { + const closeBtn = box.locator('.draggable-box__close-btn, [id$="-close"]').first(); + if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) { + await closeBtn.click(); + } + } + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario22'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the brief and the scope of an attribution report', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'What was observed, when, on which input'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[open-the-record] opens the Campaign Record and finds S17-S21 filed', async () => { + const icon = page.locator('.campaign-record-icon'); + await expect(icon).toBeVisible({ timeout: 10000 }); + await domClick(page, '.campaign-record-icon'); + const box = page.locator('#draggable-html-box-campaign-record'); + await expect(box).toBeVisible({ timeout: 10000 }); + await expect(box).toContainText('S21 Knocking on the Door'); + await expect(box).toContainText('S17 Unusual Activity'); + await answerSystemQuiz(page, 'Five: a downlink carrier and a login spray'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Open the Campaign Record'); + await closeCampaignRecordIfOpen(); + }); + + test('[read-the-chain] signs off the carried-forward log and names the common thread', async () => { + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('KG-01 unit log (sealed 21 Apr)', { timeout: 10000 }); + await expect(page.locator('#sec-audit-body')).toContainText('full station configuration exported'); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await answerSystemQuiz(page, 'Each used something in it'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Chain'); + }); + + test('[flag-the-origin] flags the 13 April export', async () => { + await domClick(page, 'button[data-event-id="evt-s17-export"]'); + await answerSystemQuiz(page, '13 Apr 01:58 password spray on svc-legacy'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Flag the Origin'); + }); + + test('[build-the-timeline] flags the denial and the skew and writes the rest', async () => { + await domClick(page, 'button[data-event-id="evt-s19-denial"]'); + await domClick(page, 'button[data-event-id="evt-s20-skew"]'); + await answerSystemQuiz(page, '14 Apr terrestrial interference 11726/11690 MHz'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Build the Timeline'); + }); + + test('[rx-chain-ready] retunes the receiver and frames a 40 MHz span on 1414', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1414'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 25 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 25, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-22T14:30:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[decode-sar1] locks SAR-1 and answers why the pass is flown', async () => { + await advanceMissionClockToUtc(page, '2027-04-22T14:33:30Z'); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(4000); + await answerSystemQuiz(page, 'Because the report is about a station that is still operating'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Operations Continue', 60000); + }); + + test('[attribute-the-actor] re-reads the chain and calls one actor with the KG-01 line present', async () => { + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('KG-01 unit log (sealed 21 Apr)'); + await page.waitForTimeout(3000); + await answerDecision(page, 'One actor, one method', ['Campaign record open', 'Audit chain read on the Security console']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Attribute the Pattern'); + }); + + test('[the-referral] writes the geolocation referral', async () => { + await answerSystemQuiz(page, 'A geolocation team with two or more receivers'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Refer the Location'); + }); + + test('[continuity-plan] writes the recommendations', async () => { + await answerSystemQuiz(page, 'Decommissioned accounts disabled on the day'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Continuity of Operations'); + }); + + test('[board-summary] writes the board paragraph', async () => { + await answerSystemQuiz(page, 'Five deliberate events over ten days'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Brief the Board'); + }); + + test('[notifications] sets who is told what', async () => { + await answerSystemQuiz(page, 'ComReg: IR-2027-0414 extended'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Notify'); + }); + + test('[close-the-record] writes the header', async () => { + await answerSystemQuiz(page, 'IAR-2027-0422-GW01: five events 13-21 Apr'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Close the Record'); + }); + + test('[close-and-debrief] writes the debrief line and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Five records closed on their own days were one incident'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario23-full-completion.spec.ts b/e2e/specs/nats-eu-scenario23-full-completion.spec.ts new file mode 100644 index 00000000..d15067ae --- /dev/null +++ b/e2e/specs/nats-eu-scenario23-full-completion.spec.ts @@ -0,0 +1,236 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + expectDashboardAlarm, + fillAndChange, + loadEphemeris, + parkAntenna, + programTrack, + sendCommandAndExpectAck, + sendCommandAndExpectNak, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 23 "Dark Passes" - full completion (Gray Zone 7/8). + * + * One command that must go up (PLD-SAFE on the 21:22 SAR-2 pass) and, at + * 21:14, everything at once: the GNSS spoof returns, SAR-2 burns and the + * element set goes stale, and the jammer is expected in the window. Triage + * (decision graded on the clock: ephemeris first while the carrier is still + * clear), load the post-burn set, holdover, acquire, chain up, HK-DUMP ACKs, + * PLD-SAFE NAKs at 21:24:30, call the denial with the reference in holdover, + * hop, PLD-SAFE ACKs, safe down, debrief. Clock starts 2027-04-24 21:00:00Z: + * MERIDIAN-SAR-2 AOS 21:22:01Z max el 32.8 deg at 21:26:50Z LOS 21:31:35Z + * surge 21:14:00Z (spoof + burn); jammer 21:24:30Z .. 21:31:30Z + * + * Objective flow (17): + * 1. review-mission-brief 2. dashboard-sweep 3. arm-the-uplink + * 4. rx-chain-ready 5. stage-the-hop-set 6. read-the-surge + * 7. triage 8. load-the-ephemeris 9. go-to-holdover + * 10. preposition-for-aos 11. acquire-sar2 12. chain-up + * 13. first-command 14. call-the-denial 15. go-to-hopping + * 16. ride-through 17. safe-and-debrief + */ +test.describe('nats-eu Scenario 23 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const readOffsetUs = async (): Promise => { + const text = (await page.locator('#gpsdo-time-offset').textContent()) ?? ''; + return parseFloat(text.replace(/[^\d.+-]/g, '')); + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario23'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the brief and the one tasking', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'PLD-SAFE up on SAR-2 inside'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Brief'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[arm-the-uplink] retunes the TX modem to 1435 MHz and engages Doppler comp', async () => { + await setTxModemFrequency(page, missionControl, 1435); + await enableDopplerComp(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm the Uplink'); + }); + + test('[rx-chain-ready] retunes the receiver and frames a 40 MHz span on 1370', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1370'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-2'); + }); + + test('[stage-the-hop-set] reads the TRANSEC card and leaves the key in the store', async () => { + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-transec-key-badge')).toHaveText('NOT LOADED', { timeout: 10000 }); + await answerSystemQuiz(page, 'A hop set is spent the moment it is used on the air'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Stage the Hop Set'); + }); + + test('[read-the-surge] the delta-T walks and the element set goes stale at 21:14', async () => { + await advanceMissionClockToUtc(page, '2027-04-24T21:14:30Z'); + await missionControl.selectTab('gps-timing'); + await expect.poll(readOffsetUs, { timeout: 30000 }).toBeGreaterThan(20); + await page.waitForTimeout(3000); + await missionControl.selectTab('pass-schedule'); + await expect(page.locator('#ephemeris-panel')).toContainText('EPHEMERIS STALE', { timeout: 15000 }); + await answerSystemQuiz(page, 'A GNSS spoof (delta-T walking, constellation healthy) and a stale element set'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Surge'); + }); + + test('[triage] reads both panels and puts the ephemeris first', async () => { + await missionControl.selectTab('gps-timing'); + await page.waitForTimeout(3000); + await missionControl.selectTab('pass-schedule'); + await page.waitForTimeout(3000); + await answerDecision(page, 'Ephemeris first - without it there is no pass to point at', ['Delta-T read on GPS Timing', 'Pass Schedule read (SAR-2 EPHEMERIS STALE)']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Triage'); + }); + + test('[load-the-ephemeris] loads the post-burn elements', async () => { + await loadEphemeris(page, missionControl, 'SAR2-DAM'); + await answerSystemQuiz(page, 'A tracker pointing where the bird would have been'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Load the Post-Burn Elements'); + }); + + test('[go-to-holdover] takes the GNSS switch down', async () => { + await missionControl.selectTab('gps-timing'); + await setSwitch(page, '#gpsdo-gnss-switch', false); + await expect(page.locator('#gpsdo-holdover-badge').first()).toHaveText('ACTIVE', { timeout: 10000 }); + await answerSystemQuiz(page, 'Because the walk costs timestamps, not the pass'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Stop Trusting GNSS', 60000); + }); + + test('[preposition-for-aos] parks the tracker on the rise azimuth', async () => { + // Parked az 5 / el 3; the rise is az 22 / el 5 (tolerance 3 deg). + await parkAntenna(page, missionControl, { az: 5, el: 3 }, { az: 22, el: 5 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar2] program-tracks SAR-2 on the post-burn set and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-04-24T21:22:40Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[chain-up] brings the chain up in order inside the window', async () => { + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await page.waitForTimeout(3000); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up in Order'); + }); + + test('[first-command] sends HK-DUMP on the fixed carrier in holdover and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'HK-DUMP'); + await answerSystemQuiz(page, 'That a free-running OCXO is a perfectly good 10 MHz'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Housekeeping Dump'); + }); + + test('[call-the-denial] PLD-SAFE NAKs into the jammer; reads the console and the reference; calls the denial', async () => { + await advanceMissionClockToUtc(page, '2027-04-24T21:24:45Z'); + await sendCommandAndExpectNak(page, missionControl, 'PLD-SAFE', 'Uplink denied - carrier jammed'); + await page.waitForTimeout(3000); // TT&C console on screen so the read latches + await missionControl.selectTab('gps-timing'); + await expect(page.locator('#gpsdo-holdover-badge').first()).toHaveText('ACTIVE'); + await page.waitForTimeout(3000); + await answerDecision(page, 'Uplink denial on the fixed carrier - the jammer is back', ['TT&C console read', 'Reference read on GPS Timing (holdover)']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Denial'); + }); + + test('[go-to-hopping] loads HOP-SAR2-05, switches the waveform, and gets sync', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, '#sec-transec-load-key'); + await expect(page.locator('#sec-transec-key-badge')).toHaveText('LOADED', { timeout: 10000 }); + await page.locator('#sec-transec-mode').selectOption({ value: 'hopping' }); + await expect(page.locator('#sec-transec-sync-badge')).toHaveText('SYNC LOCKED', { timeout: 10000 }); + await answerSystemQuiz(page, 'That the fixed carrier on 14035 is known and will be denied again'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Go to Hopping'); + }); + + test('[ride-through] resends PLD-SAFE under TRANSEC and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'PLD-SAFE'); + await answerSystemQuiz(page, 'None of them as they were'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Keep the Tasking Alive'); + }); + + test('[safe-and-debrief] chains down after LOS, writes the debrief line and completes', async () => { + await advanceMissionClockToUtc(page, '2027-04-24T21:31:50Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await answerSystemQuiz(page, 'Three inputs lost at once and one command up'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario24-full-completion.spec.ts b/e2e/specs/nats-eu-scenario24-full-completion.spec.ts new file mode 100644 index 00000000..eacf3acb --- /dev/null +++ b/e2e/specs/nats-eu-scenario24-full-completion.spec.ts @@ -0,0 +1,331 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerPendingQuizFrom, + answerSystemQuiz, + assignContact, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + expectDashboardAlarm, + fillAndChange, + loadEphemeris, + programTrack, + sendCommandAndExpectAck, + sendCommandAndExpectNak, + setRxModemFrequency, + setSwitch, + setTxModemFrequency, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 24 "North Atlantic Storm" - full completion (campaign capstone). + * + * Incident commander for the network: plan three P1 contacts across two + * sites, sweep Galway in the rain band, ready both stations, collect SAR-1 + * from Galway in the lull under the terrestrial carrier (notch), load SAR-2's + * post-burn set, spot the spoof on Shetland's reference with Galway as the + * cross-check, holdover at Shetland, collect SAR-2 from Shetland, heater on + * at Galway before the sleet, arm for SAR-3, acquire it, chain up, HK-DUMP + * ACKs, PLD-SAFE NAKs into the jammer, call it, hop, PLD-SAFE ACKs, safe the + * network, log and hand over. Clock starts 2027-04-27 03:00:00Z: + * SAR-1 @ GW-01 AOS 03:19:58Z max el 30.2 deg LOS 03:29:28Z carrier 03:21:30-03:28:30 + * SAR-2 @ SH-02 AOS 03:31:01Z max el 35.1 deg LOS 03:41:01Z burn 03:15; spoof on SH-02 from 03:25 + * SAR-3 @ GW-01 AOS 03:52:00Z max el 28.4 deg LOS 04:01:35Z window 03:52:21-04:01:15; jammer 03:55-04:01:40 + * rain band 03:01-03:16; sleet 03:40-04:10 + * + * Objective flow (20): + * 1. take-command 2. plan-the-network 3. galway-dashboard-sweep + * 4. galway-rx-ready 5. shetland-baseline 6. acquire-sar1-in-the-lull + * 7. notch-the-carrier 8. load-the-ephemeris 9. shetland-spot-the-walk + * 10. shetland-holdover 11. acquire-sar2-from-shetland + * 12. decode-the-collect 13. protect-the-feed 14. arm-galway-for-sar3 + * 15. acquire-sar3 16. chain-up-and-command 17. call-the-denial + * 18. hop-and-deliver 19. safe-the-network 20. incident-log-and-handover + */ +test.describe('nats-eu Scenario 24 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const readOffsetUs = async (): Promise => { + const text = (await page.locator('#gpsdo-time-offset').textContent()) ?? ''; + return parseFloat(text.replace(/[^\d.+-]/g, '')); + }; + + /** Configure one IF notch on the RX Analysis tab (the C1 S21 helper, verbatim). */ + async function configureNotchFilter(config: { centerFrequency: number; bandwidth: number; depth: number; notchIndex: number }): Promise { + const prefix = `notch-${config.notchIndex}`; + + const powerSwitch = page.locator('#notch-power'); + await expect(powerSwitch).toBeVisible({ timeout: 5000 }); + if (!(await powerSwitch.isChecked())) { + await powerSwitch.click(); + await expect(powerSwitch).toBeChecked(); + await page.waitForTimeout(300); + } + + const enableSwitch = page.locator(`#${prefix}-enabled`); + await expect(enableSwitch).toBeVisible(); + if (!(await enableSwitch.isChecked())) { + await enableSwitch.click(); + await expect(enableSwitch).toBeChecked(); + await page.waitForTimeout(200); + } + + for (const [suffix, value] of [ + ['freq', config.centerFrequency], + ['bw', config.bandwidth], + ['depth', config.depth], + ] as const) { + const input = page.locator(`#${prefix}-${suffix}`); + await expect(input).toBeVisible(); + await input.fill(value.toString()); + await input.press('Tab'); + await page.waitForTimeout(100); + } + + const applyBtn = page.locator('#notch-apply-btn'); + if (await applyBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await applyBtn.click(); + await page.waitForTimeout(300); + } + await page.waitForTimeout(500); + } + + async function enableFeedHeater(): Promise { + await missionControl.selectTab('acu-control'); + const heater = page.locator('input[id$="heater-switch"]:visible').first(); + await expect(heater).toBeVisible({ timeout: 10000 }); + if (!(await heater.isChecked())) { + await heater.evaluate((el) => (el as HTMLElement).click()); + } + await expect(heater).toBeChecked(); + } + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario24'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[take-command] reads the brief and puts the plan first', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'The plan: which bird from which site'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Take Command'); + }); + + test('[plan-the-network] assigns the three P1 contacts and validates the plan', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('contact-schedule'); + await assignContact(page, 'C-SAR1-GW', 'GW-01'); + await assignContact(page, 'C-SAR2-SH', 'SH-02'); + await assignContact(page, 'C-SAR3-GW', 'GW-01'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await answerSystemQuiz(page, 'Because Galway has SAR-1 setting at 03:29'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Plan the Network'); + }); + + test('[galway-dashboard-sweep] reads the board in the rain: AGC rail, no faults', async () => { + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Storm Sweep'); + }); + + test('[galway-rx-ready] frames Galway on 1414 for SAR-1', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1414'); + await fillAndChange(page, '#sa-span', '40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Galway Ready for SAR-1'); + }); + + test('[shetland-baseline] reads the Shetland reference and retunes it for SAR-2', async () => { + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('gps-timing'); + await expect(page.locator('#gpsdo-lock-badge')).toHaveText('LOCKED', { timeout: 15000 }); + expect(Math.abs(await readOffsetUs())).toBeLessThan(0.5); + await page.waitForTimeout(3000); + await setRxModemFrequency(page, missionControl, 1370); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Shetland Baseline'); + }); + + test('[acquire-sar1-in-the-lull] program-tracks SAR-1 from Galway after the rain band', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:20:40Z'); + await missionControl.selectGroundStation('GW-01'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-1 in the Lull', 60000); + }); + + test('[notch-the-carrier] notches 1410 MHz and holds C/N above 7 dB', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:21:45Z'); + await missionControl.selectTab('rx-analysis'); + await configureNotchFilter({ centerFrequency: 1410, bandwidth: 2, depth: 30, notchIndex: 1 }); + await advanceMissionClockToUtc(page, '2027-04-27T03:23:30Z'); + await page.waitForTimeout(4000); + await answerSystemQuiz(page, 'Nothing in the handling - notch it, keep the collect'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Notch the Carrier', 60000); + }); + + test('[load-the-ephemeris] loads SAR-2 post-burn elements before the Shetland pass', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:29:40Z'); + await loadEphemeris(page, missionControl, 'SAR2-CAM2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Load the Post-Burn Elements'); + }); + + test('[shetland-spot-the-walk] Shetland walking, Galway clean', async () => { + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('gps-timing'); + await expect.poll(readOffsetUs, { timeout: 30000 }).toBeGreaterThan(20); + await expect(page.locator('#gpsdo-gnss-badge')).toHaveText(/\d+ SATS/); + await page.waitForTimeout(3000); + await answerSystemQuiz(page, 'A GNSS spoof, local to Shetland'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Shetland: Spot the Walk'); + }); + + test('[shetland-holdover] takes the Shetland GNSS switch down', async () => { + await missionControl.selectTab('gps-timing'); + await setSwitch(page, '#gpsdo-gnss-switch', false); + await expect(page.locator('#gpsdo-holdover-badge').first()).toHaveText('ACTIVE', { timeout: 10000 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Shetland: Stop Trusting GNSS', 60000); + }); + + test('[acquire-sar2-from-shetland] program-tracks SAR-2 from Shetland on the post-burn set', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:31:40Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-2 from Shetland', 60000); + }); + + test('[decode-the-collect] locks SAR-2 at Shetland and logs the first half hour', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:34:30Z'); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(4000); + await answerSystemQuiz(page, '03:01-03:16 rain band GW-01'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Decode the Collect', 60000); + }); + + test('[protect-the-feed] heater on at Galway before the sleet', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:36:30Z'); + await missionControl.selectGroundStation('GW-01'); + await enableFeedHeater(); + await answerSystemQuiz(page, 'Rain does not stick and a heated feed in rain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Protect the Feed'); + }); + + test('[arm-galway-for-sar3] retunes TX to 1465, RX to 1340, Doppler on', async () => { + await setTxModemFrequency(page, missionControl, 1465); + await enableDopplerComp(page, missionControl); + await setRxModemFrequency(page, missionControl, 1340); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm Galway for SAR-3'); + }); + + test('[acquire-sar3] program-tracks SAR-3 through the sleet', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:52:40Z'); + await programTrack(page, missionControl, '61703'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-3 Through the Sleet', 60000); + }); + + test('[chain-up-and-command] chains up and gets the HK-DUMP ACK', async () => { + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await page.waitForTimeout(3000); + await sendCommandAndExpectAck(page, missionControl, 'HK-DUMP'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up and Command'); + }); + + test('[call-the-denial] PLD-SAFE NAKs into the jammer; reads console and key; calls the denial', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T03:55:20Z'); + await sendCommandAndExpectNak(page, missionControl, 'PLD-SAFE', 'Uplink denied - carrier jammed'); + await page.waitForTimeout(3000); + await missionControl.selectTab('tx-chain'); + await expect(page.locator('#tx-payload-enc-key-status').first()).toHaveText(/Valid/i); + await page.waitForTimeout(3000); + await answerDecision(page, 'Uplink denial on the fixed carrier - key HOP-SAR3-01', ['TT&C console read', 'Command key read on TX Chain']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Denial'); + }); + + test('[hop-and-deliver] loads HOP-SAR3-01, hops, and puts PLD-SAFE through', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, '#sec-transec-load-key'); + await expect(page.locator('#sec-transec-key-badge')).toHaveText('LOADED', { timeout: 10000 }); + await page.locator('#sec-transec-mode').selectOption({ value: 'hopping' }); + await expect(page.locator('#sec-transec-sync-badge')).toHaveText('SYNC LOCKED', { timeout: 10000 }); + await sendCommandAndExpectAck(page, missionControl, 'PLD-SAFE'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Hop and Deliver'); + }); + + test('[safe-the-network] chains Galway down after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-04-27T04:01:50Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Safe the Network'); + }); + + test('[incident-log-and-handover] logs the second half hour, hands over, and completes', async () => { + const answers = [ + { questionHint: 'second half hour', answerText: '03:36 GW-01 feed heater on' }, + { questionHint: 'handover line', answerText: 'Open: SH-02 reference in holdover' }, + ]; + await answerPendingQuizFrom(page, answers); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await answerPendingQuizFrom(page, answers); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible(); + await expect(levelCompleteModal).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario25-full-completion.spec.ts b/e2e/specs/nats-eu-scenario25-full-completion.spec.ts new file mode 100644 index 00000000..4209e36e --- /dev/null +++ b/e2e/specs/nats-eu-scenario25-full-completion.spec.ts @@ -0,0 +1,101 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerPendingQuizFrom, answerSystemQuiz, closeWorkingDocumentIfOpen } from '../utils/nats-eu-helpers'; +import { domClick } from '../utils/signal-hunter-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 25 "Site Orientation" (Drill 1/4, phase 18 G) - full + * completion. Ten status-check stops, two of them anchored to a console tab. + */ +test.describe('nats-eu Scenario 25 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const stop = async (answer: string, title: string): Promise => { + await answerSystemQuiz(page, answer); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, title); + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario25'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] opens the orientation sheet', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Orientation Sheet'); + }); + + test('[stops 1-6] the threat model', async () => { + await stop('Ground segment: GW-01 and Rotterdam constellation ops', 'Stop 1 - The Segments'); + await stop('Launch and early orbit', 'Stop 2 - Across the Lifetime'); + await stop('Rotation: the operator proposes', 'Stop 3 - Who Decides'); + await stop('The ground is reachable', 'Stop 4 - The Soft Target'); + await stop('Carrier: degrade. Spoofed time: deceive', 'Stop 5 - The Effects'); + await stop('One SAR-2 collect over the North Atlantic not delivered', 'Stop 6 - What Is Lost'); + }); + + test('[stop 7] the M&C path, with the Security console open', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await stop('Workstation (M&C client) -> M&C network segment', 'Stop 7 - The Monitor-and-Control Path'); + }); + + test('[stop 8] not ordinary IT, two questions in either order', async () => { + const answers = [ + { questionHint: 'Stop 8a', answerText: 'Availability first' }, + { questionHint: 'Stop 8b', answerText: 'Between M&C and the corporate network' }, + ]; + const first = await answerPendingQuizFrom(page, answers); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + const second = await answerPendingQuizFrom(page, answers); + expect(new Set([first, second]).size).toBe(2); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Stop 8 - Not Ordinary IT'); + }); + + test('[stop 9] the antenna pattern, with RX Analysis open', async () => { + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(500); + await stop('Through a sidelobe', 'Stop 9 - The Pattern'); + }); + + test('[stop 10] outside the fence, and the drill completes', async () => { + await answerSystemQuiz(page, 'The contact schedule (when the dish will be pointed where)'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal')).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario26-full-completion.spec.ts b/e2e/specs/nats-eu-scenario26-full-completion.spec.ts new file mode 100644 index 00000000..4a93cd5c --- /dev/null +++ b/e2e/specs/nats-eu-scenario26-full-completion.spec.ts @@ -0,0 +1,120 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { domClick } from '../utils/signal-hunter-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 26 "Baseline Check" (Drill 2/4, phase 18 G) - full + * completion. RX modem 1 is staged at 1410 against a 1414 baseline; the log + * carries the vendor change. The drift is read on RX Analysis, the log + * reviewed, the call graded on config-drifted BEFORE anything is flagged, + * the baseline restored, the entries flagged, the account held, the access + * request confirmed, the sweep logged. + */ +test.describe('nats-eu Scenario 26 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario26'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] opens the sweep sheet', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Sweep Sheet'); + }); + + test('[the-baseline] says what the baseline is for', async () => { + await answerSystemQuiz(page, 'It is the known-good state'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'What the Baseline Is For'); + }); + + test('[check-against-baseline] reads 1410 on the modem, reviews the log, names the drift', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('rx-analysis'); + await expect(page.locator('#frequency-input')).toHaveValue('1410', { timeout: 10000 }); + await page.waitForTimeout(3000); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('1414 -> 1410 MHz', { timeout: 10000 }); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await answerSystemQuiz(page, 'RX modem 1 is at 1410 MHz; the baseline says 1414'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Check the Receiver'); + }); + + test('[call-the-drift] calls unauthorised drift with the modem read as evidence', async () => { + await missionControl.selectTab('rx-analysis'); + await expect(page.locator('#frequency-input')).toHaveValue('1410'); + await page.waitForTimeout(3500); + await answerDecision(page, 'Drift, unauthorised', ['RX modem 1 reading 1410 MHz on the console']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Drift'); + }); + + test('[restore-the-baseline] sets RX modem 1 back to 1414', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await expect(page.locator('#frequency-input')).toHaveValue('1414'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Restore the Baseline'); + }); + + test('[flag-the-change] flags the vendor login and the change', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-vendor-login"]'); + await domClick(page, 'button[data-event-id="evt-cfg-freq"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Flag the Entries'); + }); + + test('[hold-the-account] disables the vendor account', async () => { + const select = page.locator('select.sec-account-select[data-account-id="svc-vendor"]'); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption('disabled'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Hold the Vendor Account'); + }); + + test('[confirm-access] confirms the access request', async () => { + await answerSystemQuiz(page, 'That the request (SAR-2027-0429-011)'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Access Request'); + }); + + test('[log-the-sweep] logs the sweep and completes', async () => { + await answerSystemQuiz(page, '05:30 sweep: RX modem 1 found at 1410'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal')).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario27-full-completion.spec.ts b/e2e/specs/nats-eu-scenario27-full-completion.spec.ts new file mode 100644 index 00000000..432d5f16 --- /dev/null +++ b/e2e/specs/nats-eu-scenario27-full-completion.spec.ts @@ -0,0 +1,108 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen } from '../utils/nats-eu-helpers'; +import { domClick } from '../utils/signal-hunter-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 27 "Removable Media" (Drill 3/4, phase 18 G) - full + * completion. Log reviewed, off-plan entries named, the media entry flagged + * as evidence, the call graded on config-drifted (the firmware stage still + * unflagged), verification path stated, firmware entry flagged, technician + * account suspended, transfer rule and OT reasoning stated, event logged. + */ +test.describe('nats-eu Scenario 27 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const quiz = async (answer: string, title: string): Promise => { + await answerSystemQuiz(page, answer); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, title); + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario27'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] opens the service plan', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Service Plan'); + }); + + test('[review-the-log] reviews the log and names the two off-plan entries', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('USB mass-storage device mounted', { timeout: 10000 }); + await expect(page.locator('#sec-audit-body')).toContainText('acu-4.2.1-vendor.bin'); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await quiz('07:41 USB mass storage mounted', 'Find What Is Off the Plan'); + }); + + test('[call-the-media] flags the media entry, then holds the image', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-usb"]'); + await page.waitForTimeout(3500); + await answerDecision(page, 'No. Hold the staged image', ['USB mount flagged']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call It'); + }); + + test('[verify-the-image] states the verification path', async () => { + await quiz('A release the vendor publishes with a signature and a hash', 'How the Image Earns Trust'); + }); + + test('[flag-the-firmware] flags the firmware stage', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-fw-stage"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Flag the Firmware Stage'); + }); + + test('[hold-the-technician] suspends the technician account', async () => { + const select = page.locator('select.sec-account-select[data-account-id="vend-tech"]'); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption('disabled'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Suspend the Technician'); + }); + + test('[the-transfer-rule, why-not-a-laptop] states the rule and the OT reasoning', async () => { + await quiz('Through the transfer station', 'The Right Way In'); + await quiz('Because the workstation is on the segment that controls the antenna', 'Why Not Just Scan It'); + }); + + test('[log-the-event] logs the event and completes', async () => { + await answerSystemQuiz(page, '07:41 USB mounted on GW-MC-01 by vend-tech'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal')).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario28-full-completion.spec.ts b/e2e/specs/nats-eu-scenario28-full-completion.spec.ts new file mode 100644 index 00000000..47d7019b --- /dev/null +++ b/e2e/specs/nats-eu-scenario28-full-completion.spec.ts @@ -0,0 +1,109 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen } from '../utils/nats-eu-helpers'; +import { domClick } from '../utils/signal-hunter-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 28 "Vendor Session" (Drill 4/4, phase 18 G) - full + * completion. Flow baseline stated, log reviewed, the session entry flagged + * as evidence, the call graded on audit-anomaly-present (two anomalies still + * unflagged), the vendor account disabled, the flow and burst flagged, the + * boundary and the reach stated, the event logged. + */ +test.describe('nats-eu Scenario 28 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + const quiz = async (answer: string, title: string): Promise => { + await answerSystemQuiz(page, answer); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, title); + }; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario28'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] opens the flow baseline', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Flow Baseline'); + }); + + test('[read-the-baseline] states what normal looks like', async () => { + await quiz('Workstation and monitoring service to the ACU', 'What Normal Looks Like'); + }); + + test('[review-the-log] reads the log against the baseline', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-audit-body')).toContainText('185.199.74.20', { timeout: 10000 }); + await expect(page.locator('#sec-audit-body')).toContainText('ACU parameter read burst'); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Log Against the Baseline'); + }); + + test('[call-the-session] flags the session entry, then cuts it', async () => { + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-vpn"]'); + await page.waitForTimeout(3500); + await answerDecision(page, 'Cutting it. Disable the vendor account now', ['Remote session flagged']); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Call the Session'); + }); + + test('[cut-the-session] disables the vendor account', async () => { + const select = page.locator('select.sec-account-select[data-account-id="svc-vendor"]'); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption('disabled'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Disable the Vendor Account'); + }); + + test('[flag-the-flows] flags the flow and the burst', async () => { + await domClick(page, 'button[data-event-id="evt-flow"]'); + await domClick(page, 'button[data-event-id="evt-acu-burst"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Flag the Flow and the Burst'); + }); + + test('[the-boundary, what-it-could-reach] states the boundary and the reach', async () => { + await quiz('At the site edge', 'The Boundary It Crossed'); + await quiz('Everything on the monitor-and-control path', 'What It Could Reach'); + }); + + test('[log-the-event] logs, notifies, and completes', async () => { + await answerSystemQuiz(page, '21:12 vendor remote session (svc-vendor'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal')).toContainText(/Mission Complete|Scenario Complete/i); + }); +}); diff --git a/e2e/specs/nats-eu-scenario3-full-completion.spec.ts b/e2e/specs/nats-eu-scenario3-full-completion.spec.ts new file mode 100644 index 00000000..563d71c8 --- /dev/null +++ b/e2e/specs/nats-eu-scenario3-full-completion.spec.ts @@ -0,0 +1,194 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + programTrack, + sendCommandAndExpectAck, + setSwitch, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 3 "Two-Way Street" - full completion (phase 16). + * + * First uplink: reference and BUC lock, transmit modem retuned to the 1405 MHz + * command IF, window confirmed, Doppler compensation engaged, transmit chain up + * in three ordered steps, acquisition, the command window, the ACK, then the + * chain down in the mirror order and the log. Sim clock starts + * 2027-03-15 13:45:00Z; MERIDIAN-SAR-1 AOS 14:03:10Z, window 14:03:40Z to + * 14:10:00Z, LOS 14:10:18Z. + * + * The chain is brought up BEFORE the pass (drive present at every step), so + * the HPA_NOISE_AMPLIFICATION invariant is never at risk. + * + * Objective flow (16): + * 1. review-mission-brief 2. reference-check 3. tune-tx-modem + * 4. review-pass-schedule 5. enable-doppler-comp 6. carrier-into-muted-buc + * 7. unmute-buc 8. enable-hpa 9. track-for-commanding + * 10. confirm-window-open 11. send-the-command 12. confirm-execution + * 13. disable-hpa 14. mute-buc 15. carrier-down + * 16. log-the-window + */ +test.describe('nats-eu Scenario 3 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario3'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reviews the commanding brief', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Shifts it by hundreds of kilohertz'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Commanding Brief'); + }); + + test('[reference-check] observes the GPSDO and the BUC reference lock', async () => { + await missionControl.selectGroundStation('GW-01'); + for (const tab of ['gps-timing', 'tx-chain']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference and BUC Lock'); + }); + + test('[tune-tx-modem] retunes transmit modem 1 to 1405 MHz', async () => { + await missionControl.selectTab('tx-chain'); + const freq = page.locator('#tx-frequency-input'); + await expect(freq).toBeVisible({ timeout: 10000 }); + await freq.fill('1405'); + await freq.press('Tab'); + await domClick(page, '#tx-apply-btn'); + await answerSystemQuiz(page, '1405 MHz - RF minus LO'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Tune the Transmit Modem to the Command IF'); + }); + + test('[review-pass-schedule] confirms the command window', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, '14:03:40 to 14:10:00'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Command Window'); + }); + + test('[enable-doppler-comp] engages uplink Doppler compensation', async () => { + await enableDopplerComp(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Enable Uplink Doppler Compensation'); + }); + + test('[carrier-into-muted-buc] puts the carrier up into the muted BUC', async () => { + await setTxModemOnAir(page, missionControl); + await answerSystemQuiz(page, 'So the amplifier always has drive behind it'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Carrier Up, BUC Muted'); + }); + + test('[unmute-buc] unmutes the BUC with the carrier behind it', async () => { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#buc-mute', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Unmute the BUC'); + }); + + test('[enable-hpa] enables the HPA last', async () => { + await enableHpa(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Enable the HPA'); + }); + + test('[track-for-commanding] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:03:20Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[confirm-window-open] waits for the window on the TT&C console', async () => { + await missionControl.selectTab('commanding'); + await answerSystemQuiz(page, 'Out of window - wait for 14:03:40'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Wait for the Window'); + }); + + test('[send-the-command] sends REC-PLAYBACK and gets the ACK', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:04:30Z'); + await sendCommandAndExpectAck(page, missionControl, 'REC-PLAYBACK'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Send the Playback Command'); + }); + + test('[confirm-execution] reads the ACK while still on the bird', async () => { + await missionControl.selectTab('rx-analysis'); + await answerSystemQuiz(page, 'The spacecraft received and executed it'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm Execution', 60000); + }); + + test('[disable-hpa] brings the HPA down first', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:10:30Z'); + await disableHpa(page, missionControl); + await answerSystemQuiz(page, 'The HPA. Kill the amplifier'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'HPA Down First'); + }); + + test('[mute-buc] mutes the BUC', async () => { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#buc-mute', true); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Silence the BUC'); + }); + + test('[carrier-down] takes the modem off air', async () => { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Carrier Down'); + }); + + test('[log-the-window] logs the window and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'ACK received; chain secured HPA-BUC-carrier'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + }); +}); diff --git a/e2e/specs/nats-eu-scenario4-full-completion.spec.ts b/e2e/specs/nats-eu-scenario4-full-completion.spec.ts new file mode 100644 index 00000000..6813bd46 --- /dev/null +++ b/e2e/specs/nats-eu-scenario4-full-completion.spec.ts @@ -0,0 +1,203 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerSystemQuiz, + closeWorkingDocumentIfOpen, + disableHpa, + enableDopplerComp, + enableHpa, + programTrack, + sendCommandAndExpectAck, + setSwitch, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 4 "Keys to the Bird" - full completion (phase 16). + * + * COMSEC on top of the scenario 3 uplink shift: both keys read, the command key + * rotated in two steps before the pass, chain up in order, the authenticated + * command, chain down, then the staged traffic-key mismatch at 14:11 (E3 + * `crypto-key-mismatch`) diagnosed and cleared with the payload re-key control, + * and the COMSEC log. Sim clock starts 2027-03-15 13:45:00Z; MERIDIAN-SAR-1 + * AOS 14:03:10Z, window 14:03:40Z to 14:10:00Z, LOS 14:10:18Z. + * + * The optional SAR-2 proof pass (prove-the-traffic-key) is NOT driven; the log + * completes without it, which is the optional-objective gate check. + * + * Objective flow (16, one optional): + * 1. review-mission-brief 2. crypto-status-check 3. complete-key-rotation + * 4. reference-check 5. review-pass-schedule 6. enable-doppler-comp + * 7. carrier-into-muted-buc 8. unmute-buc 9. enable-hpa + * 10. track-and-acquire 11. send-authenticated-command + * 12. secure-the-chain 13. traffic-key-mismatch 14. re-key-traffic + * 15. prove-the-traffic-key (optional) 16. log-the-rotation + */ +test.describe('nats-eu Scenario 4 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario4'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reviews the COMSEC brief', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'cannot authenticate it and rejects it'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the COMSEC Brief'); + }); + + test('[crypto-status-check] reads the traffic key on both payload panels', async () => { + await missionControl.selectGroundStation('GW-01'); + for (const tab of ['tx-chain', 'rx-analysis']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await answerSystemQuiz(page, 'The command key on the TT&C console'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read Both Keys'); + }); + + test('[complete-key-rotation] rotates the command key in two steps', async () => { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-begin-rotation')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#cmd-begin-rotation'); + await page.waitForTimeout(300); + await domClick(page, '#cmd-complete-rotation'); + await expect(page.locator('#cmd-key-badge')).toHaveText(/Valid/i, { timeout: 5000 }); + await answerSystemQuiz(page, 'The far end must acknowledge'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Complete the Scheduled Key Rotation'); + }); + + test('[reference-check] observes the GPSDO and the BUC reference lock', async () => { + for (const tab of ['gps-timing', 'tx-chain']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference and BUC Lock'); + }); + + test('[review-pass-schedule] confirms the command window', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, '14:03:40 to 14:10:00, with the command key'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Command Window'); + }); + + test('[enable-doppler-comp] engages uplink Doppler compensation', async () => { + await enableDopplerComp(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Enable Uplink Doppler Compensation'); + }); + + test('[carrier-into-muted-buc] puts the carrier up into the muted BUC', async () => { + await setTxModemOnAir(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Carrier Up, BUC Muted'); + }); + + test('[unmute-buc] unmutes the BUC', async () => { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#buc-mute', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Unmute the BUC'); + }); + + test('[enable-hpa] enables the HPA last', async () => { + await enableHpa(page, missionControl); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Enable the HPA'); + }); + + test('[track-and-acquire] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:03:20Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[send-authenticated-command] sends PLD-SAFE under the new key', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:04:30Z'); + await sendCommandAndExpectAck(page, missionControl, 'PLD-SAFE'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Send the Authenticated Command'); + }); + + test('[secure-the-chain] brings the chain down in order', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:10:30Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await answerSystemQuiz(page, 'HPA output off, while the BUC'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Secure the Chain'); + }); + + test('[traffic-key-mismatch] reads the staged mismatch on the RX payload panel', async () => { + // The fault fires at 14:11:00 on the mission clock + await advanceMissionClockToUtc(page, '2027-03-15T14:11:30Z'); + await missionControl.selectTab('rx-analysis'); + await expect(page.locator('#rx-payload-dec-key-status')).toHaveText(/Mismatch/i, { timeout: 15000 }); + await answerSystemQuiz(page, 'Rotterdam rotated the traffic key'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Traffic Key Alarm', 60000); + }); + + test('[re-key-traffic] loads the new traffic key and confirms both directions', async () => { + await missionControl.selectTab('tx-chain'); + await domClick(page, '#tx-payload-rekey-btn'); + await expect(page.locator('#tx-payload-enc-key-status')).toHaveText(/Valid/i, { timeout: 5000 }); + await page.waitForTimeout(1500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'The traffic key is symmetric'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Re-key the Payload Crypto', 60000); + }); + + test('[log-the-rotation] closes the COMSEC log without the optional proof pass', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'A key in use accumulates exposure'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete and the optional proof pass stayed open', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const proof = page.locator('.objective-item', { hasText: 'Prove the Traffic Key on SAR-2' }); + await expect(proof).toHaveCount(1); + await expect(proof).not.toHaveClass(/completed/); + }); +}); diff --git a/e2e/specs/nats-eu-scenario5-full-completion.spec.ts b/e2e/specs/nats-eu-scenario5-full-completion.spec.ts new file mode 100644 index 00000000..2f40db41 --- /dev/null +++ b/e2e/specs/nats-eu-scenario5-full-completion.spec.ts @@ -0,0 +1,222 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, assignContact, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 5 "Shetland Comes Online" - full completion (phase 16). + * + * Galway sweep, the four-contact plan split across the two sites, then the + * Shetland console taken remotely: sweep, geometry, receiver, park, and the + * SAR-1 pass flown FROM SH-02 (per-station propagation). Hand back, retune + * Galway and work SAR-2 from home, then the network log. Sim clock starts + * 2027-03-15 13:40:00Z: + * SH-02 MERIDIAN-SAR-1 AOS 14:01:14Z max el 23.8 at 14:04:42Z LOS 14:08:09Z + * GW-01 MERIDIAN-SAR-2 AOS 14:18:42Z max el 25.0 at 14:22:10Z LOS 14:25:40Z + * + * Objective flow (17): + * 1. review-mission-brief 2. galway-dashboard-sweep 3. galway-reference-check + * 4. allocate-galway 5. hand-shetland-the-overlaps 6. validate-the-plan + * 7. shetland-sweep 8. shetland-geometry 9. shetland-rx-ready + * 10. shetland-preposition 11. acquire-from-shetland 12. decode-from-shetland + * 13. read-shetland-peak 14. hand-back-to-galway 15. acquire-sar2-galway + * 16. decode-sar2-galway 17. log-the-network + */ + +/** Stage repeated fine-adjust clicks on one axis of the selected station's ACU. */ +async function jogAxis(page: Page, axisPrefix: 'az-fine' | 'el-fine', delta: number, clicks: number): Promise { + const selector = `[id^="${axisPrefix}"] .btn-fine[data-delta="${delta}"]`; + await expect(page.locator(selector).first()).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < clicks; i++) { + await domClick(page, selector); + } +} + +test.describe('nats-eu Scenario 5 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + // Direct navigation bypasses the nats-eu-scenario4 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario5'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the network brief', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'One antenna points at one satellite'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Network Brief'); + }); + + test('[galway-dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[galway-reference-check] observes the Galway GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Reference Check'); + }); + + test('[allocate-galway] gives Galway its two contacts', async () => { + await missionControl.selectTab('contact-schedule'); + await expect(page.locator('#cs-plan-badge')).toHaveText('UNALLOCATED'); + await assignContact(page, 'SAR1-GW', 'GW-01'); + await assignContact(page, 'SAR2-GW', 'GW-01'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Allocate the Galway Contacts'); + }); + + test('[hand-shetland-the-overlaps] gives Shetland the overlapping pair', async () => { + await assignContact(page, 'SAR1-SH', 'SH-02'); + await assignContact(page, 'SAR2-SH', 'SH-02'); + await answerSystemQuiz(page, 'It must be allocated - priority 2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Hand Shetland the Overlaps'); + }); + + test('[validate-the-plan] reads DECONFLICTED and publishes', async () => { + await expect(page.locator('#cs-conflict-count')).toHaveText('0'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await answerSystemQuiz(page, 'Every contact gets worked'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Validate the Contact Plan'); + }); + + test('[shetland-sweep] sweeps SH-02 from the Galway console', async () => { + await missionControl.selectGroundStation('SH-02'); + for (const tab of ['gps-timing', 'tx-chain', 'rx-analysis']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Shetland Health Check'); + }); + + test('[shetland-geometry] reads the pass as Shetland sees it', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, 'Southbound track west of Ireland'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, "Read Shetland's Sky"); + }); + + test('[shetland-rx-ready] retunes the Shetland modem and analyzer', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Shetland Receiver'); + }); + + test('[shetland-preposition] slews the stowed Shetland tracker to the rise azimuth', async () => { + await missionControl.selectTab('acu-control'); + // Stowed at az 180 / el 3; the rise is az 003 / el 5 (tolerance 3 deg). + await jogAxis(page, 'az-fine', -10, 18); + await jogAxis(page, 'az-fine', 1, 3); + await jogAxis(page, 'el-fine', 1, 2); + await domClick(page, '[id$="apply-changes-btn"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position the Shetland Tracker', 60000); + }); + + test('[acquire-from-shetland] program-tracks SAR-1 on the SH-02 pedestal', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:01:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1 from Shetland', 60000); + }); + + test('[decode-from-shetland] holds lock above 8 dB from Shetland', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:03:50Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode from Shetland', 60000); + }); + + test('[read-shetland-peak] reads the Shetland culmination', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:04:30Z'); + await answerSystemQuiz(page, 'Slant range: 860 km'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Shetland Peak', 60000); + }); + + test('[hand-back-to-galway] returns to GW-01 and retunes it for SAR-2', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:09:00Z'); + await missionControl.selectGroundStation('GW-01'); + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1397'); + await missionControl.selectTab('acu-control'); + // Parked at az 5 / el 3; the SAR-2 rise is az 130 / el 5. + await jogAxis(page, 'az-fine', 10, 12); + await jogAxis(page, 'az-fine', 1, 5); + await jogAxis(page, 'el-fine', 1, 2); + await domClick(page, '[id$="apply-changes-btn"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Hand Back and Retune Galway', 60000); + }); + + test('[acquire-sar2-galway] program-tracks SAR-2 from Galway', async () => { + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-03-15T14:19:20Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-2', 60000); + }); + + test('[decode-sar2-galway] decodes SAR-2 and compares the overhead', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:21:40Z'); + await answerSystemQuiz(page, "Shetland's, by about 6.5 dB"); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Galway Contact', 60000); + }); + + test('[log-the-network] writes the network log and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Site and peak per contact'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario6-full-completion.spec.ts b/e2e/specs/nats-eu-scenario6-full-completion.spec.ts new file mode 100644 index 00000000..694e7603 --- /dev/null +++ b/e2e/specs/nats-eu-scenario6-full-completion.spec.ts @@ -0,0 +1,190 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 6 "Watch the Watchers" - full completion (phase 16). + * + * The monthly security baseline done around a routine shift: sweep and + * receiver set-up, the audit trail read and two benign warnings dispositioned + * before the SAR-1 pass, the pass decoded, then the finding flagged, the + * contractor account disabled, the lapsed relief account expired, SAR-2 + * retuned and decoded, and the return filed. Sim clock starts + * 2027-03-15 13:45:00Z: + * MERIDIAN-SAR-1 AOS 14:03:10Z max el 28.0 at 14:06:45Z LOS 14:10:18Z + * MERIDIAN-SAR-2 AOS 14:18:42Z max el 25.0 at 14:22:10Z LOS 14:25:40Z + * + * Objective flow (15): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. rx-chain-ready 5. read-the-audit-log 6. disposition-new-address + * 7. disposition-poll-change 8. acquire-sar1 9. decode-sar1 + * 10. flag-the-off-hours-failures 11. close-the-contractor-account 12. expire-the-relief-account + * 13. retune-for-sar2 14. decode-sar2 15. close-the-baseline + */ + +/** Set an account's status on the Security console access-control panel. */ +async function setAccountStatus(page: Page, accountId: string, status: 'active' | 'disabled' | 'expired'): Promise { + const select = page.locator(`select.sec-account-select[data-account-id="${accountId}"]`); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption({ value: status }); +} + +test.describe('nats-eu Scenario 6 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario6'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the baseline task', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'The whole trail read'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Baseline Task'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[rx-chain-ready] retunes the modem and analyzer for SAR-1', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + }); + + test('[read-the-audit-log] signs off the trail', async () => { + await missionControl.selectTab('security-console'); + await expect(page.locator('#sec-mark-reviewed')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#sec-mark-reviewed'); + await expect(page.locator('#sec-reviewed-badge')).toHaveText('SIGNED OFF'); + await answerSystemQuiz(page, 'You do not know yet'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Station Audit Log'); + }); + + test('[disposition-new-address] flags the Shetland login as benign', async () => { + await domClick(page, 'button[data-event-id="evt-login-fiona"]'); + await answerSystemQuiz(page, 'Benign: SH-02 is new to the rule'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Disposition the New-Address Login'); + }); + + test('[disposition-poll-change] flags the poll-interval change as benign', async () => { + await domClick(page, 'button[data-event-id="evt-svc-interval"]'); + await answerSystemQuiz(page, 'Benign: a service account'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Disposition the Poll-Interval Change'); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:03:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[decode-sar1] holds RX lock above 8 dB', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:05:40Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Routine Downlink', 60000); + }); + + test('[flag-the-off-hours-failures] flags the finding after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:10:30Z'); + await missionControl.selectTab('security-console'); + await domClick(page, 'button[data-event-id="evt-authfail"]'); + await answerSystemQuiz(page, 'Finding: an account that should not exist'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Flag the Off-Hours Failures'); + }); + + test('[close-the-contractor-account] disables the contractor account', async () => { + await setAccountStatus(page, 'op-guest', 'disabled'); + await answerSystemQuiz(page, 'An account that exists can be attacked'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Close the Contractor Account'); + }); + + test('[expire-the-relief-account] expires the lapsed relief account', async () => { + await setAccountStatus(page, 'op-relief', 'expired'); + await answerSystemQuiz(page, 'Expired records that authorisation lapsed'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Expire the Relief Account'); + }); + + test('[retune-for-sar2] retunes the modem and analyzer for SAR-2', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1397'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retune for SAR-2'); + }); + + test('[decode-sar2] retargets to SAR-2 and decodes it', async () => { + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-03-15T14:19:20Z'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:21:40Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Second Downlink', 60000); + }); + + test('[close-the-baseline] files the return and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'A dormant account with maintenance privileges'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario7-full-completion.spec.ts b/e2e/specs/nats-eu-scenario7-full-completion.spec.ts new file mode 100644 index 00000000..ac95b6ca --- /dev/null +++ b/e2e/specs/nats-eu-scenario7-full-completion.spec.ts @@ -0,0 +1,230 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, loadEphemeris, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 7 "Moving Target" - full completion (phase 16). + * + * A baseline SAR-1 pass on current elements, then the SAR-2 avoidance burn at + * 14:12 on the mission clock: the spacecraft moves, the station's element set + * does not. Program-track on the stale set reads LOCKED while C/N sinks as the + * bird rises; the operator loads the post-burn set, reacquires, verifies the + * decode and reports. Sim clock starts 2027-03-15 13:45:00Z: + * MERIDIAN-SAR-1 AOS 14:03:10Z max el 28.0 at 14:06:45Z LOS 14:10:18Z + * MERIDIAN-SAR-2 AOS 14:18:42Z max el 25.0 at 14:22:10Z LOS 14:25:40Z + * + * Objective flow (16): + * 1. review-mission-brief 2. dashboard-sweep 3. reference-check + * 4. ephemeris-epoch-check 5. rx-chain-ready 6. preposition-for-aos + * 7. acquire-sar1 8. baseline-decode 9. log-the-baseline + * 10. retune-for-sar2 11. preposition-for-sar2 12. stale-acquisition-attempt + * 13. load-the-ephemeris 14. reacquire-sar2 15. verify-the-reacquisition + * 16. report-to-rotterdam + */ + +/** Stage repeated fine-adjust clicks on one axis, then apply. */ +async function jogAxis(page: Page, axisPrefix: 'az-fine' | 'el-fine', delta: number, clicks: number): Promise { + if (clicks <= 0) { + return; + } + const selector = `[id^="${axisPrefix}"] .btn-fine[data-delta="${delta}"]`; + await expect(page.locator(selector).first()).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < clicks; i++) { + await domClick(page, selector); + } +} + +/** Read the ACU fine-control's current value for one axis (degrees). */ +async function readAxis(page: Page, axisPrefix: 'az-fine' | 'el-fine'): Promise { + const text = (await page.locator(`[id^="${axisPrefix}"][id$="-value"]`).first().textContent()) ?? '0'; + return parseFloat(text.replace(/[^\d.+-]/gu, '')); +} + +/** Jog one axis from wherever it is to a target, in 10 and 1 degree steps. */ +async function jogAxisTo(page: Page, axisPrefix: 'az-fine' | 'el-fine', target: number): Promise { + const current = await readAxis(page, axisPrefix); + const delta = Math.round(target - current); + const sign = delta < 0 ? -1 : 1; + await jogAxis(page, axisPrefix, sign * 10, Math.floor(Math.abs(delta) / 10)); + await jogAxis(page, axisPrefix, sign * 1, Math.abs(delta) % 10); +} + +test.describe('nats-eu Scenario 7 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario7'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] reads the conjunction notice', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'Pass predictions and program-track pointing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Conjunction Notice'); + }); + + test('[dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[reference-check] observes the GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + }); + + test('[ephemeris-epoch-check] reads both element sets as current', async () => { + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, 'A manoeuvre at once, or age'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Check the Element Sets'); + }); + + test('[rx-chain-ready] retunes the modem and analyzer for SAR-1', async () => { + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + }); + + test('[preposition-for-aos] slews the stowed tracker to the rise azimuth', async () => { + await missionControl.selectTab('acu-control'); + // Stowed at az 180 / el 3; the rise is az 000 / el 5 (tolerance 3 deg). + await jogAxis(page, 'az-fine', -10, 18); + await jogAxis(page, 'el-fine', 1, 2); + await domClick(page, '[id$="apply-changes-btn"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + }); + + test('[acquire-sar1] program-tracks SAR-1 on current elements', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:03:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[baseline-decode] decodes the baseline and reads the curve', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:05:40Z'); + await page.waitForTimeout(2500); + await advanceMissionClockToUtc(page, '2027-03-15T14:06:40Z'); + await answerSystemQuiz(page, 'Climbed steadily with elevation'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Baseline Pass', 60000); + }); + + test('[log-the-baseline] logs the SAR-1 result after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-03-15T14:10:30Z'); + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'program-track locked, decoded, peak C/N about 11 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Log the Baseline'); + }); + + test('[retune-for-sar2] retunes for SAR-2 after the burn', async () => { + // The burn fires at 14:12:00 on the mission clock + await advanceMissionClockToUtc(page, '2027-03-15T14:12:30Z'); + await closeWorkingDocumentIfOpen(page); + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1397'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Retune for SAR-2'); + }); + + test('[preposition-for-sar2] parks the tracker on the SAR-2 rise azimuth', async () => { + await missionControl.selectTab('acu-control'); + await domClick(page, '.btn-tracking[data-mode="manual"]'); + await page.waitForTimeout(500); + await jogAxisTo(page, 'az-fine', 130); + await jogAxisTo(page, 'el-fine', 5); + await domClick(page, '[id$="apply-changes-btn"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for SAR-2', 60000); + }); + + test('[stale-acquisition-attempt] locks on the old prediction and watches C/N sink', async () => { + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-03-15T14:19:00Z'); + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:20:30Z'); + await answerSystemQuiz(page, 'The pedestal is locked to a prediction'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Attempt SAR-2 on the Old Elements', 60000); + }); + + test('[load-the-ephemeris] loads the post-burn elements', async () => { + // Let the panel re-render as STALE on this tab before pressing Load; a + // click that arrives during the tab switch is lost. + await missionControl.selectTab('pass-schedule'); + await expect(page.locator('#ephemeris-panel .ephemeris-badge-stale')).toBeVisible({ timeout: 15000 }); + await loadEphemeris(page, missionControl, 'SAR2-CAM'); + await answerSystemQuiz(page, 'The epoch: the post-burn set'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Load the Post-Burn Elements'); + }); + + test('[reacquire-sar2] sees the carrier come up on the new set', async () => { + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reacquire MERIDIAN-SAR-2', 60000); + }); + + test('[verify-the-reacquisition] holds lock above 8 dB', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-15T14:21:30Z'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Verify the Reacquisition', 60000); + }); + + test('[report-to-rotterdam] files the report and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'That the post-burn set was loaded'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario8-full-completion.spec.ts b/e2e/specs/nats-eu-scenario8-full-completion.spec.ts new file mode 100644 index 00000000..cf406659 --- /dev/null +++ b/e2e/specs/nats-eu-scenario8-full-completion.spec.ts @@ -0,0 +1,318 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { + answerPendingQuizFrom, + answerSystemQuiz, + assignContact, + closeWorkingDocumentIfOpen, + commitLinkWithMargin, + computeLinkBudget, + disableHpa, + enableDopplerComp, + enableHpa, + programTrack, + sendCommandAndExpectAck, + setRxModemFrequency, + setSwitch, + setTxModemOnAir, +} from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 8 "Night Passes" - full completion (phase 16). + * + * The solo qualification night: plan, rotation and budget before the 00:31 + * SAR-1 pass (link proven cold, chain up inside the window, REC-PLAYBACK + * acknowledged, chain down), + * then the Shetland console remotely for a 13 deg SAR-2 telemetry contact on + * a reference in holdover (GNSS outage 02:44-02:59, E3), then Galway again + * for the 73 deg SAR-2 pass after the fleet traffic key rolls at 04:15 (E3). + * Sim clock starts 2027-03-16 00:15:00Z: + * GW-01 MERIDIAN-SAR-1 AOS 00:31:19Z max el 40.4 at 00:35:03Z LOS 00:38:49Z + * SH-02 MERIDIAN-SAR-2 AOS 02:54:39Z max el 13.4 at 02:57:34Z LOS 03:00:27Z + * GW-01 MERIDIAN-SAR-2 AOS 04:27:31Z max el 73.1 at 04:31:23Z LOS 04:35:12Z + * + * The clock jumps (advanceMissionClockToUtc) stand in for the operator's time + * skips; both faults fire on the mission clock, so the jump to each contact + * trips the fault the objective expects to find. + * + * Objective flow (21): + * 1. review-mission-brief 2. build-the-night-plan 3. night-key-rotation + * 4. night-budget 5. gw-reference-and-chain 6. acquire-sar1 + * 7. prove-the-night-link 8. chain-up-for-commanding 9. command-the-bird + * 10. secure-the-chain 11. log-contact-1 12. shetland-remote-sweep + * 13. holdover-diagnosis 14. acquire-sar2-from-shetland 15. telemetry-contact + * 16. confirm-reference-recovered 17. traffic-key-alarm 18. re-key-before-aos + * 19. acquire-sar2-galway 20. decode-the-steep-pass 21. hand-over-the-shift + */ +/** + * Answer one objective's pending quiz. Plan, rotation and budget activate + * together after the brief and the quiz manager only auto-surfaces one of + * their quizzes, so open the others from the checklist's "?" button and + * answer whichever question is showing by its text. + */ +async function answerObjectiveQuiz( + page: Page, + missionControl: MissionControlPage, + objectiveId: string, + answers: Array<{ questionHint: string; answerText: string }> +): Promise { + const modal = page.locator('#quiz-modal, .quiz-box'); + if (!(await modal.isVisible().catch(() => false))) { + if (!(await missionControl.objectivesChecklist.isVisible().catch(() => false))) { + await missionControl.openChecklist(); + } + const button = page.locator(`.condition-quiz-btn[data-objective-id="${objectiveId}"]`); + if ((await button.count()) === 0) { + return; // already answered + } + await domClick(page, `.condition-quiz-btn[data-objective-id="${objectiveId}"]`); + } + await answerPendingQuizFrom(page, answers); + await dismissDialogIfPresent(page); +} + +test.describe('nats-eu Scenario 8 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario8'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] takes the shift', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'None of them - the plan'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Take the Shift'); + }); + + test('[build-the-night-plan] splits the pairs and drops the P3 overhead', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('contact-schedule'); + await expect(page.locator('#cs-plan-badge')).toHaveText('UNALLOCATED'); + await assignContact(page, 'N-SAR1-GW', 'GW-01'); + await assignContact(page, 'N-SAR1-SH', 'SH-02'); + await assignContact(page, 'N-SAR2-SH', 'SH-02'); + await assignContact(page, 'N-SAR2-GW', 'GW-01'); + await expect(page.locator('#cs-conflict-count')).toHaveText('0'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + await dismissDialogIfPresent(page); + }); + + test('[night-key-rotation] rotates the command key', async () => { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-begin-rotation')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#cmd-begin-rotation'); + await page.waitForTimeout(300); + await domClick(page, '#cmd-complete-rotation'); + await expect(page.locator('#cmd-key-badge')).toHaveText(/Valid/i, { timeout: 5000 }); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Complete the COMSEC Rotation'); + }); + + test('[night-budget] predicts the night link and answers both pre-pass quizzes', async () => { + await computeLinkBudget(page, missionControl, { eirpDbm: 28, fsplDb: 169.1, rxGainDbi: 51.8, noiseTempK: 88, bandwidthMHz: 36, miscLossDb: 1 }); + // Plan, rotation and budget activate together after the brief, and the + // quiz manager surfaces the last-registered quiz first, so answer the two + // pending quizzes by their question text rather than in objective order. + const answers = [ + { questionHint: 'Shetland overhead', answerText: 'It is priority 3 and may be dropped' }, + { questionHint: '2.3 dB', answerText: '20 log(761 / 581)' }, + ]; + await answerObjectiveQuiz(page, missionControl, 'build-the-night-plan', answers); + await answerObjectiveQuiz(page, missionControl, 'night-budget', answers); + await waitForObjectiveComplete(missionControl, 'Build the Night Contact Plan'); + await waitForObjectiveComplete(missionControl, 'Predict the Night Link'); + }); + + test('[gw-reference-and-chain] observes the GPSDO and BUC reference', async () => { + for (const tab of ['gps-timing', 'tx-chain']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Reference and BUC Lock'); + }); + + test('[acquire-sar1] program-tracks SAR-1 and observes the beacon', async () => { + await advanceMissionClockToUtc(page, '2027-03-16T00:31:40Z'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[prove-the-night-link] locks and commits the link with margin', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-16T00:34:00Z'); + await page.waitForTimeout(2500); // receiver-signal-locked latches on RX analysis + await commitLinkWithMargin(page, missionControl, 10.5); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Prove the Night Link', 60000); + }); + + test('[chain-up-for-commanding] brings the chain up inside the window, after the link is proven', async () => { + await enableDopplerComp(page, missionControl); + await setTxModemOnAir(page, missionControl); + await setSwitch(page, '#buc-mute', false); + await enableHpa(page, missionControl); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Chain Up for Commanding'); + }); + + test('[command-the-bird] sends REC-PLAYBACK and gets the ACK', async () => { + await sendCommandAndExpectAck(page, missionControl, 'REC-PLAYBACK'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Command the Bird'); + }); + + test('[secure-the-chain] brings the chain down in order after LOS', async () => { + await advanceMissionClockToUtc(page, '2027-03-16T00:39:30Z'); + await disableHpa(page, missionControl); + await setSwitch(page, '#buc-mute', true); + await setSwitch(page, '#tx-transmit-switch', false); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Secure the Chain'); + }); + + test('[log-contact-1] logs the first contact', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'Rotation completed before the window'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Log the First Contact'); + }); + + test('[shetland-remote-sweep] skips to the Shetland window and sweeps SH-02', async () => { + // The SH-02 GNSS outage trips at 02:44 on the mission clock + await advanceMissionClockToUtc(page, '2027-03-16T02:52:30Z'); + await closeWorkingDocumentIfOpen(page); + await missionControl.selectGroundStation('SH-02'); + for (const tab of ['tx-chain', 'rx-analysis']) { + await missionControl.selectTab(tab); + await page.waitForTimeout(2500); + } + await setRxModemFrequency(page, missionControl, 1370); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Shetland Remote Sweep'); + }); + + test('[holdover-diagnosis] reads the Shetland reference in holdover', async () => { + await missionControl.selectTab('gps-timing'); + // Both stations' GPS Timing panels stay mounted; only SH-02 is in holdover. + await expect(page.locator('#gpsdo-holdover-badge').filter({ hasText: /^ACTIVE$/u })).toHaveCount(1, { timeout: 15000 }); + await answerSystemQuiz(page, 'A GNSS outage - the switch'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Shetland Reference'); + }); + + test('[acquire-sar2-from-shetland] program-tracks SAR-2 on the SH-02 pedestal', async () => { + await advanceMissionClockToUtc(page, '2027-03-16T02:54:50Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-2 from Shetland', 60000); + }); + + test('[telemetry-contact] holds lock above 5 dB through culmination', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-16T02:56:30Z'); + await answerSystemQuiz(page, 'No: 20 log(1233 / 581)'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Work the Telemetry Contact', 60000); + }); + + test('[confirm-reference-recovered] sees the GNSS return and logs contact 2', async () => { + // The outage clears at 02:59 on the mission clock + await advanceMissionClockToUtc(page, '2027-03-16T03:00:10Z'); + await missionControl.selectTab('gps-timing'); + await expect(page.locator('#gpsdo-holdover-badge').filter({ hasText: /^ACTIVE$/u })).toHaveCount(0, { timeout: 15000 }); + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'reference held on the oscillator'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Confirm the Shetland Reference Recovered', 60000); + }); + + test('[traffic-key-alarm] skips to the Galway window and reads the mismatch', async () => { + // The fleet traffic key rolls at 04:15 on the mission clock + await advanceMissionClockToUtc(page, '2027-03-16T04:23:30Z'); + await closeWorkingDocumentIfOpen(page); + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('rx-analysis'); + await expect(page.locator('#rx-payload-dec-key-status').first()).toHaveText(/Mismatch/i, { timeout: 15000 }); + await answerSystemQuiz(page, 'Rotterdam rolled the fleet traffic key'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Read the Traffic Key', 60000); + }); + + test('[re-key-before-aos] loads the new traffic key', async () => { + await missionControl.selectTab('tx-chain'); + await domClick(page, '#tx-payload-rekey-btn'); + await expect(page.locator('#tx-payload-enc-key-status').first()).toHaveText(/Valid/i, { timeout: 5000 }); + await page.waitForTimeout(1500); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Re-key the Payload Crypto', 60000); + }); + + test('[acquire-sar2-galway] retunes and program-tracks the steep pass', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await advanceMissionClockToUtc(page, '2027-03-16T04:27:50Z'); + await programTrack(page, missionControl, '61702'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-2 over Galway', 60000); + }); + + test('[decode-the-steep-pass] decodes through the top of the 73 deg pass', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-16T04:30:00Z'); + await answerSystemQuiz(page, 'The azimuth rate at the top'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Steep Pass', 60000); + }); + + test('[hand-over-the-shift] closes the handover and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The contact plan, the COMSEC state of both keys'); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + await expect(levelCompleteModal.locator('.complete-modal__title')).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-scenario9-full-completion.spec.ts b/e2e/specs/nats-eu-scenario9-full-completion.spec.ts new file mode 100644 index 00000000..ddbdc1d3 --- /dev/null +++ b/e2e/specs/nats-eu-scenario9-full-completion.spec.ts @@ -0,0 +1,237 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToUtc, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, assignContact, closeWorkingDocumentIfOpen, expectDashboardAlarm, fillAndChange, programTrack, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * nats-eu Scenario 9 "Morning Constellation" - full completion (phase 16). + * + * Both sites swept from the Galway console (four checks each), the + * six-contact day plan, MERIDIAN-SAR-1 flown from Galway for the standing + * collect, Galway armed on program-track for its SAR-2 window, the Shetland + * console taken remotely and SAR-2 flown FROM SH-02 (per-station + * propagation), then Galway's unattended pass checked and the customer told. + * Sim clock starts 2027-03-17 06:00:00Z: + * GW-01 MERIDIAN-SAR-1 AOS 06:18:00Z max el 30.5 at 06:22:47Z LOS 06:27:33Z + * SH-02 MERIDIAN-SAR-2 AOS 06:30:00Z max el 23.0 at 06:34:38Z LOS 06:39:17Z + * GW-01 MERIDIAN-SAR-2 AOS 06:32:00Z max el 25.2 at 06:36:43Z LOS 06:41:22Z + * + * The specs jump with advanceMissionClockToUtc (sim + mission clock together) + * to each pass segment and observe on RX Analysis, where the lock and C/N + * conditions latch. Pre-pass objectives carry 1.5-4 min timers that run on + * real time; each step below completes well inside its window. + * + * Objective flow (18): + * 1. review-mission-brief 2. galway-dashboard-sweep 3. galway-reference-check + * 4. galway-receive-chain 5. galway-transmit-chain 6. build-the-day-plan + * 7. shetland-dashboard-sweep 8. shetland-reference-check 9. shetland-receive-chain + * 10. shetland-transmit-chain 11. acquire-sar1 12. decode-the-standing-collect + * 13. arm-galway-for-sar2 14. take-the-shetland-console 15. acquire-sar2-from-shetland + * 16. decode-sar2-from-shetland 17. check-the-unattended-pass 18. customer-status + */ +test.describe('nats-eu Scenario 9 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + + // Direct navigation bypasses the nats-eu-scenario8 prerequisite card lock + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario9'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('[review-mission-brief] takes the shift and confirms the plan rule', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await answerSystemQuiz(page, 'No site is double-booked'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Take the Shift'); + }); + + test('[galway-dashboard-sweep] reads the board: AGC rail, no faults', async () => { + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + }); + + test('[galway-reference-check] observes the Galway GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Reference Check'); + }); + + test('[galway-receive-chain] reads the LNB and retunes the modem and analyzer for SAR-1', async () => { + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); // LNB LO / thermal state latch on RX analysis + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Receive Chain'); + }); + + test('[galway-transmit-chain] confirms the chain is cold the right way', async () => { + await missionControl.selectTab('tx-chain'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'Modem off air, HPA output disabled, BUC muted'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Transmit Chain'); + }); + + test('[build-the-day-plan] splits the conflict pairs across the two sites', async () => { + await missionControl.selectTab('contact-schedule'); + await expect(page.locator('#cs-plan-badge')).toHaveText('UNALLOCATED'); + + // The two P1/P2 conflict pairs go one to each site; the P3 second-orbit + // contacts stay unallocated (requiredPriorityAtOrAbove 2) + await assignContact(page, 'M-SAR1-GW', 'GW-01'); + await assignContact(page, 'M-SAR1-SH', 'SH-02'); + await assignContact(page, 'M-SAR2-GW', 'GW-01'); + await assignContact(page, 'M-SAR2-SH', 'SH-02'); + + await expect(page.locator('#cs-conflict-count')).toHaveText('0'); + await expect(page.locator('#cs-unassigned-count')).toHaveText('0'); + await expect(page.locator('#cs-plan-badge')).toHaveText('DECONFLICTED'); + + await answerSystemQuiz(page, 'Galway is armed on program-track'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Build the Day Plan'); + }); + + test('[shetland-dashboard-sweep] reads the remote board', async () => { + await missionControl.selectGroundStation('SH-02'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'Nothing in the alarm set is tripped'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'SH-02 Dashboard Sweep'); + }); + + test('[shetland-reference-check] observes the Shetland GPSDO', async () => { + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'SH-02 Reference Check'); + }); + + test('[shetland-receive-chain] reads the Shetland LNB and predicts the 73 degree peak', async () => { + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(2500); + await answerSystemQuiz(page, 'About 16 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'SH-02 Receive Chain'); + }); + + test('[shetland-transmit-chain] confirms the Shetland chain is cold', async () => { + await missionControl.selectTab('tx-chain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'SH-02 Transmit Chain'); + }); + + test('[acquire-sar1] returns to Galway and program-tracks SAR-1', async () => { + await advanceMissionClockToUtc(page, '2027-03-17T06:18:30Z'); + await missionControl.selectGroundStation('GW-01'); + await programTrack(page, missionControl, '61701'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire MERIDIAN-SAR-1', 60000); + }); + + test('[decode-the-standing-collect] decodes the 1414 MHz downlink for Erik', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-17T06:21:30Z'); + await answerSystemQuiz(page, 'Decoded with about 5 dB'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode the Standing Collect', 60000); + }); + + test('[arm-galway-for-sar2] retunes Galway and targets SAR-2 for the unattended pass', async () => { + await setRxModemFrequency(page, missionControl, 1370); + await programTrack(page, missionControl, '61702'); + await answerSystemQuiz(page, 'Receiver on the right carrier'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Arm Galway for SAR-2'); + }); + + test('[take-the-shetland-console] takes SH-02 after Fiona sets and retunes it for SAR-2', async () => { + await advanceMissionClockToUtc(page, '2027-03-17T06:29:20Z'); + await missionControl.selectGroundStation('SH-02'); + await setRxModemFrequency(page, missionControl, 1370); + await fillAndChange(page, '#sa-center-freq', '1397'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Take the Shetland Console'); + }); + + test('[acquire-sar2-from-shetland] program-tracks SAR-2 on the SH-02 pedestal', async () => { + await programTrack(page, missionControl, '61702'); + await advanceMissionClockToUtc(page, '2027-03-17T06:31:10Z'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Acquire SAR-2 from Shetland', 60000); + }); + + test('[decode-sar2-from-shetland] holds lock above 7 dB from Shetland', async () => { + await missionControl.selectTab('rx-analysis'); + await advanceMissionClockToUtc(page, '2027-03-17T06:33:40Z'); + await answerSystemQuiz(page, 'The pedestal still program-track locked'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Decode SAR-2 from Shetland', 60000); + }); + + test('[check-the-unattended-pass] finds Galway still tracking and locked on SAR-2', async () => { + await advanceMissionClockToUtc(page, '2027-03-17T06:38:00Z'); + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Check the Unattended Pass', 60000); + }); + + test('[customer-status] reports the morning to Erik and completes', async () => { + await closeWorkingDocumentIfOpen(page); + await answerSystemQuiz(page, 'The collect decoded with margin; both SAR-2 windows'); + await dismissDialogIfPresent(page); + + // The FINAL objective is asserted through the Mission Complete modal, not + // the checklist: the checklist stops repainting when the completion flow + // takes over, so its last row never shows the completed class. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const modalTitle = levelCompleteModal.locator('.complete-modal__title'); + await expect(modalTitle).toContainText('Mission Complete'); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + }); +}); diff --git a/e2e/specs/nats-eu-time-skip.spec.ts b/e2e/specs/nats-eu-time-skip.spec.ts new file mode 100644 index 00000000..6f402451 --- /dev/null +++ b/e2e/specs/nats-eu-time-skip.spec.ts @@ -0,0 +1,194 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { domClick, waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerSystemQuiz, expectDashboardAlarm, fillAndChange, setRxModemFrequency } from '../utils/nats-eu-helpers'; +import { dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Operator time skip (settings.timeSkip) on nats-eu Scenario 7. + * + * Exercises the control end to end in the real app: the header button appears + * only for a scenario that opted in, it stays disabled while there is nothing + * to skip, and confirming the dialog fast-forwards BOTH clocks - the scenario + * clock the sky is propagated against, and the mission clock every + * "seconds since mission start" mechanic is keyed to. The two moving together + * is the thing worth guarding: they were separate quantities before the skip + * existed, and a scenario whose command window opens at its satellite's AOS + * breaks silently if only one of them advances. + * + * Scenario 7 geometry (2027-03-15 13:45:00 UTC start, phase 16): + * MERIDIAN-SAR-1 AOS T+18.2 LOS T+25.3 + * MERIDIAN-SAR-2 AOS T+33.7 LOS T+40.7 (Shetland sees it to T+43.4) + * next pass at the 5 deg mask: SAR-2 over Galway at T+125 + * The skip is also blocked while a timed objective runs, and every pre-pass + * objective in the phase-16 scenarios is timed, so the spec works through the + * pre-pass beats to the untimed acquisition objective, then advances to T+45, + * past both first passes - which is exactly the dead hour the control exists for. + */ + +/** Stage repeated fine-adjust clicks on one axis. */ +async function jogAxis(page: Page, axisPrefix: 'az-fine' | 'el-fine', delta: number, clicks: number): Promise { + const selector = `[id^="${axisPrefix}"] .btn-fine[data-delta="${delta}"]`; + await expect(page.locator(selector).first()).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < clicks; i++) { + await domClick(page, selector); + } +} + +const SKIP_BUTTON = '#time-skip-control'; +const SKIP_CONFIRM = '#time-skip-confirm-btn'; +const SKIP_OVERLAY = '#time-skip-overlay'; + +/** Jump the scenario clock forward (sim minutes) via the dev hook. */ +async function advanceClock(page: Page, minutes: number): Promise { + await page.waitForFunction(() => typeof (window as any).advanceClock === 'function'); + await page.evaluate((ms) => (window as any).advanceClock(ms), minutes * 60_000); + await page.waitForTimeout(1500); +} + +/** Scenario-clock time in ms, via the OpsLogManager dev hook. */ +async function simClockMs(page: Page): Promise { + return page.evaluate(() => (window as any).simClockMs()); +} + +/** Total time skipped this scenario, in ms, via the OpsLogManager dev hook. */ +async function missionSkippedMs(page: Page): Promise { + return page.evaluate(() => (window as any).missionSkippedMs()); +} + +test.describe('nats-eu time skip', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + await missionControl.gotoScenario('nats-eu', 'nats-eu-scenario7'); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(120000); + }); + + test('mounts the control for a scenario that opted in', async () => { + await expect(page.locator(SKIP_BUTTON)).toBeVisible({ timeout: 15000 }); + }); + + test('stays disabled while the scenario clock is frozen for the brief', async () => { + // The first objective freezes the clock. Skipping a paused scenario would + // run the sky forward while the mission stands still. + await expect(page.locator(SKIP_BUTTON)).toBeDisabled(); + await expect(page.locator(SKIP_BUTTON)).toHaveAttribute('title', /paused/i); + }); + + test('unfreezing the clock does not on its own offer a skip', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + + // Answer by text, not by position - the options are presented in a + // scenario-defined order that a wrong pick would leave the objective on. + await waitForQuizToAppear(page); + await page.locator('.quiz-option-btn', { hasText: 'Pass predictions and program-track pointing' }).click(); + await page.locator('#quiz-continue-btn').click(); + await dismissDialogIfPresent(page); + + // The dashboard sweep that follows the brief is a timed objective, and a + // timed objective blocks the skip: the operator is being timed on real + // work, not waiting for the sky. + await expect(page.locator(SKIP_BUTTON)).toBeDisabled({ timeout: 15000 }); + await expect(page.locator(SKIP_BUTTON)).toHaveAttribute('title', /timed objective/i); + }); + + test('works the pre-pass beats to the untimed acquisition objective', async () => { + test.setTimeout(180000); + await missionControl.selectGroundStation('GW-01'); + await missionControl.selectTab('dashboard'); + await expectDashboardAlarm(page, 'AGC at max gain'); + await answerSystemQuiz(page, 'RX AGC at max gain'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'GW-01 Dashboard Sweep'); + + await missionControl.selectTab('gps-timing'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Reference Check'); + + await missionControl.selectTab('pass-schedule'); + await answerSystemQuiz(page, 'A manoeuvre at once'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Check the Element Sets'); + + await setRxModemFrequency(page, missionControl, 1414); + await fillAndChange(page, '#sa-center-freq', '1389'); + await fillAndChange(page, '#sa-span', '2'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Set Up the Receiver for SAR-1'); + + await missionControl.selectTab('acu-control'); + await jogAxis(page, 'az-fine', -10, 18); + await jogAxis(page, 'el-fine', 1, 2); + await domClick(page, '[id$="apply-changes-btn"]'); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Pre-position for AOS', 60000); + + // Acquisition has no timer and SAR-1 is a quarter of an hour out: the + // dead air before AOS is exactly what the skip is for. + await expect(page.locator(SKIP_BUTTON)).toBeEnabled({ timeout: 15000 }); + await expect(page.locator(SKIP_BUTTON)).toHaveAttribute('title', /MERIDIAN-SAR-1/u); + }); + + test('offers the skip once the sky is empty', async () => { + await advanceClock(page, 45); + + await expect(page.locator(SKIP_BUTTON)).toBeEnabled({ timeout: 15000 }); + await expect(page.locator(SKIP_BUTTON)).toContainText(/Skip \d/); + }); + + test('fast-forwards both clocks to just before the next AOS', async () => { + const beforeSimMs = await simClockMs(page); + const beforeSkippedMs = await missionSkippedMs(page); + + await page.locator(SKIP_BUTTON).click(); + await expect(page.locator(SKIP_CONFIRM)).toBeVisible({ timeout: 10000 }); + await page.locator(SKIP_CONFIRM).click(); + + // The overlay is the operator's evidence that time is being consumed, not + // that the app has hung. + await expect(page.locator(SKIP_OVERLAY)).toBeVisible({ timeout: 5000 }); + await expect(page.locator(SKIP_OVERLAY)).toBeHidden({ timeout: 20000 }); + + const afterSimMs = await simClockMs(page); + const afterSkippedMs = await missionSkippedMs(page); + const scenarioClockDeltaMs = afterSimMs - beforeSimMs; + const missionClockDeltaMs = afterSkippedMs - beforeSkippedMs; + + // The next pass at the mask is SAR-2 over Galway at T+125, ~80 min out from T+45. + expect(scenarioClockDeltaMs).toBeGreaterThan(30 * 60_000); + + // Both clocks advanced together (a few seconds of real time also elapses + // during the animation, so this is a tolerance, not an equality). + expect(Math.abs(scenarioClockDeltaMs - missionClockDeltaMs)).toBeLessThan(15_000); + }); + + test('goes back to disabled once the skip has been taken', async () => { + // The clock now sits inside the lead-in to the pass it skipped to, so + // there is nothing left to skip. + await expect(page.locator(SKIP_BUTTON)).toBeDisabled({ timeout: 15000 }); + }); +}); diff --git a/e2e/specs/navigation.spec.ts b/e2e/specs/navigation.spec.ts index 5a5f978c..ebc67c79 100644 --- a/e2e/specs/navigation.spec.ts +++ b/e2e/specs/navigation.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/test-fixtures'; +import { expect, test } from '../fixtures/test-fixtures'; test.describe('Navigation', () => { test('should load campaign selection page at root URL', async ({ campaignSelectionPage }) => { @@ -17,20 +17,14 @@ test.describe('Navigation', () => { await expect(natsCard).not.toHaveClass(/disabled/); }); - test('should navigate to scenario selection when campaign is clicked', async ({ - campaignSelectionPage, - page, - }) => { + test('should navigate to scenario selection when campaign is clicked', async ({ campaignSelectionPage, page }) => { await campaignSelectionPage.goto(); await campaignSelectionPage.selectCampaign('nats'); await expect(page).toHaveURL('/campaigns/nats'); }); - test('should navigate back to campaigns from scenario selection', async ({ - scenarioSelectionPage, - page, - }) => { + test('should navigate back to campaigns from scenario selection', async ({ scenarioSelectionPage, page }) => { await scenarioSelectionPage.gotoCampaign('nats'); await expect(scenarioSelectionPage.backButton).toBeVisible(); diff --git a/e2e/specs/objective-completion.spec.ts b/e2e/specs/objective-completion.spec.ts index 0c045202..7f2df1bc 100644 --- a/e2e/specs/objective-completion.spec.ts +++ b/e2e/specs/objective-completion.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from '../fixtures/test-fixtures'; +import { expect, test } from '../fixtures/test-fixtures'; import { waitForSimulationReady } from '../utils/simulation-helpers'; test.describe('Objective Completion', () => { @@ -26,9 +26,7 @@ test.describe('Objective Completion', () => { await expect(missionControlPage.objectivesChecklist).toBeVisible(); // Look for active objective styling - const activeObjective = missionControlPage.objectivesChecklist.locator( - '.objective-item.active, .objective-active, [data-active="true"]' - ); + const activeObjective = missionControlPage.objectivesChecklist.locator('.objective-item.active, .objective-active, [data-active="true"]'); // May or may not have an active objective depending on scenario state const count = await activeObjective.count(); @@ -87,12 +85,7 @@ test.describe('Objective Completion', () => { test.describe('Full Scenario Flow', () => { // This is a longer test that exercises the full user workflow - test('should complete campaign to scenario flow', async ({ - campaignSelectionPage, - scenarioSelectionPage, - missionControlPage, - page, - }) => { + test('should complete campaign to scenario flow', async ({ campaignSelectionPage, scenarioSelectionPage, missionControlPage, page }) => { // Step 1: Start at campaign selection await campaignSelectionPage.goto(); await expect(campaignSelectionPage.pageTitle).toHaveText('Signal Range Training'); diff --git a/e2e/specs/sandbox-loadout.spec.ts b/e2e/specs/sandbox-loadout.spec.ts new file mode 100644 index 00000000..7f704e86 --- /dev/null +++ b/e2e/specs/sandbox-loadout.spec.ts @@ -0,0 +1,92 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Sandbox loadout (phase 17). + * + * Scenarios ship hard-coded equipment; sandboxes let the operator swap it. Two + * entry points are exercised: the `?antenna=` query override that e2e and the + * plugin dev harness use, and the LOADOUT control in the command bar, which + * writes the choice to localStorage and restarts the sandbox from a clean + * store. The ACU tab's identification line is the assertion point because it + * is rendered from the live antenna's config, not from scenario data. + */ + +const SANDBOX = '/campaigns/nats/scenarios/nats-sandbox'; +const STATION = 'VT-01'; + +async function readAcuAntennaInfo(page: Page): Promise { + const mc = new MissionControlPage(page); + + await mc.selectGroundStation(STATION); + await mc.selectTab('acu-control'); + + const info = page.locator('.acu-antenna-info').first(); + + await expect(info).toBeVisible(); + + return (await info.textContent()) ?? ''; +} + +test.describe('Sandbox loadout', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/'); + await page.evaluate(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + }); + + test('?antenna= swaps the station antenna for one launch', async ({ page }) => { + await page.goto(`${SANDBOX}?antenna=KU_BAND_9M_LIMIT`); + await waitForSimulationReady(page); + + expect(await readAcuAntennaInfo(page)).toContain('Ku-Band 9m'); + + // The override is per launch: nothing was written to storage. + const stored = await page.evaluate(() => localStorage.getItem('sandbox-loadout:nats-sandbox')); + + expect(stored).toBeNull(); + }); + + test('LOADOUT applies a choice, restarts the sandbox, and remembers it', async ({ page }) => { + await page.goto(SANDBOX); + await waitForSimulationReady(page); + + expect(await readAcuAntennaInfo(page)).toContain('C-Band 9m'); + + await page.locator('#loadout-control').click(); + + const box = page.locator('#loadout-modal'); + + await expect(box).toBeVisible(); + await box.locator('select[data-station-index="0"]').selectOption('KU_BAND_3M_ANTESTAR'); + await box.locator('#loadout-apply').click(); + + await waitForSimulationReady(page); + expect(await readAcuAntennaInfo(page)).toContain('Ku-Band 3m'); + + const stored = await page.evaluate(() => localStorage.getItem('sandbox-loadout:nats-sandbox')); + + expect(stored).toContain('KU_BAND_3M_ANTESTAR'); + + // A plain reload honours the stored loadout. + await page.goto(SANDBOX); + await waitForSimulationReady(page); + expect(await readAcuAntennaInfo(page)).toContain('Ku-Band 3m'); + + // Reset returns the scenario's own equipment. + await page.locator('#loadout-control').click(); + await page.locator('#loadout-modal #loadout-reset').click(); + await waitForSimulationReady(page); + expect(await readAcuAntennaInfo(page)).toContain('C-Band 9m'); + }); + + test('scored scenarios have no loadout control', async ({ page }) => { + await page.goto('/campaigns/nats/scenarios/nats-scenario1'); + await expect(page.locator('#app-shell-page')).toBeVisible({ timeout: 15000 }); + + await expect(page.locator('#loadout-control')).toHaveCount(0); + }); +}); diff --git a/e2e/specs/scenario1-full-completion.spec.ts b/e2e/specs/scenario1-full-completion.spec.ts index ec0882b5..eac46593 100644 --- a/e2e/specs/scenario1-full-completion.spec.ts +++ b/e2e/specs/scenario1-full-completion.spec.ts @@ -1,13 +1,8 @@ -import { test as base, expect, Page, BrowserContext } from '@playwright/test'; +import { BrowserContext, test as base, expect, Page } from '@playwright/test'; import { CampaignSelectionPage } from '../pages/campaign-selection.page'; -import { ScenarioSelectionPage } from '../pages/scenario-selection.page'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { ScenarioSelectionPage } from '../pages/scenario-selection.page'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 1 objectives - expanded tutorial with interactive conditions and quizzes. @@ -161,11 +156,7 @@ const SCENARIO_1_OBJECTIVES: Scenario1Objective[] = [ /** * Helper to complete a single objective */ -async function completeObjective( - page: Page, - missionControlPage: MissionControlPage, - objective: Scenario1Objective -): Promise { +async function completeObjective(page: Page, missionControlPage: MissionControlPage, objective: Scenario1Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -178,6 +169,10 @@ async function completeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; } diff --git a/e2e/specs/scenario10-full-completion.spec.ts b/e2e/specs/scenario10-full-completion.spec.ts index f3a3479e..042de476 100644 --- a/e2e/specs/scenario10-full-completion.spec.ts +++ b/e2e/specs/scenario10-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 10 - "Customer Pass": High-Throughput Window on AURORA-7. @@ -25,13 +20,7 @@ import { * - 'set-tracking-mode': Antenna tracking mode change (step-track, no satellite reselect) * - 'configure-hpa-backoff': HPA backoff field + Apply button */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'auto' - | 'set-tracking-mode' - | 'configure-hpa-backoff'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'auto' | 'set-tracking-mode' | 'configure-hpa-backoff'; interface Scenario10Objective { id: string; @@ -82,8 +71,7 @@ const SCENARIO_10_OBJECTIVES: Scenario10Objective[] = [ id: 'why-step-track', title: 'Confirm Tracking Mode for the Pass', type: 'quiz', - correctAnswer: - 'Inclined orbit drift will degrade C/N across the pass - step-track holds beacon optimum continuously', + correctAnswer: 'Inclined orbit drift will degrade C/N across the pass - step-track holds beacon optimum continuously', }, { id: 'enable-step-track-tab', @@ -145,8 +133,7 @@ const SCENARIO_10_OBJECTIVES: Scenario10Objective[] = [ id: 'assess-current-backoff', title: 'Assess Current HPA Backoff', type: 'quiz', - correctAnswer: - 'Safe but conservative - tighter backoff gives the customer more EIRP without breaking the amp', + correctAnswer: 'Safe but conservative - tighter backoff gives the customer more EIRP without breaking the amp', }, { id: 'optimize-hpa-backoff', @@ -162,10 +149,9 @@ const SCENARIO_10_OBJECTIVES: Scenario10Objective[] = [ }, { id: 'imd-tradeoff-check', - title: 'Acknowledge the IMD Tradeoff', + title: 'Acknowledge the IMD Cost', type: 'quiz', - correctAnswer: - 'IMD products rise as the amp moves closer to saturation - monitor for overdrive across the window', + correctAnswer: 'IMD products rise as the amp moves closer to saturation - monitor for overdrive across the window', }, // ============================================================ @@ -197,15 +183,13 @@ const SCENARIO_10_OBJECTIVES: Scenario10Objective[] = [ id: 'final-pass-snapshot', title: 'Final Pass Disposition', type: 'quiz', - correctAnswer: - 'AURORA-7 pass complete - step-track held throughout, C/N margin sustained, no overdrive events, HPA returned to 10 dB', + correctAnswer: 'AURORA-7 pass complete - step-track held throughout, C/N margin sustained, no overdrive events, HPA returned to 10 dB', }, { id: 'log-customer-pass', title: 'Log the Customer Pass', type: 'quiz', - correctAnswer: - 'SeaLink 30-min high-priority pass on AURORA-7 - step-track engaged, HPA optimized to 6 dB backoff for window then restored to 10 dB. C/N margin sustained, no link events.', + correctAnswer: 'SeaLink pass on AURORA-7 - step-track, HPA 6 dB backoff for the window then restored to 10 dB. Margin held, no link events.', }, ]; @@ -219,10 +203,7 @@ const SCENARIO_10_OBJECTIVES: Scenario10Objective[] = [ * AURORA-7 the satellite selection is already correct from the scenario * preconfig, so no target reselection is required. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { // Step-track is an optimization toggle on top of program-track, not a // separate mode button (same pattern as the scenario 6 spec). if (trackingMode === 'step-track') { @@ -257,10 +238,7 @@ async function setTrackingMode( * Configure HPA backoff to achieve target output power. * Lower backoff = higher output power. */ -async function configureHpaBackoff( - page: import('@playwright/test').Page, - backoff: number -): Promise { +async function configureHpaBackoff(page: import('@playwright/test').Page, backoff: number): Promise { const backoffInput = page.locator('#hpa-backoff'); await expect(backoffInput).toBeVisible({ timeout: 5000 }); await backoffInput.fill(backoff.toString()); @@ -276,11 +254,7 @@ async function configureHpaBackoff( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario10Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario10Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -293,6 +267,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'set-tracking-mode': diff --git a/e2e/specs/scenario11-full-completion.spec.ts b/e2e/specs/scenario11-full-completion.spec.ts index de1982ee..52521180 100644 --- a/e2e/specs/scenario11-full-completion.spec.ts +++ b/e2e/specs/scenario11-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 11 - "Planned Maintenance: Hand Off": Coordinated Traffic Transfer. @@ -28,16 +23,7 @@ import { * - 'execute-handover': Traffic handover dropdown + execute button * - 'auto': Pre-staged state already satisfies the condition */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'toggle-switch' - | 'enable-tx-commit' - | 'set-tracking-mode' - | 'select-satellite' - | 'execute-handover' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'toggle-switch' | 'enable-tx-commit' | 'set-tracking-mode' | 'select-satellite' | 'execute-handover' | 'auto'; interface Scenario11Objective { id: string; @@ -64,8 +50,7 @@ const SCENARIO_11_OBJECTIVES: Scenario11Objective[] = [ id: 'review-mission-brief', title: 'Review Shift Brief', type: 'quiz', - correctAnswer: - 'TIDEMARK-1 traffic moves to ME-02 for a 2-hour VT-01 maintenance window, then comes back next shift', + correctAnswer: 'TIDEMARK-1 traffic moves to ME-02 for a 2-hour VT-01 maintenance window, then comes back next shift', }, // ============================================================ @@ -87,8 +72,7 @@ const SCENARIO_11_OBJECTIVES: Scenario11Objective[] = [ id: 'vt-pre-handover-dashboard', title: 'VT-01 Pre-Handover Sweep', type: 'quiz', - correctAnswer: - 'A pre-handover snapshot documents what the link looked like healthy, so any post-handover anomaly can be attributed correctly', + correctAnswer: 'It records what the link looked like healthy, so any anomaly after the transfer can be attributed', }, { id: 'vt-confirm-tm1-locked', @@ -137,8 +121,7 @@ const SCENARIO_11_OBJECTIVES: Scenario11Objective[] = [ id: 'understand-commit-point', title: 'Understand the Commit Point', type: 'quiz', - correctAnswer: - 'Antenna locked, RX carrier with C/N margin, TX chain staged - modem transmitting into a muted BUC, HPA disabled until the transfer swaps RF authority', + correctAnswer: 'Antenna locked, RX carrier with C/N margin, TX staged cold - modem on, BUC muted, HPA disabled until the transfer', }, // ============================================================ @@ -216,8 +199,7 @@ const SCENARIO_11_OBJECTIVES: Scenario11Objective[] = [ id: 'vt-final-rf-safety-check', title: 'Final RF Safety Check', type: 'quiz', - correctAnswer: - 'HPA disabled, BUC muted, antenna at maintenance position - no RF energy on the feed, dish accessible', + correctAnswer: 'HPA disabled, BUC muted, antenna at maintenance position - no RF energy on the feed, dish accessible', }, // ============================================================ @@ -227,8 +209,7 @@ const SCENARIO_11_OBJECTIVES: Scenario11Objective[] = [ id: 'log-handover-entry', title: 'Log the Handover', type: 'quiz', - correctAnswer: - '1000 - Planned handover TM-1 VT-01 to ME-02 complete. VT-01 RF chain safed, antenna at maintenance position. Crew on-site for HPA waveguide inspection. ME-02 (Vega) carrying traffic. Return to service next shift.', + correctAnswer: '1000 - Planned TM-1 handover to ME-02 complete. VT-01 safed, antenna at maintenance position, crew on-site. Return to service next shift.', }, ]; @@ -239,14 +220,10 @@ const SCENARIO_11_OBJECTIVES: Scenario11Objective[] = [ /** * Toggle a switch to a desired state. */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - desiredState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, desiredState: boolean): Promise { const switchEl = page.locator(`#${switchId}`); await expect(switchEl).toBeVisible({ timeout: 5000 }); - // Let the adapter's throttled DOM sync land before reading - the static + // Let the adapter's throttled DOM sync settle before reading - the static // template can render a stale checked state right after the tab mounts. await page.waitForTimeout(1200); const isChecked = await switchEl.isChecked(); @@ -301,10 +278,7 @@ async function enableTxForHandover(page: import('@playwright/test').Page): Promi * Set the antenna tracking mode by clicking the appropriate button. * For maintenance and stow modes, also clicks Apply to commit position. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { const modeButton = page.locator(`.btn-tracking[data-mode="${trackingMode}"]`); await expect(modeButton).toBeVisible({ timeout: 5000 }); await modeButton.click(); @@ -323,15 +297,14 @@ async function setTrackingMode( * polling the simulation state directly (DOM polling can exit before the slew * starts and desync the objective chain). */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 240000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 240000): Promise { await page.waitForFunction( () => { - const sim = (window as unknown as { - signalRange?: { simulationManager?: { groundStations?: Array<{ antennas?: Array<{ state?: { slewing?: boolean; elevation?: number } }> }> } }; - }).signalRange?.simulationManager; + const sim = ( + window as unknown as { + signalRange?: { simulationManager?: { groundStations?: Array<{ antennas?: Array<{ state?: { slewing?: boolean; elevation?: number } }> }> } }; + } + ).signalRange?.simulationManager; const antennaState = sim?.groundStations?.[0]?.antennas?.[0]?.state; if (!antennaState) return false; // Maintenance position is 5° elevation (±1° objective tolerance) @@ -347,10 +320,7 @@ async function waitForAntennaMovement( /** * Select a satellite in the asset tree to open its dashboard. */ -async function selectSatellite( - page: import('@playwright/test').Page, - satelliteId: string -): Promise { +async function selectSatellite(page: import('@playwright/test').Page, satelliteId: string): Promise { const satTreeItem = page.locator(`[data-asset-id="${satelliteId}"]`); await expect(satTreeItem).toBeVisible({ timeout: 10000 }); await satTreeItem.click(); @@ -361,10 +331,7 @@ async function selectSatellite( /** * Execute traffic handover to target station via the satellite dashboard. */ -async function executeTrafficHandover( - page: import('@playwright/test').Page, - targetStation: string -): Promise { +async function executeTrafficHandover(page: import('@playwright/test').Page, targetStation: string): Promise { const handoverSelect = page.locator('#sat-handover-target'); await expect(handoverSelect).toBeVisible({ timeout: 5000 }); await handoverSelect.selectOption({ value: targetStation }); @@ -381,11 +348,7 @@ async function executeTrafficHandover( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario11Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario11Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -398,6 +361,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': diff --git a/e2e/specs/scenario12-full-completion.spec.ts b/e2e/specs/scenario12-full-completion.spec.ts index 279478d6..37990547 100644 --- a/e2e/specs/scenario12-full-completion.spec.ts +++ b/e2e/specs/scenario12-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 12 - "Planned Maintenance: Return to Service". @@ -164,8 +159,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ id: 'tx-chain-inspection', title: 'TX Chain Pre-Power Inspection', type: 'quiz', - correctAnswer: - 'BUC gain is at 50 dB - testing value left over from maintenance, operating value is 23 dB', + correctAnswer: 'BUC gain is at 50 dB - testing value left over from maintenance, operating value is 23 dB', }, // ============================================================ // PHASE 5: TX CHAIN RESTORATION @@ -206,8 +200,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ id: 'verify-tx-staged', title: 'Confirm TX Staged Cold', type: 'quiz', - correctAnswer: - 'Two stations radiating at the same transponder is dual illumination - the handover swaps RF authority in one coordinated action so only one uplink is ever on the air', + correctAnswer: 'Two uplinks on one transponder is dual illumination - the handover swaps RF authority so only one is ever on the air', }, // ============================================================ @@ -223,8 +216,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ id: 'pre-handover-criteria', title: 'Confirm Handover-Ready State', type: 'quiz', - correctAnswer: - 'Beacon lock + RX modem lock with C/N margin + TX chain staged cold (modem on, BUC muted, HPA disabled)', + correctAnswer: 'Beacon lock + RX modem lock with C/N margin + TX chain staged cold (modem on, BUC muted, HPA disabled)', }, { id: 'execute-handover-return-select-sat', @@ -248,8 +240,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ id: 'verify-handover-success', title: 'Confirm Return Complete', type: 'quiz', - correctAnswer: - 'VT-01 owns TM-1 traffic, ME-02 TX stood down, no packet loss reported, no new alarms', + correctAnswer: 'VT-01 owns TM-1 traffic, ME-02 TX stood down, no packet loss reported, no new alarms', }, // ============================================================ @@ -259,8 +250,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ id: 'notify-customer', title: 'Notify SeaLink', type: 'quiz', - correctAnswer: - 'VT-01 restored, TM-1 traffic returned to primary station, no service interruption observed.', + correctAnswer: 'VT-01 restored, TM-1 traffic returned to primary station, no service interruption observed.', }, { id: 'final-dashboard-sweep-tab', @@ -278,8 +268,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ id: 'log-maintenance-complete', title: 'Log Maintenance Cycle Complete', type: 'quiz', - correctAnswer: - 'VT-01 returned to service post-waveguide-gasket inspection. TM-1 traffic returned from ME-02. Maintenance leftover (BUC gain 50 dB) corrected before energizing. No customer impact.', + correctAnswer: 'VT-01 back in service after gasket inspection. TM-1 returned from ME-02. BUC gain leftover (50 dB) corrected before energizing. No customer impact.', }, ]; @@ -291,10 +280,7 @@ const SCENARIO_12_OBJECTIVES: Scenario12Objective[] = [ * Set the antenna tracking mode by clicking the appropriate button. * ACU control tab must be active before calling this. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { const modeButton = page.locator(`.btn-tracking[data-mode="${trackingMode}"]`); await expect(modeButton).toBeVisible({ timeout: 5000 }); await modeButton.click(); @@ -312,10 +298,7 @@ async function setTrackingMode( * Select a target satellite from the ACU dropdown and click Move to Target. * Used after setTrackingMode('program-track'). */ -async function selectSatelliteAndMove( - page: import('@playwright/test').Page, - satelliteNoradId: string -): Promise { +async function selectSatelliteAndMove(page: import('@playwright/test').Page, satelliteNoradId: string): Promise { const satelliteSelect = page.locator('select[id$="satellite-select"]'); await expect(satelliteSelect).toBeVisible({ timeout: 5000 }); await satelliteSelect.selectOption({ value: satelliteNoradId }); @@ -333,15 +316,14 @@ async function selectSatelliteAndMove( * the wait early while the dish is still slewing from stow, which desyncs the * whole objective chain). */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 240000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 240000): Promise { await page.waitForFunction( () => { - const sim = (window as unknown as { - signalRange?: { simulationManager?: { groundStations?: Array<{ antennas?: Array<{ state?: { slewing?: boolean; isLocked?: boolean } }> }> } }; - }).signalRange?.simulationManager; + const sim = ( + window as unknown as { + signalRange?: { simulationManager?: { groundStations?: Array<{ antennas?: Array<{ state?: { slewing?: boolean; isLocked?: boolean } }> }> } }; + } + ).signalRange?.simulationManager; const antennaState = sim?.groundStations?.[0]?.antennas?.[0]?.state; return antennaState ? antennaState.slewing === false && antennaState.isLocked === true : false; }, @@ -355,10 +337,7 @@ async function waitForAntennaMovement( /** * Configure spectrum analyzer center frequency. Span is optional. */ -async function configureSpectrumAnalyzer( - page: import('@playwright/test').Page, - config: { centerFrequency: number; span?: number } -): Promise { +async function configureSpectrumAnalyzer(page: import('@playwright/test').Page, config: { centerFrequency: number; span?: number }): Promise { const centerFreqInput = page.locator('#sa-center-freq'); await expect(centerFreqInput).toBeVisible({ timeout: 5000 }); await centerFreqInput.fill(config.centerFrequency.toString()); @@ -380,10 +359,7 @@ async function configureSpectrumAnalyzer( * Power on the LNB and apply operating LO + gain values. * Waits for thermal stabilization after Apply. */ -async function configureLnb( - page: import('@playwright/test').Page, - config: { loFrequency: number; gain: number } -): Promise { +async function configureLnb(page: import('@playwright/test').Page, config: { loFrequency: number; gain: number }): Promise { const powerSwitch = page.locator('#lnb-power'); await expect(powerSwitch).toBeVisible({ timeout: 5000 }); // The static template renders the power switch `checked`; the adapter syncs @@ -420,10 +396,7 @@ async function configureLnb( * Set the BUC gain via the TX Chain panel and click Apply. * TX Chain tab must be active. */ -async function configureBucGain( - page: import('@playwright/test').Page, - gain: number -): Promise { +async function configureBucGain(page: import('@playwright/test').Page, gain: number): Promise { const gainInput = page.locator('#buc-gain'); await expect(gainInput).toBeVisible({ timeout: 5000 }); await gainInput.fill(gain.toString()); @@ -439,14 +412,10 @@ async function configureBucGain( /** * Toggle a switch to the desired state. */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - desiredState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, desiredState: boolean): Promise { const switchEl = page.locator(`#${switchId}`); await expect(switchEl).toBeVisible({ timeout: 5000 }); - // Let the adapter's throttled DOM sync land before reading - the static + // Let the adapter's throttled DOM sync settle before reading - the static // template can render a stale checked state right after the tab mounts. await page.waitForTimeout(1200); const isChecked = await switchEl.isChecked(); @@ -464,10 +433,7 @@ async function toggleSwitch( /** * Select a satellite by clicking its asset-tree item. */ -async function selectSatellite( - page: import('@playwright/test').Page, - satelliteAssetId: string -): Promise { +async function selectSatellite(page: import('@playwright/test').Page, satelliteAssetId: string): Promise { const satTreeItem = page.locator(`[data-asset-id="${satelliteAssetId}"]`); await expect(satTreeItem).toBeVisible({ timeout: 10000 }); await satTreeItem.click(); @@ -478,10 +444,7 @@ async function selectSatellite( /** * Execute the satellite-dashboard traffic handover to the target station. */ -async function executeTrafficHandover( - page: import('@playwright/test').Page, - targetStation: string -): Promise { +async function executeTrafficHandover(page: import('@playwright/test').Page, targetStation: string): Promise { const handoverSelect = page.locator('#sat-handover-target'); await expect(handoverSelect).toBeVisible({ timeout: 5000 }); await handoverSelect.selectOption({ value: targetStation }); @@ -498,11 +461,7 @@ async function executeTrafficHandover( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario12Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario12Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -515,6 +474,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'set-tracking-mode': diff --git a/e2e/specs/scenario13-full-completion.spec.ts b/e2e/specs/scenario13-full-completion.spec.ts index 9e21378d..64a17042 100644 --- a/e2e/specs/scenario13-full-completion.spec.ts +++ b/e2e/specs/scenario13-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 13 - "Thermal Anomaly": Reading the Trend. @@ -23,12 +18,7 @@ import { * - 'auto': Auto-satisfied by simulation state (HPA backs off naturally * once BUC drive drops; no user action required) */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'configure-buc-gain' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'configure-buc-gain' | 'auto'; interface Scenario13Objective { id: string; @@ -70,8 +60,7 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'confirm-no-active-alarm', title: 'Confirm Pre-Alarm State', type: 'quiz', - correctAnswer: - 'Pre-alarm - you have time to choose a deliberate action instead of a reflexive one', + correctAnswer: 'Pre-alarm - you have time to choose a deliberate action instead of a reflexive one', }, { id: 'open-tx-chain', @@ -89,8 +78,7 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'check-current-draw', title: 'Cross-Check Current Draw', type: 'quiz', - correctAnswer: - 'BUC is dissipating more electrical power - consistent with the thermal rise, not a separate fault', + correctAnswer: 'BUC is dissipating more power as heat - consistent with the thermal rise, not a separate fault', }, { id: 'cross-check-spectrum-tab', @@ -107,8 +95,7 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'record-baseline-readings', title: 'Record Baseline Readings', type: 'quiz', - correctAnswer: - 'Time, BUC temperature, BUC current, BUC gain, HPA backoff - so the next operator can rebuild the curve', + correctAnswer: 'Time, BUC temperature, BUC current, BUC gain, HPA backoff - the next operator can rebuild the curve', }, // ============================================================ @@ -118,22 +105,19 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'identify-root-cause', title: 'Name the Root Cause', type: 'quiz', - correctAnswer: - 'BUC gain is set higher than required - the module is dissipating the excess as heat instead of useful RF', + correctAnswer: 'BUC gain is 10 dB above the operating value - the module is dissipating the excess as heat, not RF', }, { id: 'evaluate-options', title: 'Choose a Course of Action', type: 'quiz', - correctAnswer: - 'De-rate now: reduce BUC gain ~10 dB to cut dissipation, monitor the trend reverse, schedule a swap during the next planned window', + correctAnswer: 'De-rate now - reduce BUC gain ~10 dB, watch the trend reverse, schedule a swap for the next planned window', }, { id: 'confirm-action-plan', title: 'Confirm the Sequence', type: 'quiz', - correctAnswer: - 'Lower BUC gain ~10 dB, verify HPA still in linear region, verify carrier still nominal, then watch the temperature curve bend', + correctAnswer: 'Lower BUC gain ~10 dB, verify HPA still linear, verify carrier still nominal, then watch the temperature curve', }, // ============================================================ @@ -160,8 +144,7 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'verify-trend-stabilizing', title: 'Confirm the Trend Is Bending', type: 'quiz', - correctAnswer: - 'Watch 5-10 minutes: temperature slope flattens then trends down, current draw drops toward nominal, carrier still locked downstream', + correctAnswer: 'Watch 5-10 minutes: temperature slope flattens then trends down, current drops toward 3.0A, carrier still locked', }, // ============================================================ @@ -171,8 +154,7 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'schedule-maintenance-ticket', title: 'Open the Maintenance Ticket', type: 'quiz', - correctAnswer: - 'Trend record (15-min curve), de-rate action taken, current BUC gain/backoff settings, recommendation to swap module during next planned window', + correctAnswer: 'Trend record (15-min curve), de-rate action taken, current gain/backoff settings, swap recommended for next planned window', }, { id: 'final-dashboard-sweep-tab', @@ -184,15 +166,13 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ id: 'final-dashboard-sweep', title: 'Final Dashboard Sweep', type: 'quiz', - correctAnswer: - 'No active alarms, BUC running de-rated, carrier nominal, swap ticket open against next planned window', + correctAnswer: 'No active alarms, BUC running de-rated, carrier nominal, swap ticket open against next planned window', }, { id: 'log-shift-summary', title: 'Log the Shift Entry', type: 'quiz', - correctAnswer: - '1003 - VT-01 BUC thermal trend (57°C->62°C over 15 min) addressed by 10 dB gain de-rate. Trend reversing. Swap ticket opened for next planned window. Carrier nominal throughout.', + correctAnswer: '1003 - VT-01 BUC thermal trend (57->62°C over 15 min) addressed by 10 dB gain de-rate. Trend reversing, swap ticket open, carrier nominal.', }, ]; @@ -204,10 +184,7 @@ const SCENARIO_13_OBJECTIVES: Scenario13Objective[] = [ * Configure BUC gain via the TX Chain adjust control. * TX Chain tab must be active before calling this. */ -async function configureBucGain( - page: import('@playwright/test').Page, - gain: number -): Promise { +async function configureBucGain(page: import('@playwright/test').Page, gain: number): Promise { const gainInput = page.locator('#buc-gain'); await expect(gainInput).toBeVisible({ timeout: 5000 }); await gainInput.fill(gain.toString()); @@ -226,11 +203,7 @@ async function configureBucGain( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario13Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario13Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -243,6 +216,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'configure-buc-gain': diff --git a/e2e/specs/scenario14-full-completion.spec.ts b/e2e/specs/scenario14-full-completion.spec.ts index 0873b5f1..2576916b 100644 --- a/e2e/specs/scenario14-full-completion.spec.ts +++ b/e2e/specs/scenario14-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 14 - "Rain Fade": Adapt Without Handover. @@ -23,12 +18,7 @@ import { * - 'auto': Auto-satisfied by simulation state (signal locks, HPA nominal, * sustained-monitor objectives whose conditions are already met) */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'toggle-switch' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'toggle-switch' | 'auto'; interface Scenario14Objective { id: string; @@ -59,8 +49,7 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'acknowledge-customer-constraint', title: 'Customer Constraint', type: 'quiz', - correctAnswer: - 'Their SLA penalizes handover events more heavily than a few dB of margin loss', + correctAnswer: 'Their SLA penalizes handover events more heavily than a few dB of margin loss', }, // ============================================================ @@ -103,8 +92,7 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'understand-heater-vs-rain', title: 'Heater Purpose for Rain', type: 'quiz', - correctAnswer: - 'Keeping water from beading and sheeting on the feed - dry surfaces attenuate less than wet ones', + correctAnswer: 'Keeping water from sheeting on the feed - dry surfaces attenuate less than wet ones', }, // ============================================================ @@ -125,8 +113,7 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'baseline-margin-quiz', title: 'Link Margin Baseline', type: 'quiz', - correctAnswer: - 'It defines how much fade the link can absorb before reaching the demodulation threshold', + correctAnswer: 'It defines how much fade the link can absorb before reaching the demodulation threshold', }, // ============================================================ @@ -141,15 +128,13 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'agc-behavior-quiz', title: 'AGC Behavior in the Fade', type: 'quiz', - correctAnswer: - 'AGC is compensating - the demodulator still sees a usable signal, and we still have headroom in the gain stage', + correctAnswer: 'AGC is compensating - the demodulator still sees a usable signal, and the gain stage still has headroom', }, { id: 'agc-headroom-quiz', title: 'AGC Headroom Reading', type: 'quiz', - correctAnswer: - 'Plenty of headroom remaining - link is comfortable, hold is justified', + correctAnswer: 'Plenty of headroom remaining - link is comfortable, hold is justified', }, // ============================================================ @@ -165,8 +150,7 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'hpa-backoff-decision-quiz', title: 'HPA Backoff Decision', type: 'quiz', - correctAnswer: - 'No - the link is healthy; trading IMD risk for unused margin is a bad bargain', + correctAnswer: 'No - the link is healthy; trading IMD risk for unused margin is a bad bargain', }, { id: 'verify-hpa-still-nominal', @@ -187,8 +171,7 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'decision-hold', title: 'Make the Call', type: 'quiz', - correctAnswer: - 'Hold VT-01. AGC has headroom, modem locked, customer preference honored. Re-evaluate if state changes.', + correctAnswer: 'Hold VT-01. AGC has headroom, modem locked, customer preference honored. Re-evaluate if state changes.', }, // ============================================================ @@ -207,15 +190,13 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ id: 'post-storm-baseline-quiz', title: 'Post-Storm Recovery', type: 'quiz', - correctAnswer: - 'C/N recovers toward baseline; AGC backs its gain down; modem lock unchanged', + correctAnswer: 'C/N recovers toward baseline; AGC backs its gain down; modem lock unchanged', }, { id: 'document-handover-avoided', title: 'Log the Hold', type: 'quiz', - correctAnswer: - 'Moderate rain over VT-01, ~3 dB fade. Held TM-1 service per customer SLA preference; AGC max 3 dB, modem lock maintained throughout, no handover.', + correctAnswer: 'Moderate rain over VT-01, ~3 dB fade. Held TM-1 per customer SLA preference; AGC max 3 dB, modem lock maintained, no handover.', }, ]; @@ -228,11 +209,7 @@ const SCENARIO_14_OBJECTIVES: Scenario14Objective[] = [ * Tries exact ID match first, then suffix and substring fallbacks * to tolerate prefix variations. */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - targetState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, targetState: boolean): Promise { let switchEl = page.locator(`#${switchId}`); if ((await switchEl.count()) === 0) { @@ -246,7 +223,7 @@ async function toggleSwitch( await expect(switchEl.first()).toBeVisible({ timeout: 5000 }); - // Let the adapter's throttled DOM sync land before reading - the static + // Let the adapter's throttled DOM sync settle before reading - the static // template can render a stale checked state right after the tab mounts. await page.waitForTimeout(1200); const isChecked = await switchEl.first().isChecked(); @@ -266,11 +243,7 @@ async function toggleSwitch( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario14Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario14Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -283,6 +256,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': @@ -344,10 +321,7 @@ test.describe('Scenario 14 Full Completion', () => { test(`[${objective.id}] ${objective.title}`, async () => { // Monitor-during-fade and sustained-monitor wait for the simulation // to advance through the rain event. Give them more runway. - if ( - objective.id === 'monitor-during-fade' || - objective.id === 'sustained-monitor' - ) { + if (objective.id === 'monitor-during-fade' || objective.id === 'sustained-monitor') { test.setTimeout(180000); } await executeObjective(page, missionControlPage, objective); diff --git a/e2e/specs/scenario15-full-completion.spec.ts b/e2e/specs/scenario15-full-completion.spec.ts index af1599c8..93d20cb7 100644 --- a/e2e/specs/scenario15-full-completion.spec.ts +++ b/e2e/specs/scenario15-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 15 - "Frequency Coordination": Inter-Operator Spectrum Etiquette. @@ -27,13 +22,7 @@ import { * - 'configure-hpa-backoff': HPA back-off slider/input * - 'auto': Auto-satisfied by simulation state (no user action needed) */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'configure-speca' - | 'configure-hpa-backoff' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'configure-speca' | 'configure-hpa-backoff' | 'auto'; interface Scenario15Objective { id: string; @@ -94,8 +83,7 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ id: 'assess-guard-adequacy', title: 'Assess Guard Band Adequacy', type: 'quiz', - correctAnswer: - "Verify our TX chain is producing clean spectrum - no spurs or IMD landing in RedSky's band", + correctAnswer: "Verify our TX chain is producing clean spectrum - no spurs or IMD falling into RedSky's band", }, // ============================================================ @@ -117,15 +105,13 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ id: 'observe-current-hpa-backoff', title: 'Observe HPA Backoff', type: 'quiz', - correctAnswer: - 'Operating close to saturation - third-order IMD products will be elevated and extend several MHz beyond the carrier edges', + correctAnswer: 'Operating close to saturation - third-order IMD will be elevated and spill several MHz past the carrier edges', }, { id: 'understand-imd-mechanism', title: 'Understand IMD Mechanism', type: 'quiz', - correctAnswer: - 'Nonlinearity in the amplifier mixes spectral components, generating intermodulation products that fall just outside the carrier edges', + correctAnswer: 'Amplifier nonlinearity mixes spectral components, generating intermodulation products just outside the carrier edges', }, // ============================================================ @@ -135,8 +121,7 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ id: 'evaluate-mitigation-options', title: 'Choose Mitigation', type: 'quiz', - correctAnswer: - 'Increase HPA backoff to 10 dB - reduces IMD without dropping the carrier and without requiring customer coordination', + correctAnswer: 'Increase HPA backoff to 10 dB - suppresses IMD without dropping the carrier or coordinating with the customer', }, // ============================================================ @@ -179,8 +164,7 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ id: 'confirm-carrier-still-nominal', title: 'Confirm Carrier Still Nominal', type: 'quiz', - correctAnswer: - 'Wideband carrier still present at slightly reduced power - customer link healthy, IMD skirts dropped well below the adjacent slot noise floor', + correctAnswer: 'Wideband carrier still present at slightly lower power - link healthy and IMD skirts below the adjacent noise floor', }, { id: 'verify-receiver-locked', @@ -195,8 +179,7 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ id: 'confirm-spectrum-clean-for-partner', title: 'Draft Confirmation to RedSky', type: 'quiz', - correctAnswer: - 'Confirmed clear. TIDEMARK-3 TP-1 carrier holds 5967-6003 MHz H-pol with adjacent-channel emissions well below your planned slot. Proceed with your 5961 MHz V-pol uplink as scheduled.', + correctAnswer: 'Confirmed clear. TP-1 carrier 5967-6003 MHz H-pol, adjacent-channel emissions below your slot. Proceed 5961 MHz V-pol as scheduled.', }, // ============================================================ @@ -206,8 +189,7 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ id: 'log-coordination-event', title: 'Log Coordination Event', type: 'quiz', - correctAnswer: - '0937 - RedSky coordination notice received and confirmed. TIDEMARK-3 TP-1 HPA backoff raised from 5 to 10 dB to suppress adjacent-channel IMD. Cleared RedSky for 5961 MHz V-pol uplink. SeaLink carrier remains nominal.', + correctAnswer: '0937 - RedSky notice confirmed. TP-1 HPA backoff raised 5 to 10 dB to suppress adjacent IMD. RedSky cleared for 5961 MHz V-pol, SeaLink nominal.', }, ]; @@ -219,10 +201,7 @@ const SCENARIO_15_OBJECTIVES: Scenario15Objective[] = [ * Configure spectrum analyzer settings. * Element IDs: #sa-center-freq, #sa-span, #sa-min-amp, #sa-max-amp, #sa-rbw */ -async function configureSpectrumAnalyzer( - page: import('@playwright/test').Page, - config: NonNullable -): Promise { +async function configureSpectrumAnalyzer(page: import('@playwright/test').Page, config: NonNullable): Promise { if (config.centerFrequency !== undefined) { const centerInput = page.locator('#sa-center-freq'); await expect(centerInput).toBeVisible({ timeout: 5000 }); @@ -270,10 +249,7 @@ async function configureSpectrumAnalyzer( /** * Configure HPA back-off via input + Apply button. */ -async function configureHpaBackoff( - page: import('@playwright/test').Page, - backoff: number -): Promise { +async function configureHpaBackoff(page: import('@playwright/test').Page, backoff: number): Promise { const backoffInput = page.locator('#hpa-backoff'); await expect(backoffInput).toBeVisible({ timeout: 5000 }); await backoffInput.fill(backoff.toString()); @@ -289,11 +265,7 @@ async function configureHpaBackoff( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario15Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario15Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -306,6 +278,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'configure-speca': diff --git a/e2e/specs/scenario16-full-completion.spec.ts b/e2e/specs/scenario16-full-completion.spec.ts index 3df10824..88fbb687 100644 --- a/e2e/specs/scenario16-full-completion.spec.ts +++ b/e2e/specs/scenario16-full-completion.spec.ts @@ -1,10 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 16 - "Cascade Failure": Multi-System Recovery Under Customer Pressure. @@ -32,14 +28,7 @@ import { * - 'lnb-power-cycle': OFF, wait, ON sequence * - 'auto': Auto-satisfied by simulation state (waiting for thermal recovery) */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'toggle-switch' - | 'configure-hpa-backoff' - | 'lnb-power-cycle' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'toggle-switch' | 'configure-hpa-backoff' | 'lnb-power-cycle' | 'auto'; interface Scenario16Objective { id: string; @@ -63,8 +52,7 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ id: 'review-mission-brief', title: 'Review Incident Brief', type: 'quiz', - correctAnswer: - 'Triage all alarms first, then act in priority order: RF safety, customer impact, equipment health', + correctAnswer: 'Triage all alarms first, then act in priority order: RF safety, customer impact, equipment health', }, // ============================================================ @@ -86,15 +74,13 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ id: 'triage-dashboard-alarms', title: 'Triage the Alarm Board', type: 'quiz', - correctAnswer: - 'BUC over-temperature and high current, LNB reference unlocked, HPA overdriven', + correctAnswer: 'BUC over-temperature and high current, LNB reference unlocked, HPA overdriven', }, { id: 'prioritize-recovery-order', title: 'Set the Recovery Order', type: 'quiz', - correctAnswer: - 'RF safety first (HPA overdrive + BUC) → customer impact next (LNB / RX) → final verification', + correctAnswer: 'RF safety first (HPA overdrive + BUC) → customer impact next (LNB / RX) → final verification', }, // ============================================================ @@ -117,8 +103,7 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ id: 'disable-hpa-for-safety', title: 'Disable the HPA - Confirm Rationale', type: 'quiz', - correctAnswer: - 'It takes the dirty uplink off the air immediately, and an enabled HPA must never be left without BUC drive - it would amplify raw noise into the feed', + correctAnswer: 'It takes the dirty uplink off the air, and an enabled HPA left without BUC drive would amplify raw noise into the feed', }, { id: 'mute-buc-for-cooldown', @@ -131,8 +116,7 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ id: 'diagnose-hpa-overdrive', title: 'Diagnose HPA Overdrive', type: 'quiz', - correctAnswer: - 'Output is too close to saturation - IMD products are rising and the amplifier is at risk', + correctAnswer: 'Output is too close to saturation - IMD products are rising and the amplifier is at risk', }, { id: 'correct-hpa-backoff', @@ -208,8 +192,7 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ id: 'verify-tx-output-clean', title: 'Restore HPA Output - Confirm Spectrum Posture', type: 'quiz', - correctAnswer: - 'Clean - IMD products are back below coordination limits and we are no longer interfering', + correctAnswer: 'Clean - IMD products are back below coordination limits and we are no longer interfering', }, // ============================================================ @@ -225,22 +208,20 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ id: 'final-alarm-sweep', title: 'Final Dashboard Sweep', type: 'quiz', - correctAnswer: - 'All three faults cleared - BUC thermal normal, LNB locked, HPA within back-off - link operational', + correctAnswer: 'All three faults cleared - BUC thermal normal, LNB locked, HPA within back-off - link operational', }, { id: 'customer-notification', title: 'Notify the Customer', type: 'quiz', - correctAnswer: - 'Three concurrent faults identified and cleared in priority order. Link is operational. Will follow up with a written impact report within the hour.', + correctAnswer: 'Three concurrent faults identified and cleared in priority order. Link is operational. Written impact report to follow within the hour.', }, { id: 'log-cascade-event', title: 'Log the Cascade Event', type: 'quiz', correctAnswer: - 'Concurrent unrelated faults VT-01: BUC over-temp (72°C, high I), LNB ref unlock (sticky), HPA back-off drift (2 dB). Recovery in order: HPA output disabled, BUC muted for cooldown, back-off restored to 10 dB, LNB power cycled, BUC unmuted, HPA restored. Customer (SeaLink) notified.', + 'VT-01 concurrent unrelated faults: BUC over-temp (72°C), LNB ref unlock (sticky), HPA back-off 2 dB. Cleared in priority order: HPA, BUC, LNB. SeaLink notified.', }, ]; @@ -248,16 +229,12 @@ const SCENARIO_16_OBJECTIVES: Scenario16Objective[] = [ // Helper Functions // ============================================================ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - desiredState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, desiredState: boolean): Promise { const switchEl = page.locator(`#${switchId}`); await expect(switchEl).toBeVisible({ timeout: 5000 }); // Adapters sync DOM to sim state on a ~1 s throttle; the static template may // render a stale checked state right after the tab mounts. Let the first - // sync land before reading. + // sync settle before reading. await page.waitForTimeout(1200); const isChecked = await switchEl.isChecked(); if (isChecked !== desiredState) { @@ -271,10 +248,7 @@ async function toggleSwitch( await page.waitForTimeout(300); } -async function configureHpaBackoff( - page: import('@playwright/test').Page, - backoff: number -): Promise { +async function configureHpaBackoff(page: import('@playwright/test').Page, backoff: number): Promise { const backoffInput = page.locator('#hpa-backoff'); await expect(backoffInput).toBeVisible({ timeout: 5000 }); await backoffInput.fill(backoff.toString()); @@ -310,9 +284,7 @@ async function closeQuizModalIfPresent(page: import('@playwright/test').Page): P const quizModal = page.locator('#quiz-modal, .quiz-box'); try { if (await quizModal.isVisible({ timeout: 500 })) { - const closeBtn = quizModal - .locator('.draggable-box__close-btn, [class*="close"], button:has-text("×")') - .first(); + const closeBtn = quizModal.locator('.draggable-box__close-btn, [class*="close"], button:has-text("×")').first(); if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) { await closeBtn.click({ force: true }); await page.waitForTimeout(400); @@ -341,9 +313,7 @@ async function openPendingQuiz(page: import('@playwright/test').Page): Promise 3) { await page.evaluate(() => { - const qm = ( - window as unknown as { __quizManager__?: { reopenPendingQuiz: () => void } } - ).__quizManager__; + const qm = (window as unknown as { __quizManager__?: { reopenPendingQuiz: () => void } }).__quizManager__; if (qm) { qm.reopenPendingQuiz(); } @@ -359,11 +329,7 @@ async function openPendingQuiz(page: import('@playwright/test').Page): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario16Objective): Promise { if (objective.type !== 'quiz') { await closeQuizModalIfPresent(page); } @@ -381,6 +347,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': diff --git a/e2e/specs/scenario17-full-completion.spec.ts b/e2e/specs/scenario17-full-completion.spec.ts index e443d491..d9e17bb8 100644 --- a/e2e/specs/scenario17-full-completion.spec.ts +++ b/e2e/specs/scenario17-full-completion.spec.ts @@ -1,17 +1,14 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 17 - "Solar Event": Sun Transit Outage. * * Phase 3 opener. A predicted sun transit (weather event type 'sun-transit') - * raises VT-01's sky noise from T+300s to T+600s with a 12 dB sin^2 peak. + * raises VT-01's sky noise for 300 s with a 12 dB sin^2 peak, starting 20 s + * after 'observe-onset' activates (startAfterObjectiveId). The slow-player + * case lives in scenario17-late-onset.spec.ts. * The operator baselines the link, notifies the customer BEFORE the window, * holds configuration through the peak (demod loses lock and self-recovers), * verifies recovery, and documents predicted-vs-actual. @@ -19,14 +16,9 @@ import { * Spec notes: * - 'wait-sky-noise' steps poll the simulation state directly * (skyNoiseDegradation_dB on VT-01's antenna) instead of sleeping. - * - Total wall-clock is dominated by the transit timeline (~11 min). + * - Total wall-clock is dominated by the transit timeline (~5 min). */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'auto' - | 'wait-sky-noise'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'auto' | 'wait-sky-noise'; interface Scenario17Objective { id: string; @@ -71,22 +63,19 @@ const SCENARIO_17_OBJECTIVES: Scenario17Objective[] = [ id: 'baseline-dashboard', title: 'Pre-Transit Baseline', type: 'quiz', - correctAnswer: - 'Anything abnormal AFTER the window starts will be attributed to the Sun - a fault hiding under the transit would survive unnoticed unless the board was provably clean before', + correctAnswer: 'Anything abnormal after window-open gets blamed on the Sun - a hidden fault only shows if the board was provably clean before', }, { id: 'transit-geometry-quiz', title: 'Transit Geometry', type: 'quiz', - correctAnswer: - 'The Sun (a ~20,000 K noise source at C-band) passes through the antenna main beam behind the satellite - system noise temperature soars and C/N collapses, with the signal itself unchanged', + correctAnswer: 'The Sun (~20,000 K at C-band) passes through the main beam behind the satellite - noise soars, C/N collapses, signal unchanged', }, { id: 'transit-predictability-quiz', title: 'Why It Is Predictable', type: 'quiz', - correctAnswer: - 'Twice a year near the equinoxes - a few minutes a day for several consecutive days, at a time computable years in advance from the station/satellite geometry', + correctAnswer: 'Twice a year near the equinoxes - a few minutes a day for several days, at a time computable years ahead from the geometry', }, { id: 'baseline-rx-tab', @@ -103,15 +92,14 @@ const SCENARIO_17_OBJECTIVES: Scenario17Objective[] = [ id: 'why-not-handover-quiz', title: 'The Handover Question', type: 'quiz', - correctAnswer: - 'The outage is brief, predicted, and SLA-excluded with notice - a handover trades that for two transfer events, and ME-02 inherits its own transit on its own schedule anyway', + correctAnswer: 'Brief, predicted, SLA-excluded with notice - a handover costs two transfer events, and ME-02 gets its own transit anyway', }, { id: 'notify-customer-quiz', title: 'Pre-Event Notification', type: 'quiz', correctAnswer: - 'Predicted solar transit on TIDEMARK-1 from Vermont, window and peak times attached; expect degraded margin and a possible 1-3 minute carrier interruption near peak; service recovers without intervention; this message constitutes SLA advance notice.', + 'Predicted solar transit on TIDEMARK-1, window and peak times attached; possible 1-3 minute carrier loss near peak, self-recovering; this is SLA advance notice.', }, // ============================================================ @@ -121,8 +109,7 @@ const SCENARIO_17_OBJECTIVES: Scenario17Objective[] = [ id: 'observe-onset-quiz', title: 'Confirm Predicted Onset (quiz)', type: 'quiz', - correctAnswer: - 'Confidence this is the predicted transit and not a coincidental fault - the alarm tracking the prediction sheet IS the diagnosis', + correctAnswer: 'Confidence this is the predicted transit, not a coincidental fault - the alarm tracking the sheet IS the diagnosis', }, { id: 'observe-onset-wait', @@ -144,8 +131,7 @@ const SCENARIO_17_OBJECTIVES: Scenario17Objective[] = [ id: 'peak-behavior-quiz', title: 'What the Peak Looks Like', type: 'quiz', - correctAnswer: - 'The downlink is buried in solar noise at OUR antenna only - the satellite still hears our uplink perfectly, and the demod will relock on its own as the Sun moves off boresight', + correctAnswer: 'The downlink is buried in solar noise at OUR antenna only - the uplink is fine, and the demod relocks on its own as the Sun moves off', }, // ============================================================ @@ -168,29 +154,26 @@ const SCENARIO_17_OBJECTIVES: Scenario17Objective[] = [ id: 'post-event-sweep', title: 'Post-Event Alarm Sweep', type: 'quiz', - correctAnswer: - 'Any alarm that survived the window - the transit excuses exactly five minutes of sky noise and nothing else', + correctAnswer: 'Any alarm that survived the window - the transit excuses exactly five minutes of sky noise and nothing else', }, { id: 'marcus-confirm', title: 'Spacecraft-Side Confirmation', type: 'quiz', - correctAnswer: - 'Nothing abnormal on the spacecraft - our uplink steady throughout, vehicle telemetry nominal; the event existed only at our antenna', + correctAnswer: 'Nothing abnormal on the spacecraft - our uplink steady, vehicle telemetry nominal; the event existed only at our antenna', }, { id: 'document-impact', title: 'Impact Documentation', type: 'quiz', - correctAnswer: - 'Predicted vs actual window times, peak degradation observed, carrier lock-loss duration, notification timestamp (pre-window), and customer impact statement', + correctAnswer: 'Predicted vs actual window times, peak degradation, lock-loss duration, notification timestamp (pre-window), customer impact statement', }, { id: 'log-shift-summary', title: 'Log the Event', type: 'quiz', correctAnswer: - 'Predicted solar transit TM-1/VT-01 executed per SOP-SX-001. Customer notified pre-window. Peak ~12 dB sky noise, brief demod loss near peak, self-recovered to baseline. No operator intervention, no residual alarms. Day 2 of 4 in this transit series - next window tomorrow, ~4 minutes earlier.', + 'TM-1 transit per SOP-SX-001, customer notified pre-window. Peak ~12 dB, brief demod loss, self-recovered, no residual alarms. Next window tomorrow, ~4 min earlier.', }, ]; @@ -199,11 +182,7 @@ const SCENARIO_17_OBJECTIVES: Scenario17Objective[] = [ // ============================================================ /** Poll VT-01's antenna sky-noise state until the threshold is met. */ -async function waitForSkyNoise( - page: import('@playwright/test').Page, - condition: { above?: number; below?: number }, - timeoutSeconds: number -): Promise { +async function waitForSkyNoise(page: import('@playwright/test').Page, condition: { above?: number; below?: number }, timeoutSeconds: number): Promise { await page.waitForFunction( (cond) => { const w = window as unknown as { @@ -216,7 +195,7 @@ async function waitForSkyNoise( }; }; }; - const gs = w.signalRange?.simulationManager?.groundStations?.find(g => g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); const sky = gs?.antennas?.[0]?.state?.skyNoiseDegradation_dB ?? 0; if (cond.above !== undefined && sky <= cond.above) return false; if (cond.below !== undefined && sky >= cond.below) return false; @@ -229,11 +208,7 @@ async function waitForSkyNoise( await page.waitForTimeout(2500); } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario17Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario17Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -246,6 +221,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'wait-sky-noise': diff --git a/e2e/specs/scenario17-late-onset.spec.ts b/e2e/specs/scenario17-late-onset.spec.ts new file mode 100644 index 00000000..37af6039 --- /dev/null +++ b/e2e/specs/scenario17-late-onset.spec.ts @@ -0,0 +1,171 @@ +import { expect, type Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForObjectiveComplete } from '../utils/ham-sdr-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Scenario 17 - late-player regression for the sun transit onset. + * + * Bug (reported 2026-09-25): the transit was scheduled T+300..600 s from + * mission start. A player who reached 'Confirm Predicted Onset' after ~T+560 s + * found the transit already over, so 'Sky Noise Rising (transit underway)' + * could never tick (sky noise stayed 0, Sky Temp read 10 K). + * + * Fix: the weather event is anchored to the objective + * (startAfterObjectiveId: 'observe-onset', 20 s after it activates), and the + * Sky Temp readout includes the transit's noise. + * + * This spec plays a slow operator: it parks on 'Pre-Event Notification' and + * jumps the scenario clock through the entire old window (window.advanceClock + * moves every mission-elapsed schedule but not objective countdowns). + * - BEFORE the onset objective: the old peak time and the old window's end + * both pass with zero sky noise and no active weather event. (Old engine: + * ~12 dB at T+450 s - this test fails.) + * - AFTER the onset objective activates: the transit arrives on cue, the + * checklist condition ticks, the objective completes, and Sky Temp climbs + * far above the clear-sky 10 K. (Old engine: sky noise stays 0 - fails.) + */ + +type SimWindow = { + advanceClock?: (ms: number) => void; + missionElapsedMs?: () => number; + signalRange?: { + simulationManager?: { + groundStations?: Array<{ state?: { id?: string }; antennas?: Array<{ state?: { skyNoiseDegradation_dB?: number } }> }>; + objectivesManager?: { getObjectiveState: (id: string) => { isActive: boolean; isCompleted: boolean } | undefined }; + }; + }; +}; + +async function readSkyNoiseDb(page: Page): Promise { + return page.evaluate(() => { + const w = window as unknown as SimWindow; + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); + return gs?.antennas?.[0]?.state?.skyNoiseDegradation_dB ?? 0; + }); +} + +async function readObjective(page: Page, id: string): Promise<{ isActive: boolean; isCompleted: boolean } | null> { + return page.evaluate((objectiveId) => { + const s = (window as unknown as SimWindow).signalRange?.simulationManager?.objectivesManager?.getObjectiveState(objectiveId); + return s ? { isActive: s.isActive, isCompleted: s.isCompleted } : null; + }, id); +} + +/** Jump the scenario clock to an absolute mission-elapsed second (no-op if past it). */ +async function advanceMissionTo(page: Page, targetSeconds: number): Promise { + await page.waitForFunction(() => { + const w = window as unknown as SimWindow; + return typeof w.advanceClock === 'function' && typeof w.missionElapsedMs === 'function'; + }); + const elapsedMs = await page.evaluate((targetMs) => { + const w = window as unknown as SimWindow; + const delta = targetMs - w.missionElapsedMs!(); + if (delta > 0) w.advanceClock!(delta); + return w.missionElapsedMs!(); + }, targetSeconds * 1000); + // Let the weather manager and objectives tick over the jump + await page.waitForTimeout(3000); + // Whole ms: the skip offset accumulates float error (720 s reads 719.9999...) + return Math.round(elapsedMs) / 1000; +} + +async function answerQuiz(page: Page, answer: string): Promise { + await waitForQuizToAppear(page); + await answerQuizByText(page, answer); + await dismissDialogIfPresent(page); +} + +test.describe('Scenario 17 Late Onset Regression', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let missionControlPage: MissionControlPage; + let context: import('@playwright/test').BrowserContext; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControlPage = new MissionControlPage(page); + await missionControlPage.gotoScenario('nats', 'nats-scenario17'); + await waitForSimulationReady(page); + await missionControlPage.dismissDialogIfPresent(); + await missionControlPage.openMissionBrief(); + await missionControlPage.closeMissionBrief(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(90_000); + }); + + test('play the pre-window objectives up to Pre-Event Notification', async () => { + await answerQuiz(page, 'Acknowledged - prediction sheet reviewed, pre-event checklist starting now.'); + await missionControlPage.selectGroundStation('VT-01'); + + await missionControlPage.selectTab('dashboard'); + await answerQuiz(page, 'Anything abnormal after window-open gets blamed on the Sun - a hidden fault only shows if the board was provably clean before'); + await answerQuiz(page, 'The Sun (~20,000 K at C-band) passes through the main beam behind the satellite - noise soars, C/N collapses, signal unchanged'); + await answerQuiz(page, 'Twice a year near the equinoxes - a few minutes a day for several days, at a time computable years ahead from the geometry'); + + await missionControlPage.selectTab('rx-analysis'); + await waitForObjectiveComplete(missionControlPage, 'Baseline RX Snapshot'); + await answerQuiz(page, 'Brief, predicted, SLA-excluded with notice - a handover costs two transfer events, and ME-02 gets its own transit anyway'); + + // Parked on the notice quiz: onset objective not yet up + await waitForQuizToAppear(page); + expect(await readObjective(page, 'notify-customer-quiz')).toEqual({ isActive: true, isCompleted: false }); + expect(await readObjective(page, 'observe-onset')).toEqual({ isActive: false, isCompleted: false }); + }); + + test('BEFORE onset objective: no transit through the old T+300..600 s window', async () => { + // The old schedule's peak (T+450 s: ~12 dB on the unfixed engine) + const atOldPeak = await advanceMissionTo(page, 450); + expect(atOldPeak).toBeGreaterThanOrEqual(450); + expect(await readSkyNoiseDb(page)).toBe(0); + + // Past the old window entirely - where the reported players were stuck + const pastOldWindow = await advanceMissionTo(page, 720); + expect(pastOldWindow).toBeGreaterThanOrEqual(720); + expect(await readSkyNoiseDb(page)).toBe(0); + expect(await readObjective(page, 'observe-onset')).toEqual({ isActive: false, isCompleted: false }); + }); + + test('AFTER onset objective activates: transit arrives on cue and the objective completes', async () => { + test.setTimeout(180_000); + + await answerQuiz( + page, + 'Predicted solar transit on TIDEMARK-1, window and peak times attached; possible 1-3 minute carrier loss near peak, self-recovering; this is SLA advance notice.' + ); + await expect.poll(async () => (await readObjective(page, 'observe-onset'))?.isActive, { timeout: 15_000 }).toBe(true); + + await answerQuiz(page, 'Confidence this is the predicted transit, not a coincidental fault - the alarm tracking the sheet IS the diagnosis'); + + // Anchor + 20 s start, >2 dB ~40 s later: about a minute of run time + await expect.poll(() => readSkyNoiseDb(page), { timeout: 120_000, intervals: [1000] }).toBeGreaterThan(2); + + // Checklist first (DOM), then engine state + await waitForObjectiveComplete(missionControlPage, 'Confirm Predicted Onset', 30_000); + expect((await readObjective(page, 'observe-onset'))?.isCompleted).toBe(true); + expect((await readObjective(page, 'ride-through-peak'))?.isActive).toBe(true); + }); + + test('AFTER: Sky Temp readout reflects the transit instead of clear sky', async () => { + await missionControlPage.selectTab('acu-control'); + const skyTemp = page.locator('[id$="rf-metric-sky-temp"]:visible').first(); + + // Clear sky is ~10 K; >2 dB of sun noise at 290 K is >170 K + await expect.poll(async () => parseFloat(((await skyTemp.textContent()) ?? '').replace(/[^\d.]/g, '')), { timeout: 15_000 }).toBeGreaterThan(150); + }); +}); diff --git a/e2e/specs/scenario18-full-completion.spec.ts b/e2e/specs/scenario18-full-completion.spec.ts index d79f36e7..abe99fbe 100644 --- a/e2e/specs/scenario18-full-completion.spec.ts +++ b/e2e/specs/scenario18-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 18 - "Satellite Anomaly": TIDEMARK-2 Station-Keeping Drift. @@ -22,12 +17,7 @@ import { * - 'set-tracking-mode': Step-track toggle (rides program-track) * - 'auto': Simulation-satisfied condition (maintain-duration holds) */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'set-tracking-mode' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'set-tracking-mode' | 'auto'; interface Scenario18Objective { id: string; @@ -45,8 +35,7 @@ const SCENARIO_18_OBJECTIVES: Scenario18Objective[] = [ id: 'review-mission-brief', title: 'Review Anomaly Brief', type: 'quiz', - correctAnswer: - 'Keep the link alive and feed Halifax ground observations - the vehicle is theirs, the lock is mine', + correctAnswer: 'Keep the link alive and feed Halifax ground observations - the vehicle is theirs, the lock is mine', }, { id: 'select-maine-station', @@ -66,15 +55,13 @@ const SCENARIO_18_OBJECTIVES: Scenario18Objective[] = [ id: 'dashboard-baseline', title: 'Station Health vs Link Health', type: 'quiz', - correctAnswer: - 'The ground segment is healthy - the degradation is on the space side or in the geometry, which matches Halifax\'s report exactly', + correctAnswer: "The ground segment is healthy - the degradation is on the space side or in the geometry, matching Halifax's report", }, { id: 'station-keeping-quiz', title: 'What Died on the Spacecraft', type: 'quiz', - correctAnswer: - 'Residual inclination accumulates - the bird traces a daily figure-8 in az/el that grows over weeks, and the published ephemeris becomes progressively more wrong', + correctAnswer: 'Residual inclination builds - the bird traces a daily figure-8 in az/el that grows over weeks, and the ephemeris gets steadily more wrong', }, { id: 'read-program-track-decay-tab', @@ -86,15 +73,13 @@ const SCENARIO_18_OBJECTIVES: Scenario18Objective[] = [ id: 'read-program-track-decay', title: 'Read the Program-Track Decay', type: 'quiz', - correctAnswer: - 'Program-track IS on target - the ephemeris target. The satellite is somewhere else, and the gap between prediction and reality is being paid in pattern loss', + correctAnswer: 'Program-track IS on the ephemeris target - the satellite is somewhere else, and the gap between prediction and reality is paid in pattern loss', }, { id: 'beamwidth-risk-quiz', title: 'How Long Until It Falls Off', type: 'quiz', - correctAnswer: - 'Already lost - the excursion is several beamwidths and only gets worse; any fix based on following the ephemeris fails until Halifax publishes a corrected one', + correctAnswer: 'Already lost - the excursion is several beamwidths and growing; any fix that follows the ephemeris fails until Halifax publishes a corrected one', }, // PHASE 2: TRANSITION TO STEP-TRACK @@ -126,8 +111,7 @@ const SCENARIO_18_OBJECTIVES: Scenario18Objective[] = [ id: 'no-manual-chase-quiz', title: 'Why Not Fly It By Hand', type: 'quiz', - correctAnswer: - 'A human chases where the bird WAS; the loop tracks where it IS - manual nudges add pointing error between corrections, fatigue guarantees a missed one, and a bad nudge can drop the beacon entirely', + correctAnswer: 'A human chases where the bird WAS; the loop tracks where it IS - nudges add error between corrections, and a bad nudge can drop the beacon', }, // PHASE 3: HOLD AND REPORT @@ -141,29 +125,27 @@ const SCENARIO_18_OBJECTIVES: Scenario18Objective[] = [ id: 'ground-observations-quiz', title: 'Feed the Vehicle Team', type: 'quiz', - correctAnswer: - 'Timestamped step-track pointing history - the dish is physically following the satellite, so its az/el trace IS an independent measurement of the actual orbit', + correctAnswer: 'Timestamped step-track pointing history - the dish is physically following the satellite, so its az/el trace is a measurement of the actual orbit', }, { id: 'impact-assessment-quiz', title: 'Customer Impact Posture', type: 'quiz', correctAnswer: - 'No current impact; service nominal under contingency tracking. Elevated risk posture while the vehicle anomaly is open - next decision points are loss of step-track margin or a vehicle-status change from Halifax', + 'No current impact; service nominal under step-track. Elevated risk while the anomaly is open; decision points are step-track margin loss or a Halifax status change', }, { id: 'escalation-boundary-quiz', title: 'Escalation Tripwires', type: 'quiz', - correctAnswer: - 'Step-track losing the beacon, C/N trending below demod threshold despite good tracking, or Halifax declaring the vehicle unsafe - anything where keeping the link stops being possible or stops being wise', + correctAnswer: 'Step-track losing the beacon, C/N below demod threshold despite good tracking, or Halifax declaring the vehicle unsafe', }, { id: 'log-shift-summary', title: 'Log the Anomaly Response', type: 'quiz', correctAnswer: - 'TM-2 vehicle anomaly (Halifax NOC ref): N-S station-keeping suspended, ephemeris stale. ME-02 transitioned to step-track 0935, beacon and carrier recovered, no customer impact. Pointing history streaming to Halifax. Tripwires: step-track margin, vehicle status change. Anomaly OPEN.', + 'TM-2 vehicle anomaly (Halifax ref): N-S burns suspended, ephemeris stale. ME-02 step-track from 0935, carrier recovered, no impact. Pointing history to Halifax. Anomaly OPEN.', }, ]; @@ -172,10 +154,7 @@ const SCENARIO_18_OBJECTIVES: Scenario18Objective[] = [ // ============================================================ /** Step-track is a toggle riding program-track (same pattern as S6/S10). */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { if (trackingMode === 'step-track') { const stepTrackToggle = page.locator('input[id$="step-track-toggle"]'); await expect(stepTrackToggle).toBeVisible({ timeout: 5000 }); @@ -194,11 +173,7 @@ async function setTrackingMode( await page.waitForTimeout(300); } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario18Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario18Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -211,6 +186,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'set-tracking-mode': diff --git a/e2e/specs/scenario19-full-completion.spec.ts b/e2e/specs/scenario19-full-completion.spec.ts index 681e1b54..c2382cbd 100644 --- a/e2e/specs/scenario19-full-completion.spec.ts +++ b/e2e/specs/scenario19-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 19 - "Train the New Hire": Producing the Quick-Reference Card. @@ -16,15 +11,7 @@ import { * beacon tune, step-track, verify) interleaved with the card quizzes, then * verifies the Working Document panel actually contains the card lines. */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'repoint-program-track' - | 'configure-speca' - | 'set-step-track' - | 'verify-working-doc' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'repoint-program-track' | 'configure-speca' | 'set-step-track' | 'verify-working-doc' | 'auto'; interface Scenario19Objective { id: string; @@ -53,10 +40,9 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ }, { id: 'card-scope-quiz', - title: 'Set the Card\'s Scope', + title: "Set the Card's Scope", type: 'quiz', - correctAnswer: - 'The numbers someone needs under pressure plus the mistakes with the highest local base rate - one page, taped to the console; the procedure itself stays in the SOP', + correctAnswer: 'The numbers needed under pressure plus the highest-base-rate local mistakes - one page; the procedure stays in the SOP', }, // PHASE 1: ACQUIRE @@ -76,8 +62,7 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ id: 'acquire-callout-quiz', title: 'Card Line: Acquisition', type: 'quiz', - correctAnswer: - 'Program-track FIRST - it puts you inside beacon capture range. Nominal: Az 190, El 32, but the bird rides a ±3° figure-8', + correctAnswer: 'Program-track FIRST - it puts you inside beacon capture range. Nominal Az 190, El 32, but the bird rides a ±3° figure-8', }, { id: 'tune-beacon-tab', @@ -95,15 +80,13 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ id: 'beacon-formula-quiz', title: 'Card Line: the Beacon Number', type: 'quiz', - correctAnswer: - 'Beacon IF = LO − RF = 5250 − 4165 = 1085 MHz. Weak CW (aging bird) - use a narrow span, ~2 kHz', + correctAnswer: 'Beacon IF = LO − RF = 5250 − 4165 = 1085 MHz. Weak CW (aging bird) - use a narrow span, ~2 kHz', }, { id: 'mistake-lo-quiz', title: 'Card Line: First Watch-Out', type: 'quiz', - correctAnswer: - 'Beacon "missing" at 1085? Check the LNB LO = 5250 - an operator fresh from Maine duty once hunted a healthy beacon for an hour with the LO still at Maine\'s 6080 default', + correctAnswer: 'Beacon "missing" at 1085? Check the LNB LO = 5250 - an operator fresh from Maine duty once hunted a healthy beacon for an hour', }, // PHASE 2: TRACK @@ -122,8 +105,7 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ id: 'step-track-rule-quiz', title: 'Card Line: the Engagement Rule', type: 'quiz', - correctAnswer: - 'Step-track RIDES program-track - engage it as an optimization on an acquired beacon, never from MANUAL (the loop needs a beacon to optimize)', + correctAnswer: 'Step-track RIDES program-track - engage it on an acquired beacon, never from MANUAL (the loop needs a beacon to optimize)', }, { id: 'hold-beacon', @@ -135,15 +117,13 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ id: 'healthy-track-quiz', title: 'Card Line: What Healthy Looks Like', type: 'quiz', - correctAnswer: - 'Healthy = beacon C/N steady at its peak while Az/El visibly wander the figure-8. Moving dish + flat C/N is the loop WORKING, not a fault', + correctAnswer: 'Healthy = beacon C/N steady at its peak while Az/El wander the figure-8. Moving dish + flat C/N is the loop WORKING', }, { id: 'mistake-chase-quiz', title: 'Card Line: Second Watch-Out', type: 'quiz', - correctAnswer: - 'C/N sagging mid-track? Verify step-track is still ON before touching the axes - hand-chasing the figure-8 is a losing game an operator here once played for twenty minutes', + correctAnswer: 'C/N sagging mid-track? Verify step-track is still ON before touching the axes - hand-chasing the figure-8 is a losing game', }, // PHASE 3: VERIFY @@ -163,26 +143,23 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ id: 'verify-chain-quiz', title: 'Card Line: the Proof Chain', type: 'quiz', - correctAnswer: - 'Proof chain, in order: beacon at 1085 (pointing + LO) → RX locked at 1422 MHz / 24 MHz (carrier) → C/N ≥ 8 (margin). Each link proves something the others don\'t', + correctAnswer: 'Proof chain, in order: beacon at 1085 (pointing + LO) → RX locked at 1422 / 24 MHz (carrier) → C/N ≥ 8 dB (margin)', }, { id: 'mistake-span-quiz', title: 'Card Line: Third Watch-Out', type: 'quiz', - correctAnswer: - 'Carrier "gone" but beacon fine? Widen the span - a 24 MHz carrier is invisible at the 2 kHz span you used for the beacon. (Operator here once declared an outage over this)', + correctAnswer: 'Carrier "gone" but beacon fine? Widen the span - a 24 MHz carrier is invisible at the 2 kHz span you used for the beacon', }, { id: 'tx-numbers-quiz', title: 'Card Line: the TX Number', type: 'quiz', - correctAnswer: - 'TX IF = BUC LO − uplink RF = 7500 − 6053 = 1447 MHz. AURORA\'s chain uses BUC LO 7500 - NOT the TIDEMARK 7000', + correctAnswer: "TX IF = BUC LO − uplink RF = 7500 − 6053 = 1447 MHz. AURORA's chain uses BUC LO 7500 - NOT the TIDEMARK 7000", }, // Verify the card BEFORE the final quizzes (the Mission Complete modal - // overlays the sidebar once the last objective lands) + // overlays the sidebar once the last objective completes) { id: 'verify-working-doc', title: 'Working Document: card accumulated all lines', @@ -194,15 +171,13 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ id: 'card-review-quiz', title: 'Editorial Review', type: 'quiz', - correctAnswer: - 'Card space is the reader\'s attention under pressure - every line they scan past to find the one they need is time on a degraded link; the card earns trust by containing only what earns its place', + correctAnswer: "Card space is the reader's attention under pressure - every line they scan past is time lost on a degraded link", }, { id: 'log-handoff', title: 'Hand Off the Card', type: 'quiz', - correctAnswer: - 'AURORA-7 quick-reference card complete - built against a live procedure run (acquire, step-track, verify, all green). Sections: Acquire / Track / Verify / Numbers / 3x Watch-Out from station history. Delivered to Dana for the new-hire packet.', + correctAnswer: 'AURORA-7 card complete - built against a live procedure run, all green; delivered to Dana for the new-hire packet', }, ]; @@ -211,10 +186,7 @@ const SCENARIO_19_OBJECTIVES: Scenario19Objective[] = [ // ============================================================ /** Program-track + select satellite + Move to Target, then wait on sim state. */ -async function repointProgramTrack( - page: import('@playwright/test').Page, - satelliteNoradId: string -): Promise { +async function repointProgramTrack(page: import('@playwright/test').Page, satelliteNoradId: string): Promise { const modeButton = page.locator('.btn-tracking[data-mode="program-track"]'); await expect(modeButton).toBeVisible({ timeout: 5000 }); await modeButton.click(); @@ -242,7 +214,7 @@ async function repointProgramTrack( }; }; }; - const gs = w.signalRange?.simulationManager?.groundStations?.find(g => g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); const antennaState = gs?.antennas?.[0]?.state; return antennaState ? antennaState.slewing === false && antennaState.isLocked === true : false; }, @@ -252,10 +224,7 @@ async function repointProgramTrack( await page.waitForTimeout(1500); } -async function configureSpeca( - page: import('@playwright/test').Page, - centerFrequencyMhz: number -): Promise { +async function configureSpeca(page: import('@playwright/test').Page, centerFrequencyMhz: number): Promise { const centerFreqInput = page.locator('#sa-center-freq'); await expect(centerFreqInput).toBeVisible({ timeout: 5000 }); await centerFreqInput.fill(centerFrequencyMhz.toString()); @@ -296,11 +265,7 @@ async function verifyWorkingDocument(page: import('@playwright/test').Page): Pro } } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario19Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario19Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -313,6 +278,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'repoint-program-track': diff --git a/e2e/specs/scenario2-full-completion.spec.ts b/e2e/specs/scenario2-full-completion.spec.ts index 3a27e843..6d2543a2 100644 --- a/e2e/specs/scenario2-full-completion.spec.ts +++ b/e2e/specs/scenario2-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 2 objectives - Scheduled Maintenance: Power Down and Recovery Procedures. @@ -19,14 +14,7 @@ import { * - 'set-tracking-mode': Requires clicking a tracking mode button * - 'configure-lnb': Requires configuring LNB settings */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'auto' - | 'toggle-switch' - | 'set-tracking-mode' - | 'configure-lnb'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'auto' | 'toggle-switch' | 'set-tracking-mode' | 'configure-lnb'; interface Scenario2Objective { id: string; @@ -59,8 +47,7 @@ const SCENARIO_2_OBJECTIVES: Scenario2Objective[] = [ id: 'safety-briefing', title: 'Acknowledge RF Safety Briefing', type: 'quiz', - correctAnswer: - 'I have received and understood the RF safety briefing for today\'s maintenance work.', + correctAnswer: "I have received and understood the RF safety briefing for today's maintenance work.", }, // ============================================================ @@ -288,8 +275,7 @@ const SCENARIO_2_OBJECTIVES: Scenario2Objective[] = [ id: 'final-verification', title: 'Confirm Service Restored', type: 'quiz', - correctAnswer: - 'Shutdown: HPA → BUC → Modem TX → LNB → Antenna. Restore: Antenna → LNB → Modem TX → BUC → HPA', + correctAnswer: 'Shutdown: HPA → BUC → Modem TX → LNB → Antenna. Restore: Antenna → LNB → Modem TX → BUC → HPA', }, ]; @@ -302,11 +288,7 @@ const SCENARIO_2_OBJECTIVES: Scenario2Objective[] = [ * @param switchId The ID of the switch element (without # prefix) * @param targetState true = checked/on, false = unchecked/off */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - targetState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, targetState: boolean): Promise { const switchEl = page.locator(`#${switchId}`); await expect(switchEl).toBeVisible({ timeout: 5000 }); @@ -334,10 +316,7 @@ async function toggleSwitch( * ACU control tab must be active before calling this. * For maintenance mode, also clicks Apply to commit the position change. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { // Find the tracking mode button with data-mode attribute const modeButton = page.locator(`.btn-tracking[data-mode="${trackingMode}"]`); await expect(modeButton).toBeVisible({ timeout: 5000 }); @@ -383,10 +362,7 @@ async function selectSatelliteAndMove(page: import('@playwright/test').Page): Pr * Wait for antenna movement to complete by monitoring position changes. * The antenna moves at ~2-5 deg/sec, so large movements take several seconds. */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 60000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 60000): Promise { const startTime = Date.now(); let lastPosition = ''; let stableCount = 0; @@ -400,8 +376,7 @@ async function waitForAntennaMovement( // Get current elevation from the fine-adjust control display // The Elevation control has a label and value span - use text matching // Try multiple selector strategies - let elDisplay = page.locator('.fine-adjust-control', { hasText: 'Elevation' }) - .locator('.fine-adjust-value-active'); + let elDisplay = page.locator('.fine-adjust-control', { hasText: 'Elevation' }).locator('.fine-adjust-value-active'); // Fallback: try finding by ID pattern (contains "el-fine") if ((await elDisplay.count()) === 0) { @@ -438,10 +413,7 @@ async function waitForAntennaMovement( * Configure LNB with specified settings. * Powers on LNB, sets LO frequency and gain, waits for thermal stabilization. */ -async function configureLnb( - page: import('@playwright/test').Page, - config: { loFrequency: number; gain: number } -): Promise { +async function configureLnb(page: import('@playwright/test').Page, config: { loFrequency: number; gain: number }): Promise { // Power on LNB const powerSwitch = page.locator('#lnb-power'); await expect(powerSwitch).toBeVisible({ timeout: 5000 }); @@ -481,11 +453,7 @@ async function configureLnb( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario2Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario2Objective): Promise { switch (objective.type) { case 'quiz': // Wait for quiz to appear and answer it @@ -501,6 +469,10 @@ async function executeObjective( case 'click-tab': // Click on the specified tab using data-tab-id selector await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': @@ -593,12 +565,12 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Review Mission Brief', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'review-mission-brief')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'review-mission-brief')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Acknowledge RF Safety Briefing', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'safety-briefing')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'safety-briefing')!; await executeObjective(page, missionControlPage, objective); }); @@ -607,12 +579,12 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Access Vermont Ground Station', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'select-vermont-station')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'select-vermont-station')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Open TX Chain Tab', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'navigate-tx-chain-shutdown')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'navigate-tx-chain-shutdown')!; await executeObjective(page, missionControlPage, objective); }); @@ -621,22 +593,22 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Verify Current HPA State', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-hpa-initial-state')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-hpa-initial-state')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Disable HPA Output', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'disable-hpa-output')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'disable-hpa-output')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm HPA Output Disabled', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-hpa-disabled-quiz')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-hpa-disabled-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Power Off HPA', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'power-off-hpa')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'power-off-hpa')!; await executeObjective(page, missionControlPage, objective); }); @@ -645,17 +617,17 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Power Off BUC', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'power-off-buc')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'power-off-buc')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm BUC Powered Off', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-buc-powered-off-quiz')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-buc-powered-off-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Stop Modem Transmission', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'stop-modem-transmitting')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'stop-modem-transmitting')!; await executeObjective(page, missionControlPage, objective); }); @@ -664,17 +636,17 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Open RX Analysis Tab (Shutdown)', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'navigate-rx-analysis-shutdown')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'navigate-rx-analysis-shutdown')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Power Down LNB', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'power-down-lnb')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'power-down-lnb')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm RF Chain Shutdown', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-rf-chain-shutdown-quiz')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-rf-chain-shutdown-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -683,19 +655,19 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Open ACU Control Tab', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'navigate-acu-control-maintenance')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'navigate-acu-control-maintenance')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Move Antenna to Maintenance Position', async () => { // Antenna movement can take up to 90 seconds test.setTimeout(120000); - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'antenna-to-maintenance')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'antenna-to-maintenance')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm Maintenance Position', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-maintenance-position-quiz')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-maintenance-position-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -704,7 +676,7 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Maintenance Window Complete', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'maintenance-complete')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'maintenance-complete')!; await executeObjective(page, missionControlPage, objective); }); @@ -715,7 +687,7 @@ test.describe('Scenario 2 Full Completion', () => { test('Objective: Repoint Antenna at TIDEMARK-1', async () => { // Antenna movement can take up to 90 seconds test.setTimeout(120000); - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'repoint-antenna')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'repoint-antenna')!; await executeObjective(page, missionControlPage, objective); }); @@ -724,17 +696,17 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Open RX Analysis Tab (Restore)', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'navigate-rx-analysis-restore')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'navigate-rx-analysis-restore')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Restore LNB', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'power-up-lnb')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'power-up-lnb')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify LNB Restoration', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-lnb-restored-quiz')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-lnb-restored-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -743,12 +715,12 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Verify Beacon Reception', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-beacon')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-beacon')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm Beacon Analysis', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'verify-beacon-quiz')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'verify-beacon-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -757,17 +729,17 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Open TX Chain Tab (Restore)', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'navigate-tx-chain-restore')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'navigate-tx-chain-restore')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Start Modem Transmission', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'start-modem-transmitting')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'start-modem-transmitting')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Power On BUC', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'power-on-buc')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'power-on-buc')!; await executeObjective(page, missionControlPage, objective); }); @@ -776,12 +748,12 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Power On HPA', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'power-on-hpa')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'power-on-hpa')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Enable HPA Output', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'enable-hpa-output')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'enable-hpa-output')!; await executeObjective(page, missionControlPage, objective); }); @@ -790,7 +762,7 @@ test.describe('Scenario 2 Full Completion', () => { // ============================================================ test('Objective: Confirm Service Restored', async () => { - const objective = SCENARIO_2_OBJECTIVES.find(o => o.id === 'final-verification')!; + const objective = SCENARIO_2_OBJECTIVES.find((o) => o.id === 'final-verification')!; await executeObjective(page, missionControlPage, objective); }); diff --git a/e2e/specs/scenario20-full-completion.spec.ts b/e2e/specs/scenario20-full-completion.spec.ts index 1141c164..63e3fdf3 100644 --- a/e2e/specs/scenario20-full-completion.spec.ts +++ b/e2e/specs/scenario20-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 20 - "Dual Outage": Concurrent Site Loss, Prioritized Recovery. @@ -16,14 +11,7 @@ import { * restore back-off -> re-enable -> verify VT melt -> customer comms + * adversarial rule-out + log. */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'toggle-switch' - | 'configure-hpa-backoff' - | 'wait-ice-melt' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'toggle-switch' | 'configure-hpa-backoff' | 'wait-ice-melt' | 'auto'; interface Scenario20Objective { id: string; @@ -43,8 +31,7 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ id: 'review-mission-brief', title: 'Review the Incident Brief', type: 'quiz', - correctAnswer: - 'Read both boards before fixing either - triage is a decision about order, and order needs the whole picture', + correctAnswer: 'Read both boards before fixing either - triage is a decision about order, and order needs the whole picture', }, // PHASE 1: VERMONT - START THE SLOW RECOVERY @@ -64,8 +51,7 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ id: 'vt-read-board', title: 'Read the Vermont Board', type: 'quiz', - correctAnswer: - 'The feed heater is OFF - it should have been running before the front arrived; ice is the consequence, the cold heater is the fault', + correctAnswer: 'The feed heater is OFF - it should have been running before the front; ice is the consequence, the cold heater is the fault', }, { id: 'vt-enable-heater-tab', @@ -98,22 +84,19 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ id: 'me-read-board', title: 'Read the Maine Board', type: 'quiz', - correctAnswer: - 'One fault, two symptoms: back-off at 1 dB drives the amplifier near saturation - IMD rises (overdrive alarm) and the output stage dissipates harder (thermal alarm). Fix the back-off and both clear', + correctAnswer: 'One fault, two symptoms: 1 dB back-off drives the HPA near saturation - IMD rises (overdrive) and dissipation climbs (thermal)', }, { id: 'coincidence-quiz', title: 'Coincidence or Attack?', type: 'quiz', - correctAnswer: - 'Hold it open and collect the rule-out evidence as you work: independent causes that each fully explain their own site, no unexplained RF on either spectrum. Answer it with evidence after the fixes, not with a shrug before them', + correctAnswer: 'Hold it open and collect rule-out evidence as you work: independent causes for each site, no unexplained RF on either spectrum', }, { id: 'triage-order-quiz', title: 'Defend the Order', type: 'quiz', - correctAnswer: - 'VT\'s recovery is slow but starts with one switch - starting it first costs ME nothing. ME\'s fault is actively dangerous (spectrum pollution + amplifier stress) and deterministic to fix, so it gets full attention immediately after', + correctAnswer: "VT's fix is one switch that runs unattended; ME's fault is dangerous (spectrum pollution + HPA stress) and needs full attention", }, { id: 'me-tx-tab', @@ -145,8 +128,7 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ id: 'me-verify-quiz', title: 'Confirm Both Symptoms Cleared', type: 'quiz', - correctAnswer: - 'It clears on its own - output power dropped ~9 dB, so the output stage dissipates a fraction of the heat; the temperature falls with the dissipation that caused it', + correctAnswer: 'It clears on its own - output power dropped ~9 dB, so the output stage dissipates a fraction of the heat and cools', }, // PHASE 3: VERIFY VERMONT @@ -165,8 +147,7 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ id: 'storm-steady-state-quiz', title: 'Steady State in the Storm', type: 'quiz', - correctAnswer: - 'Nothing new - the heater holds ice at bay as fast as it forms; the steady state is heater ON plus periodic margin checks until the front clears', + correctAnswer: 'Nothing new - the heater holds ice at bay as fast as it forms; heater ON plus periodic margin checks until the front clears', }, // PHASE 4: CUSTOMER, EVIDENCE, LOG @@ -174,22 +155,19 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ id: 'james-comms-quiz', title: 'Call James Back', type: 'quiz', - correctAnswer: - 'Both trunks restored: Vermont was storm icing (heater now running, holding), Maine was an amplifier config fault (corrected, verified). Causes independent and fully explained - the simultaneity was coincidence, and here is why we are confident.', + correctAnswer: 'Both trunks up: Vermont was storm icing (heater on), Maine an amplifier config fault (fixed) - coincidence, and here is why', }, { id: 'adversarial-ruleout-quiz', - title: 'Close the Question Honestly', + title: 'Close the Question Cleanly', type: 'quiz', - correctAnswer: - 'VT degradation tracked the storm exactly (radar + precip sensor agree, heater-on fixed it); ME fault was a config value with mundane history; no unexplained signals on either spectrum; both recoveries behaved as their diagnoses predicted', + correctAnswer: 'VT tracked the storm (radar + precip agree, heater fixed it); ME was a config value with mundane history; both spectra clean', }, { id: 'log-dual-outage', title: 'Log the Dual Recovery', type: 'quiz', - correctAnswer: - 'Concurrent site degradation 0712: VT-01 feed icing (heater off ahead of front - corrected 0716, melt verified) | ME-02 HPA back-off drift to 1 dB (output disabled, 10 dB restored, re-enabled clean by 0734). Causes independent - rule-out documented. Customers notified. Heater discipline flagged for shift-change checklist.', + correctAnswer: 'Dual degradation 0712: VT-01 icing (heater on 0716, melted) | ME-02 HPA back-off 1 dB (10 dB restored 0734); causes independent', }, ]; @@ -197,17 +175,13 @@ const SCENARIO_20_OBJECTIVES: Scenario20Objective[] = [ // Helper Functions // ============================================================ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - desiredState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, desiredState: boolean): Promise { let switchEl = page.locator(`#${switchId}`); if ((await switchEl.count()) === 0) { switchEl = page.locator(`[id$="${switchId}"]`); } await expect(switchEl.first()).toBeVisible({ timeout: 5000 }); - // Let the adapter's throttled DOM sync land before reading + // Let the adapter's throttled DOM sync settle before reading await page.waitForTimeout(1200); const isChecked = await switchEl.first().isChecked(); if (isChecked !== desiredState) { @@ -221,10 +195,7 @@ async function toggleSwitch( await page.waitForTimeout(300); } -async function configureHpaBackoff( - page: import('@playwright/test').Page, - backoff: number -): Promise { +async function configureHpaBackoff(page: import('@playwright/test').Page, backoff: number): Promise { const backoffInput = page.locator('#hpa-backoff'); await expect(backoffInput).toBeVisible({ timeout: 5000 }); await backoffInput.fill(backoff.toString()); @@ -251,7 +222,7 @@ async function waitForIceMelt(page: import('@playwright/test').Page): Promise g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); const ice = gs?.antennas?.[0]?.state?.iceAccumulation_dB ?? 99; return ice < 2; }, @@ -262,11 +233,7 @@ async function waitForIceMelt(page: import('@playwright/test').Page): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario20Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -279,6 +246,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': diff --git a/e2e/specs/scenario21-full-completion.spec.ts b/e2e/specs/scenario21-full-completion.spec.ts index a0a43c25..645dc4f7 100644 --- a/e2e/specs/scenario21-full-completion.spec.ts +++ b/e2e/specs/scenario21-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 21 - "Hostile RF": Suspected Intentional Interference. @@ -21,13 +16,7 @@ import { * crypto checks (ACTIVE/Valid by default), the notch filter, and a * post-notch receiver-lock check (the carrier is ~15 dB above the jammer). */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'configure-speca' - | 'configure-notch' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'configure-speca' | 'configure-notch' | 'auto'; interface Scenario21Objective { id: string; @@ -46,8 +35,7 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'review-mission-brief', title: 'Review the Interference Flag', type: 'quiz', - correctAnswer: - 'As an interference incident: characterize, discriminate, mitigate, document - and verify the data layer separately. The same evidence resolves an accident or builds the case for deliberate interference', + correctAnswer: 'As an interference incident: characterize, discriminate, mitigate, document - and verify the data layer separately', }, { id: 'select-maine-station', @@ -73,22 +61,19 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'duty-cycle-quiz', title: 'Characterize the Duty Cycle', type: 'quiz', - correctAnswer: - 'Against accident, toward deliberation: natural and accidental sources are continuous or random; a clean repeating on/off cadence on the scale of a minute implies a hand on a switch or an automated transmitter, not weather or a stuck oscillator', + correctAnswer: 'Against accident, toward deliberation: accidents are continuous or random; a clean minute-scale on/off cadence implies a switch', }, { id: 'transponder-vs-local-quiz', title: 'Transponder or Local?', type: 'quiz', - correctAnswer: - 'It is in the UPLINK - the satellite is relaying it to every receiving station, so the source is transmitting at the bird, not a local terrestrial source at Maine. That rules out radar/5G/our own equipment and points at a misaligned or deliberate uplink', + correctAnswer: 'It is in the UPLINK - the satellite relays it to every station, so the source is transmitting at the bird, not local to Maine', }, { id: 'signature-shape-quiz', title: 'Signature vs the Database', type: 'quiz', - correctAnswer: - 'It matches nothing in the coordination database: cross-pol leakage would mirror a known neighbor\'s carrier, an errant uplink would look like a modulated carrier at a coordinated slot - broadband noise shaped to our carrier inside our passband fits neither', + correctAnswer: "It matches nothing in the database: cross-pol would mirror a neighbor's carrier, an errant uplink would sit at a known slot", }, // PHASE 2: DENIAL VS INTRUSION @@ -96,8 +81,7 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'denial-vs-intrusion-quiz', title: 'Denial or Intrusion?', type: 'quiz', - correctAnswer: - 'It is a DENIAL attack on the RF layer - it degrades or blocks the signal but does not touch the data, crypto, or keys. The distinction matters because an intrusion (data-layer compromise) has a different escalation path and you must verify which you are facing', + correctAnswer: 'A DENIAL attack on the RF layer - it blocks the signal but never touches data, crypto, or keys; intrusion escalates differently', }, { id: 'verify-data-layer', @@ -109,8 +93,7 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'data-layer-meaning-quiz', title: 'What Intact Crypto Proves', type: 'quiz', - correctAnswer: - 'Confirms RF-layer denial only: the link is being degraded, not breached. If crypto state changed or keys went invalid/mismatched mid-incident, it would become a security incident with a different escalation (Dana then security officer; nothing holding keys gets power-cycled)', + correctAnswer: 'Confirms RF denial only - degraded, not breached; a crypto change or invalid keys mid-incident flips it to a security incident', }, // PHASE 3: MITIGATION @@ -118,8 +101,7 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'mitigation-choice-quiz', title: 'Choose Mitigation', type: 'quiz', - correctAnswer: - 'Notch filter on the interferer band - excise its energy at the cost of a little SNR in that slice; reversible, customer-preserving, and it does nothing to neighbors', + correctAnswer: 'Notch filter on the interferer band - excise its energy for a little SNR in that slice; reversible and customer-preserving', }, { id: 'configure-notch', @@ -137,8 +119,7 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'no-burn-through-quiz', title: 'Why Not Fight Back', type: 'quiz', - correctAnswer: - 'You cannot out-shout an uplink jammer from the ground without violating coordination and degrading every neighbor on the transponder - a power war escalates the incident, harms bystanders, and still loses. Receive-side mitigation plus regulatory action is the path', + correctAnswer: 'It breaks coordination and degrades every neighbor on the transponder - a power war escalates the incident and still loses', }, // PHASE 4: COORDINATE AND REPORT @@ -146,22 +127,19 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ id: 'regulator-package-quiz', title: 'Build the Regulator Package', type: 'quiz', - correctAnswer: - 'Victim (satellite/transponder/pol/center freq/BW), interferer (center freq, BW, measured duty cycle, first/last seen), spectrum captures at documented settings, cross-station confirmation (transponder vs local), service impact, and mitigation applied - all timestamped', + correctAnswer: 'Victim and interferer parameters (freq, BW, duty cycle), spectrum captures, cross-station confirmation, impact - all timestamped', }, { id: 'attribution-restraint-quiz', title: 'Conclude Carefully', type: 'quiz', - correctAnswer: - '"Signature consistent with deliberate uplink interference" - a confidence-bounded characterization backed by the evidence; attribution and intent are for the regulator to determine, not the ground operator to assert', + correctAnswer: '"Signature consistent with deliberate uplink interference" - confidence-bounded; attribution is for the regulator, not us', }, { id: 'log-incident', title: 'Log the Incident', type: 'quiz', - correctAnswer: - 'TM-2 TP-1 interference: broadband noise ~1470 MHz IF, ~90s/60s duty cycle, confirmed uplink (seen VT-01 + Halifax). Data layer intact (crypto ACTIVE, keys Valid) - RF denial, not intrusion. Notch applied, carrier holding. Signature consistent with deliberate interference - regulator package filed. Monitoring; escalate on any data-layer change.', + correctAnswer: 'TM-2 TP-1 interference: noise ~1470 MHz IF, 90s/60s duty, uplink-confirmed (VT-01 + Halifax); crypto intact - RF denial, notched', }, ]; @@ -169,10 +147,7 @@ const SCENARIO_21_OBJECTIVES: Scenario21Objective[] = [ // Helper Functions // ============================================================ -async function configureSpeca( - page: import('@playwright/test').Page, - config: { centerFrequencyMhz: number; spanMhz: number } -): Promise { +async function configureSpeca(page: import('@playwright/test').Page, config: { centerFrequencyMhz: number; spanMhz: number }): Promise { const centerFreqInput = page.locator('#sa-center-freq'); await expect(centerFreqInput).toBeVisible({ timeout: 5000 }); await centerFreqInput.fill(config.centerFrequencyMhz.toString()); @@ -237,11 +212,7 @@ async function configureNotchFilter( await page.waitForTimeout(500); } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario21Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario21Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -254,6 +225,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'configure-speca': diff --git a/e2e/specs/scenario22-full-completion.spec.ts b/e2e/specs/scenario22-full-completion.spec.ts index 1153a6ea..4624b7e9 100644 --- a/e2e/specs/scenario22-full-completion.spec.ts +++ b/e2e/specs/scenario22-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 22 - "End-of-Life Planning": AURORA-7 Sunset Recommendation. @@ -16,15 +11,7 @@ import { * verifies the report content in the Working Document before the final quizzes * (the Mission Complete modal overlays the sidebar afterward). */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'repoint-program-track' - | 'set-step-track' - | 'configure-speca' - | 'verify-working-doc' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'repoint-program-track' | 'set-step-track' | 'configure-speca' | 'verify-working-doc' | 'auto'; interface Scenario22Objective { id: string; @@ -43,8 +30,7 @@ const SCENARIO_22_OBJECTIVES: Scenario22Objective[] = [ id: 'review-mission-brief', title: 'Review the Tasking', type: 'quiz', - correctAnswer: - 'A defensible recommendation grounded in measured data and an honest trend, with assumptions labeled - not a single date with false precision', + correctAnswer: 'A defensible recommendation grounded in measured data and an honest trend, with assumptions labeled - not a single date', }, { id: 'select-vermont-station', @@ -79,16 +65,15 @@ const SCENARIO_22_OBJECTIVES: Scenario22Objective[] = [ }, { id: 'measure-beacon', - title: 'Measure Today\'s Beacon', + title: "Measure Today's Beacon", type: 'configure-speca', centerFrequencyMhz: 1085, }, { id: 'record-data-point-quiz', - title: 'Report: Today\'s Measurement', + title: "Report: Today's Measurement", type: 'quiz', - correctAnswer: - 'Beacon at -4.0 dB relative to the 24-month reference; step-track held lock at this level; carrier C/N still above demod threshold - measured, not estimated', + correctAnswer: 'Beacon at -4.0 dB relative to the 24-month reference; step-track held lock at this level; carrier C/N above demod threshold', }, // PHASE 2: TREND ANALYSIS @@ -96,22 +81,19 @@ const SCENARIO_22_OBJECTIVES: Scenario22Objective[] = [ id: 'trend-slope-quiz', title: 'Report: the Trend', type: 'quiz', - correctAnswer: - 'Accelerating - the last 6 months lost ~1.6 dB versus ~1.1 dB in the prior 6; a steepening curve, not a straight line, so naive linear extrapolation understates the near-term decline', + correctAnswer: 'Accelerating - the last 6 months lost ~1.6 dB against ~1.3 dB in the 6 before; a steepening curve, not a straight line', }, { id: 'binding-constraint-quiz', title: 'Report: What Fails First', type: 'quiz', - correctAnswer: - 'The beacon getting too weak to step-track. AURORA-7 is inclined, so without a trackable beacon every pass becomes manual figure-8 chasing and service quality collapses - the cliff is tracking, not transponder death', + correctAnswer: 'The beacon getting too weak to step-track - AURORA-7 is inclined, so without it every pass becomes manual figure-8 chasing', }, { id: 'marcus-corroboration-quiz', title: 'Report: Vehicle Corroboration', type: 'quiz', - correctAnswer: - 'It raises confidence and explains the mechanism: ground-measured beacon decline and spacecraft-reported power loss are the same story from two independent vantage points - the trend is real, not an artifact of our station', + correctAnswer: 'It raises confidence: ground-measured beacon decline and spacecraft-reported power loss agree from independent vantage points', }, // PHASE 3: RECOMMENDATION @@ -119,22 +101,19 @@ const SCENARIO_22_OBJECTIVES: Scenario22Objective[] = [ id: 'sunset-window-quiz', title: 'Report: the Recommendation', type: 'quiz', - correctAnswer: - 'Begin customer migration now; target sunset in roughly one to two quarters with a hard review at each monthly data point, and a firm decision trigger when the beacon crosses the step-track floor. A window with tripwires, not a date', + correctAnswer: 'Begin migration now; sunset in one to two quarters, reviewed monthly, with a firm trigger at the step-track floor', }, { id: 'assumptions-quiz', title: 'Report: Label the Assumptions', type: 'quiz', - correctAnswer: - 'The decline continues or steepens (no recovery), no single-event failure intervenes, and tracking - not the payload - is the binding constraint. If any breaks, the window changes', + correctAnswer: 'The decline continues or steepens (no recovery), no single-event failure intervenes, and tracking is the binding constraint', }, { id: 'false-precision-quiz', title: 'Confidence Discipline', type: 'quiz', - correctAnswer: - 'Explicitly: today\'s -4.0 dB and the historical points are measured; the sunset window is a projection from those points under stated assumptions. Label each so the board knows which is which', + correctAnswer: "Explicitly: today's -4.0 dB and the historical points are measured; the sunset window is a projection under stated assumptions", }, // Verify the report BEFORE the final quizzes (modal overlays the sidebar after) @@ -149,15 +128,13 @@ const SCENARIO_22_OBJECTIVES: Scenario22Objective[] = [ id: 'review-report', title: 'Review the Assessment', type: 'quiz', - correctAnswer: - 'It has data, a trend, the binding-constraint risk, a windowed recommendation with tripwires, and labeled assumptions - a board member can read it, understand the basis, and defend the decision to others', + correctAnswer: 'It has data, a trend, the binding-constraint risk, a windowed recommendation with tripwires, and labeled assumptions', }, { id: 'log-delivery', title: 'Deliver and Log', type: 'quiz', - correctAnswer: - 'AURORA-7 EOL assessment delivered to board (Martin). Final data run complete (beacon -4.0 dB, step-track held). Trend accelerating, binding constraint = beacon trackability. Recommendation: begin migration now, sunset ~1-2 quarters, trigger at step-track floor. Assumptions stated. Measured vs projected labeled.', + correctAnswer: 'AURORA-7 EOL assessment to board: beacon -4.0 dB, trend accelerating; migrate now, sunset ~1-2 quarters, trigger at tracking floor', }, ]; @@ -165,10 +142,7 @@ const SCENARIO_22_OBJECTIVES: Scenario22Objective[] = [ // Helper Functions // ============================================================ -async function repointProgramTrack( - page: import('@playwright/test').Page, - satelliteNoradId: string -): Promise { +async function repointProgramTrack(page: import('@playwright/test').Page, satelliteNoradId: string): Promise { const modeButton = page.locator('.btn-tracking[data-mode="program-track"]'); await expect(modeButton).toBeVisible({ timeout: 5000 }); await modeButton.click(); @@ -195,7 +169,7 @@ async function repointProgramTrack( }; }; }; - const gs = w.signalRange?.simulationManager?.groundStations?.find(g => g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); const antennaState = gs?.antennas?.[0]?.state; return antennaState ? antennaState.slewing === false && antennaState.isLocked === true : false; }, @@ -215,10 +189,7 @@ async function setStepTrack(page: import('@playwright/test').Page): Promise { +async function configureSpeca(page: import('@playwright/test').Page, centerFrequencyMhz: number): Promise { const centerFreqInput = page.locator('#sa-center-freq'); await expect(centerFreqInput).toBeVisible({ timeout: 5000 }); await centerFreqInput.fill(centerFrequencyMhz.toString()); @@ -247,11 +218,7 @@ async function verifyWorkingDocument(page: import('@playwright/test').Page): Pro } } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario22Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario22Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -264,6 +231,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'repoint-program-track': diff --git a/e2e/specs/scenario23-full-completion.spec.ts b/e2e/specs/scenario23-full-completion.spec.ts index 8c841468..26233174 100644 --- a/e2e/specs/scenario23-full-completion.spec.ts +++ b/e2e/specs/scenario23-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 23 - "Emergency Bypass": Manual Operations During Automation Failure. @@ -18,14 +13,7 @@ import { * Spec also asserts the ACU-fault UI gating: program-track button disabled, * MANUAL button still usable. */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'set-manual-mode' - | 'configure-speca' - | 'verify-acu-gating' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'set-manual-mode' | 'configure-speca' | 'verify-acu-gating' | 'auto'; interface Scenario23Objective { id: string; @@ -43,8 +31,7 @@ const SCENARIO_23_OBJECTIVES: Scenario23Objective[] = [ id: 'review-mission-brief', title: 'Review the Fault', type: 'quiz', - correctAnswer: - 'Broken: program-track, step-track, move-to-target, lock logic (the automation brain). Working: servos, manual mode, and the RF chain - the dish can still move, you just have to fly it', + correctAnswer: 'Broken: program-track, step-track, move-to-target, lock logic. Working: servos, manual mode, the RF chain - you have to fly it', }, { id: 'select-vermont-station', @@ -64,22 +51,19 @@ const SCENARIO_23_OBJECTIVES: Scenario23Objective[] = [ id: 'confirm-fault-dashboard', title: 'Confirm the Automation Fault', type: 'quiz', - correctAnswer: - 'Current position (Az 161.8 / El 34.2 on TM-1) and that beacon + carrier are still locked - the dish is ON the bird; whatever you do next must not lose that', + correctAnswer: 'Current position (Az 161.8 / El 34.2 on TM-1) and that beacon + carrier are still locked - the dish is ON the bird', }, { id: 'what-automation-did-quiz', title: 'What the Automation Was Doing', type: 'quiz', - correctAnswer: - 'Compute the pointing solution (from the prediction sheet), drive the axes manually, and judge lock from the spectrum - the ACU lock indicator is part of the dead automation and cannot be trusted', + correctAnswer: 'Compute pointing (from the prediction sheet), drive the axes by hand, and judge lock from the spectrum - not the ACU indicator', }, { id: 'no-reboot-quiz', title: 'Why Not Reboot It', type: 'quiz', - correctAnswer: - 'A reboot can return with stale/defaulted axis calibration and destroys the crash state IT needs for root cause - bypass keeps the link up AND preserves the evidence; IT cycles it on their schedule', + correctAnswer: 'A reboot risks defaulted axis calibration and wipes the crash state IT needs - bypass keeps the link up and keeps the evidence', }, // PHASE 2: COMMIT TO MANUAL (+ UI gating assertion) @@ -103,8 +87,7 @@ const SCENARIO_23_OBJECTIVES: Scenario23Objective[] = [ id: 'manual-deliberate-quiz', title: 'Why Deliberate Manual', type: 'quiz', - correctAnswer: - 'In program-track the panel implies an automation is flying the dish when none is - manual makes the truth explicit: YOU are the controller, the displays mean what they say, and there is no phantom loop to fight', + correctAnswer: 'In program-track the panel implies a loop is flying the dish when none is - manual makes YOU the controller, explicitly', }, // PHASE 3: PROVE THE LINK @@ -130,8 +113,7 @@ const SCENARIO_23_OBJECTIVES: Scenario23Objective[] = [ id: 'instruments-not-feel-quiz', title: 'Fly Instruments, Not Feel', type: 'quiz', - correctAnswer: - 'The beacon at 1074.5 MHz on the spectrum and receiver lock - the RF truth, independent of the dead ACU automation. Never the ACU lock indicator', + correctAnswer: 'The beacon at 1074.5 MHz on the spectrum and receiver lock - the RF truth, independent of the dead ACU automation', }, // PHASE 4: HOLD AND COORDINATE @@ -145,22 +127,19 @@ const SCENARIO_23_OBJECTIVES: Scenario23Objective[] = [ id: 'geo-feasibility-quiz', title: 'Why Manual Holds (This Time)', type: 'quiz', - correctAnswer: - 'TIDEMARK-1 is a well-behaved GEO bird - it barely moves over hours, so a fixed manual point holds. It would NOT work for an inclined bird like AURORA-7, whose figure-8 needs the step-track this fault disabled', + correctAnswer: "TIDEMARK-1 is a well-behaved GEO bird that barely moves, so a fixed point holds - NOT on AURORA-7's inclined figure-8", }, { id: 'it-coordination-quiz', title: 'Coordinate the Repair', type: 'quiz', - correctAnswer: - 'Confirm the link is stable on manual first, agree a window, expect to re-establish program-track and re-verify pointing AFTER recovery - and be ready to fall back to manual if the restart misbehaves', + correctAnswer: 'Confirm the link is stable on manual, agree a window, then re-verify pointing on RF after recovery - ready to fall back to manual', }, { id: 'log-bypass', title: 'Log the Bypass', type: 'quiz', - correctAnswer: - 'ACU automation fault 1358 (IT NOC-2026-2231). Bypassed to manual 1404 - TM-1 held on prediction-sheet pointing, beacon + carrier verified on spectrum (ACU lock indicator NOT trusted, driven by failed processor). Link nominal under manual. Controller NOT rebooted - crash state preserved for IT. Recovery to be re-verified on RF post-restart.', + correctAnswer: 'ACU fault 1358 (NOC-2026-2231); manual 1404, TM-1 held on sheet pointing, beacon + carrier verified on RF; not rebooted', }, ]; @@ -188,10 +167,7 @@ async function verifyAcuGating(page: import('@playwright/test').Page): Promise { +async function configureSpeca(page: import('@playwright/test').Page, centerFrequencyMhz: number): Promise { const centerFreqInput = page.locator('#sa-center-freq'); await expect(centerFreqInput).toBeVisible({ timeout: 5000 }); await centerFreqInput.fill(centerFrequencyMhz.toString()); @@ -199,11 +175,7 @@ async function configureSpeca( await page.waitForTimeout(500); } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario23Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario23Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -216,6 +188,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'set-manual-mode': diff --git a/e2e/specs/scenario24-full-completion.spec.ts b/e2e/specs/scenario24-full-completion.spec.ts index 8cdc0c8b..92fa2feb 100644 --- a/e2e/specs/scenario24-full-completion.spec.ts +++ b/e2e/specs/scenario24-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 24 - "Constellation Crisis": Campaign Capstone. @@ -15,16 +10,7 @@ import { * board comms. The Working Document is the incident-command log. No new * mechanics - this validates the orchestration of everything prior. */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'toggle-switch' - | 'configure-buc-gain' - | 'repoint-program-track' - | 'set-step-track' - | 'verify-working-doc' - | 'auto'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'toggle-switch' | 'configure-buc-gain' | 'repoint-program-track' | 'set-step-track' | 'verify-working-doc' | 'auto'; interface Scenario24Objective { id: string; @@ -45,15 +31,13 @@ const SCENARIO_24_OBJECTIVES: Scenario24Objective[] = [ id: 'review-mission-brief', title: 'Assume Incident Command', type: 'quiz', - correctAnswer: - 'Order the board before touching anything: separate the clocks you do not control (storm ETA, pass window, board deadline) from the trends you can bend (BUC heat, customer confidence), and sequence your attention accordingly', + correctAnswer: 'Order the board first: fixed clocks (storm ETA, pass window, board note) versus bendable trends (BUC heat, customer confidence)', }, { id: 'sequencing-quiz', title: 'Set the Sequence', type: 'quiz', - correctAnswer: - 'Protect against the fixed clock first: enable VT-01 heater NOW (one switch, beats the front), then de-rate the ME-02 BUC trend, then set up the AURORA pass before its window - acting on the soonest fixed deadline first, cheap protections before expensive ones', + correctAnswer: 'VT-01 heater NOW (one switch, beats the front), then de-rate the ME-02 BUC, then set up the AURORA pass before its 0800 window', }, // TRACK 1: VT-01 STORM @@ -94,8 +78,7 @@ const SCENARIO_24_OBJECTIVES: Scenario24Objective[] = [ id: 'recognize-buc-signature', title: 'Recognize the Signature', type: 'quiz', - correctAnswer: - 'The same over-gain thermal trend from before - the swap that never cleared procurement. Fix is the de-rate: gain back to the 23 dB operating value, which cuts the dissipation at the source', + correctAnswer: 'The same over-gain thermal trend as before (swap still pending) - fix is the de-rate, gain back to the 23 dB operating value', }, { id: 'derate-buc', @@ -138,8 +121,7 @@ const SCENARIO_24_OBJECTIVES: Scenario24Objective[] = [ id: 'pass-go-nogo-quiz', title: 'Pass Go/No-Go', type: 'quiz', - correctAnswer: - 'GO with a caveat: step-track holding, margin thin on an end-of-life beacon; proceed with the pass but flag that AURORA is on its sunset trajectory - and deliver that BEFORE 0800, not mid-pass', + correctAnswer: 'GO with a caveat: step-track holding, margin thin on an end-of-life beacon; flag the sunset trajectory to Marcus BEFORE 0800', }, // CUSTOMER + BOARD @@ -147,22 +129,19 @@ const SCENARIO_24_OBJECTIVES: Scenario24Objective[] = [ id: 'customer-escalation-quiz', title: 'Manage the Customer Channel', type: 'quiz', - correctAnswer: - 'Per-trunk cause/action/next-update, in thirty seconds each: TM-1 protected ahead of the storm (heater on, holding), TM-2 stabilized (BUC de-rated, no impact) - with a committed next-update time so he stops calling and you keep working', + correctAnswer: 'Per-trunk cause/action/next-update, thirty seconds each: TM-1 protected (heater on), TM-2 stabilized (BUC de-rated, no impact)', }, { id: 'storm-hold-check', title: 'Storm Holding', type: 'quiz', - correctAnswer: - 'The feed stays clear as fast as ice tries to form - no accumulation, link holding; the proactive heater turned a potential outage into a non-event you only have to monitor', + correctAnswer: 'The feed stays clear as fast as ice tries to form - no accumulation, link holding; the outage never happens, monitor only', }, { id: 'board-note-quiz', title: 'The Board Note', type: 'quiz', - correctAnswer: - 'Posture, exposure, action: both stations stable through a concurrent storm + thermal-trend morning, no customer outage; AURORA pass delivered on a sunsetting beacon (reinforces the migration recommendation); residual risk = the pending ME-02 BUC swap. One paragraph, board-level, no jargon', + correctAnswer: 'Posture and exposure: both stations stable, no outage; AURORA pass flown on a sunsetting beacon; residual risk = ME-02 BUC swap', }, // Verify command log BEFORE the final quizzes (modal overlays sidebar after) @@ -177,15 +156,13 @@ const SCENARIO_24_OBJECTIVES: Scenario24Objective[] = [ id: 'review-command-log', title: 'Review the Command Log', type: 'quiz', - correctAnswer: - 'It shows the ordered board, the sequence and why, each track\'s action and outcome, the customer and board comms, and the residual risk - someone could pick up your shift cold and know exactly what happened and what is still open', + correctAnswer: "It shows the ordered board, the sequence and why, each track's action and outcome, the comms, and the residual risk", }, { id: 'log-crisis-closed', title: 'Close the Incident', type: 'quiz', - correctAnswer: - 'Constellation crisis worked under single-operator incident command: VT-01 storm protected (proactive heater, no ice), ME-02 BUC thermal trend de-rated (no outage), AURORA SeaLink pass delivered on EOL beacon, customers briefed per-trunk, board note filed. Zero customer outage across five concurrent tracks. Residual: ME-02 BUC swap pending. IC closed.', + correctAnswer: 'IC closed: VT-01 storm protected, ME-02 BUC de-rated, AURORA pass delivered; zero outage, five tracks; residual = ME-02 BUC swap', }, ]; @@ -193,11 +170,7 @@ const SCENARIO_24_OBJECTIVES: Scenario24Objective[] = [ // Helper Functions // ============================================================ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - desiredState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, desiredState: boolean): Promise { let switchEl = page.locator(`#${switchId}`); if ((await switchEl.count()) === 0) { switchEl = page.locator(`[id$="${switchId}"]`); @@ -216,10 +189,7 @@ async function toggleSwitch( await page.waitForTimeout(300); } -async function configureBucGain( - page: import('@playwright/test').Page, - gain: number -): Promise { +async function configureBucGain(page: import('@playwright/test').Page, gain: number): Promise { const gainInput = page.locator('#buc-gain'); await expect(gainInput).toBeVisible({ timeout: 5000 }); await gainInput.fill(gain.toString()); @@ -232,10 +202,7 @@ async function configureBucGain( await page.waitForTimeout(400); } -async function repointProgramTrack( - page: import('@playwright/test').Page, - satelliteNoradId: string -): Promise { +async function repointProgramTrack(page: import('@playwright/test').Page, satelliteNoradId: string): Promise { const modeButton = page.locator('.btn-tracking[data-mode="program-track"]'); await expect(modeButton).toBeVisible({ timeout: 5000 }); await modeButton.click(); @@ -262,7 +229,7 @@ async function repointProgramTrack( }; }; }; - const gs = w.signalRange?.simulationManager?.groundStations?.find(g => g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); const antennaState = gs?.antennas?.[0]?.state; return antennaState ? antennaState.slewing === false && antennaState.isLocked === true : false; }, @@ -303,11 +270,7 @@ async function verifyWorkingDocument(page: import('@playwright/test').Page): Pro } } -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario24Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario24Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -320,6 +283,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': diff --git a/e2e/specs/scenario3-full-completion.spec.ts b/e2e/specs/scenario3-full-completion.spec.ts index b1d33fa8..95c71158 100644 --- a/e2e/specs/scenario3-full-completion.spec.ts +++ b/e2e/specs/scenario3-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 3 objectives - Weather Emergency Handover: Multi-Site Operations. @@ -127,8 +122,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-heater-quiz', title: 'Understand Feed Heater Consequences', type: 'quiz', - correctAnswer: - 'Ice would accumulate on the feed horn and waveguide, causing signal attenuation and potential physical damage', + correctAnswer: 'Ice would build up on the feed horn and waveguide and attenuate the signal', }, // ============================================================ @@ -144,22 +138,19 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-agc-status', title: 'Understand AGC Function', type: 'quiz', - correctAnswer: - 'The output signal level would drop as weather attenuated the input, eventually causing loss of lock', + correctAnswer: 'The output level would fall with the attenuated input until the receiver lost lock', }, { id: 'estimate-time-remaining', title: 'Understand Time Pressure', type: 'quiz', - correctAnswer: - "Weather degradation is progressive - once AGC runs out of compensation range, the link fails rapidly", + correctAnswer: 'Degradation is progressive - once the AGC runs out of range the link fails fast', }, { id: 'verify-agc-limits-quiz', title: 'Understand AGC Limitations', type: 'quiz', - correctAnswer: - 'AGC has a maximum gain limit - once reached, further signal loss cannot be compensated', + correctAnswer: 'AGC has a maximum gain limit - once reached, further signal loss cannot be compensated', }, // ============================================================ @@ -175,8 +166,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-multisite-quiz', title: 'Understand Multi-Site Operations', type: 'quiz', - correctAnswer: - 'Vermont continues operating normally - customers are still being served from VT-01', + correctAnswer: 'Vermont continues operating normally - customers are still being served from VT-01', }, // ============================================================ @@ -198,8 +188,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-gpsdo-weather-quiz', title: 'Understand Weather Impact on GPSDO', type: 'quiz', - correctAnswer: - 'GPS uses L-band frequencies (~1.5 GHz) which are less affected by precipitation than C-band', + correctAnswer: 'GPS uses L-band frequencies (~1.5 GHz) which are less affected by precipitation than C-band', }, // ============================================================ @@ -222,8 +211,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'catherine-look-angles', title: "Catherine's Sanity Check", type: 'quiz', - correctAnswer: - "Because look angles to a satellite depend on the ground station's geographic location", + correctAnswer: "Because look angles to a satellite depend on the ground station's geographic location", }, // ============================================================ @@ -248,8 +236,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-lnb-config-quiz', title: 'Verify LNB Configuration', type: 'quiz', - correctAnswer: - 'Same LO frequency produces the same IF frequency, so downstream equipment configuration is identical', + correctAnswer: 'Same LO gives the same IF, so downstream equipment configuration is identical', }, // ============================================================ @@ -275,8 +262,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-beacon-reason-quiz', title: 'Understand Beacon Verification', type: 'quiz', - correctAnswer: - 'Beacon confirms the entire receive chain is working - antenna, feed, LNB, cables, and spectrum analyzer', + correctAnswer: 'Beacon proves the whole receive chain works - antenna, feed, LNB, cables, analyzer', }, // ============================================================ @@ -297,8 +283,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-modem-match-quiz', title: 'Understand Parameter Matching', type: 'quiz', - correctAnswer: - 'Both sites are receiving the same satellite carrier - mismatched parameters would fail to demodulate', + correctAnswer: 'Both sites receive the same carrier - mismatched parameters would fail to demodulate', }, // ============================================================ @@ -313,8 +298,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-lock-quality-quiz', title: 'Understand Lock vs. Quality', type: 'quiz', - correctAnswer: - 'Lock can occur at low C/N but with high error rates - we need margin for reliable data', + correctAnswer: 'Lock can occur at low C/N but with high error rates - we need margin for reliable data', }, // ============================================================ @@ -353,8 +337,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'understand-handover-quiz', title: 'Understand Handover Process', type: 'quiz', - correctAnswer: - "Maine's transmitter activates fully while Vermont's is disabled - avoiding dual uplinks to the satellite", + correctAnswer: "Maine's transmitter comes up while Vermont's is disabled - avoiding a dual uplink", }, { id: 'execute-handover', @@ -368,8 +351,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-handover-success-quiz', title: 'Confirm Handover Success', type: 'quiz', - correctAnswer: - 'Traffic indicator shows ME-02 as active, VT-01 TX disabled, no alarms, continuous data flow', + correctAnswer: 'Traffic indicator shows ME-02 as active, VT-01 TX disabled, no alarms, continuous data flow', }, // ============================================================ @@ -398,8 +380,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ id: 'verify-stow-quiz', title: 'Understand Stow Position', type: 'quiz', - correctAnswer: - 'Minimizes wind loading on the dish and prevents snow from accumulating in the reflector', + correctAnswer: 'Minimizes wind loading on the dish and prevents snow from accumulating in the reflector', }, // This is a single objective in the scenario with TWO quiz conditions. // The second quiz (Documentation Purpose) appears first in the UI, followed by the first quiz (What to Log). @@ -428,11 +409,7 @@ const SCENARIO_3_OBJECTIVES: Scenario3Objective[] = [ * @param switchId The ID of the switch element (without # prefix) * @param targetState true = checked/on, false = unchecked/off */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - targetState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, targetState: boolean): Promise { // Try multiple selectors to find the switch let switchEl = page.locator(`#${switchId}`); @@ -470,10 +447,7 @@ async function toggleSwitch( * ACU control tab must be active before calling this. * For maintenance and stow modes, also clicks Apply to commit the position change. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { // Find the tracking mode button with data-mode attribute const modeButton = page.locator(`.btn-tracking[data-mode="${trackingMode}"]`); await expect(modeButton).toBeVisible({ timeout: 5000 }); @@ -518,10 +492,7 @@ async function selectSatelliteAndMove(page: import('@playwright/test').Page): Pr * Wait for antenna movement to complete by monitoring position changes. * The antenna moves at ~2-5 deg/sec, so large movements take several seconds. */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 90000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 90000): Promise { const startTime = Date.now(); let lastPosition = ''; let stableCount = 0; @@ -533,9 +504,7 @@ async function waitForAntennaMovement( await page.waitForTimeout(1000); // Get current elevation from the fine-adjust control display - let elDisplay = page - .locator('.fine-adjust-control', { hasText: 'Elevation' }) - .locator('.fine-adjust-value-active'); + let elDisplay = page.locator('.fine-adjust-control', { hasText: 'Elevation' }).locator('.fine-adjust-value-active'); // Fallback: try finding by ID pattern (contains "el-fine") if ((await elDisplay.count()) === 0) { @@ -569,10 +538,7 @@ async function waitForAntennaMovement( * Configure LNB with specified settings. * Powers on LNB, sets LO frequency and gain, waits for thermal stabilization. */ -async function configureLnb( - page: import('@playwright/test').Page, - config: { loFrequency: number; gain: number } -): Promise { +async function configureLnb(page: import('@playwright/test').Page, config: { loFrequency: number; gain: number }): Promise { // Power on LNB const powerSwitch = page.locator('#lnb-power'); await expect(powerSwitch).toBeVisible({ timeout: 5000 }); @@ -659,10 +625,7 @@ async function configureSpectrumAnalyzer( /** * Configure receiver modem settings. */ -async function configureRxModem( - page: import('@playwright/test').Page, - config: { frequency?: number; bandwidth?: number; modulation?: string; fec?: string } -): Promise { +async function configureRxModem(page: import('@playwright/test').Page, config: { frequency?: number; bandwidth?: number; modulation?: string; fec?: string }): Promise { // Configure frequency (in MHz) if specified if (config.frequency !== undefined) { const freqInput = page.locator('#frequency-input'); @@ -802,10 +765,7 @@ async function configureTxModem( /** * Select a satellite by clicking on it in the map or asset tree. */ -async function selectSatellite( - page: import('@playwright/test').Page, - satelliteId: string -): Promise { +async function selectSatellite(page: import('@playwright/test').Page, satelliteId: string): Promise { // Try clicking on satellite in the asset tree const satTreeItem = page.locator(`[data-asset-id="${satelliteId}"]`); await expect(satTreeItem).toBeVisible({ timeout: 10000 }); @@ -821,10 +781,7 @@ async function selectSatellite( /** * Execute traffic handover to target station. */ -async function executeTrafficHandover( - page: import('@playwright/test').Page, - targetStation: string -): Promise { +async function executeTrafficHandover(page: import('@playwright/test').Page, targetStation: string): Promise { // Select target station in handover dropdown const handoverSelect = page.locator('#sat-handover-target'); await expect(handoverSelect).toBeVisible({ timeout: 5000 }); @@ -844,11 +801,7 @@ async function executeTrafficHandover( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario3Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario3Objective): Promise { switch (objective.type) { case 'quiz': // Wait for quiz to appear and answer it @@ -864,6 +817,10 @@ async function executeObjective( case 'click-tab': // Click on the specified tab await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': @@ -980,7 +937,7 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Review Mission Brief', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'review-mission-brief')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'review-mission-brief')!; await executeObjective(page, missionControlPage, objective); }); @@ -989,27 +946,27 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Access Vermont Ground Station', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'select-vermont-station')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'select-vermont-station')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Open ACU Control Tab (VT-01)', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-acu-vt01-heater')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-acu-vt01-heater')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Enable Feed Heater', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'enable-vt01-heater')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'enable-vt01-heater')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Operational Priorities', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'understand-prioritization')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'understand-prioritization')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Feed Heater Consequences', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-heater-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-heater-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1018,22 +975,22 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Open RX Analysis Tab (VT-01)', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-rx-vt01-agc')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-rx-vt01-agc')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand AGC Function', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-agc-status')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-agc-status')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Time Pressure', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'estimate-time-remaining')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'estimate-time-remaining')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand AGC Limitations', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-agc-limits-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-agc-limits-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1042,12 +999,12 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Access Maine Backup Station', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'switch-to-maine')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'switch-to-maine')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Multi-Site Operations', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-multisite-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-multisite-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1056,17 +1013,17 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Open GPS Timing Tab', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-gps-timing-maine')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-gps-timing-maine')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify GPSDO Lock Status', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-maine-gpsdo')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-maine-gpsdo')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Weather Impact on GPSDO', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-gpsdo-weather-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-gpsdo-weather-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1075,19 +1032,19 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Open ACU Control Tab (Maine)', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-acu-maine')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-acu-maine')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Point Antenna at TIDEMARK-1', async () => { // Antenna movement can take up to 90 seconds test.setTimeout(120000); - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'configure-maine-antenna')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'configure-maine-antenna')!; await executeObjective(page, missionControlPage, objective); }); test("Objective: Catherine's Sanity Check", async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'catherine-look-angles')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'catherine-look-angles')!; await executeObjective(page, missionControlPage, objective); }); @@ -1096,17 +1053,17 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Open RX Analysis Tab (Maine)', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-rx-maine-lnb')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-rx-maine-lnb')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Power Up LNB', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'configure-maine-lnb')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'configure-maine-lnb')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify LNB Configuration', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-lnb-config-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-lnb-config-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1115,17 +1072,17 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Configure Spectrum Analyzer', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'configure-speca-maine')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'configure-speca-maine')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Beacon Signal', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-beacon-maine')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-beacon-maine')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Beacon Verification', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-beacon-reason-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-beacon-reason-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1134,12 +1091,12 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Configure Receiver Modem', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'configure-maine-rx-modem')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'configure-maine-rx-modem')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Parameter Matching', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-modem-match-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-modem-match-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1148,12 +1105,12 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Confirm Signal Acquisition', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-maine-lock')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-maine-lock')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Lock vs. Quality', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-lock-quality-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-lock-quality-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1162,12 +1119,12 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Open TX Chain Tab', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-tx-maine')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-tx-maine')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Configure Transmitter Modem', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'configure-maine-tx-modem')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'configure-maine-tx-modem')!; await executeObjective(page, missionControlPage, objective); }); @@ -1176,22 +1133,22 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Open Satellite Dashboard', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-dashboard-handover')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-dashboard-handover')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Handover Process', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'understand-handover-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'understand-handover-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Execute Traffic Handover', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'execute-handover')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'execute-handover')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm Handover Success', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-handover-success-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-handover-success-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -1200,37 +1157,37 @@ test.describe('Scenario 3 Full Completion', () => { // ============================================================ test('Objective: Return to Vermont Station', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'switch-to-vermont-stow')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'switch-to-vermont-stow')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Open ACU Control Tab (Stow)', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'navigate-acu-vt01-stow')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'navigate-acu-vt01-stow')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Stow Vermont Antenna', async () => { // Antenna movement can take up to 90 seconds test.setTimeout(120000); - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'stow-vermont-antenna')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'stow-vermont-antenna')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Stow Position', async () => { - const objective = SCENARIO_3_OBJECTIVES.find(o => o.id === 'verify-stow-quiz')!; + const objective = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'verify-stow-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Document Handover Event', async () => { // This objective has TWO quiz conditions but they may appear in different order. // The second quiz (Purpose) appears first in the UI with letter prefixes. - const purposeQuiz = SCENARIO_3_OBJECTIVES.find(o => o.id === 'document-handover-event-quiz1')!; + const purposeQuiz = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'document-handover-event-quiz1')!; await executeObjective(page, missionControlPage, purposeQuiz); // The first quiz (What to Log) appears second in the UI without letter prefixes. // Check if there's another quiz to answer try { - const whatToLogQuiz = SCENARIO_3_OBJECTIVES.find(o => o.id === 'document-handover-event-quiz2')!; + const whatToLogQuiz = SCENARIO_3_OBJECTIVES.find((o) => o.id === 'document-handover-event-quiz2')!; await executeObjective(page, missionControlPage, whatToLogQuiz); } catch { // Quiz may have already been answered or doesn't appear diff --git a/e2e/specs/scenario4-full-completion.spec.ts b/e2e/specs/scenario4-full-completion.spec.ts index 5a6d1fa6..4b7e2e3c 100644 --- a/e2e/specs/scenario4-full-completion.spec.ts +++ b/e2e/specs/scenario4-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 4 objectives - New Bird on the Block: Satellite Switchover Operations. @@ -21,16 +16,7 @@ import { * - 'configure-rx-modem': Requires configuring receiver modem * - 'configure-tx-modem': Requires configuring transmitter modem */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'auto' - | 'toggle-switch' - | 'set-tracking-mode' - | 'configure-speca' - | 'configure-rx-modem' - | 'configure-tx-modem'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'auto' | 'toggle-switch' | 'set-tracking-mode' | 'configure-speca' | 'configure-rx-modem' | 'configure-tx-modem'; interface Scenario4Objective { id: string; @@ -98,8 +84,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ id: 'verify-antenna-initial-state', title: 'Verify Antenna Configuration', type: 'quiz', - correctAnswer: - 'Program-track - the antenna follows ephemeris data and will need new coordinates for TIDEMARK-2', + correctAnswer: 'Program-track - the antenna follows ephemeris data and will need new coordinates for TIDEMARK-2', }, // ============================================================ @@ -116,8 +101,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ id: 'verify-antenna-slew-quiz', title: 'Understand Position Change', type: 'quiz', - correctAnswer: - 'TIDEMARK-2 is at a different orbital slot (45°W vs 53°W), requiring different look angles from Vermont', + correctAnswer: 'TIDEMARK-2 sits at a different orbital slot (45°W vs 53°W), so the look angles differ', }, // ============================================================ @@ -161,8 +145,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ id: 'verify-beacon-chain-quiz', title: 'Understand Receive Chain Validation', type: 'quiz', - correctAnswer: - 'It proves the entire RF path is working - antenna feed, LNB, cables, and signal routing - so we know modem issues would be modem configuration, not upstream problems', + correctAnswer: 'It proves the RF path works - feed, LNB, cables - so a modem fault would be configuration', }, // ============================================================ @@ -195,8 +178,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ id: 'verify-rx-margin-quiz', title: 'Understand Link Margin', type: 'quiz', - correctAnswer: - 'Lock can occur at C/N as low as 3-4 dB, but error rates would be high - we need margin for reliable operation', + correctAnswer: 'Lock can occur at C/N as low as 3-4 dB, but error rates would be high - we need margin', }, // ============================================================ @@ -231,8 +213,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ id: 'understand-buc-hpa-sequence', title: 'Understand TX Sequence', type: 'quiz', - correctAnswer: - 'Unmute BUC first, then enable HPA - drive the amplifier chain from input to output to avoid undriven amplifiers', + correctAnswer: 'Unmute BUC first, then enable HPA - drive the chain from input to output', }, { id: 'enable-transmit-path', @@ -245,8 +226,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ id: 'verify-full-duplex-quiz', title: 'Verify Full Duplex Operation', type: 'quiz', - correctAnswer: - 'Receiver locked with good C/N, HPA enabled with proper backoff, no alarms - bidirectional link established', + correctAnswer: 'Receiver locked with good C/N, HPA enabled with backoff, no alarms - both directions up', }, ]; @@ -259,11 +239,7 @@ const SCENARIO_4_OBJECTIVES: Scenario4Objective[] = [ * @param switchId The ID of the switch element (without # prefix) * @param targetState true = checked/on, false = unchecked/off */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - targetState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, targetState: boolean): Promise { const switchEl = page.locator(`#${switchId}`); await expect(switchEl).toBeVisible({ timeout: 5000 }); @@ -290,10 +266,7 @@ async function toggleSwitch( * Set the antenna tracking mode by clicking the appropriate button. * ACU control tab must be active before calling this. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { // Find the tracking mode button with data-mode attribute const modeButton = page.locator(`.btn-tracking[data-mode="${trackingMode}"]`); await expect(modeButton).toBeVisible({ timeout: 5000 }); @@ -337,10 +310,7 @@ async function selectTidemark2AndMove(page: import('@playwright/test').Page): Pr * Wait for antenna movement to complete by monitoring position changes. * The antenna moves at ~2-5 deg/sec, so large movements take several seconds. */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 90000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 90000): Promise { const startTime = Date.now(); let lastPosition = ''; let stableCount = 0; @@ -352,9 +322,7 @@ async function waitForAntennaMovement( await page.waitForTimeout(1000); // Get current elevation from the fine-adjust control display - let elDisplay = page - .locator('.fine-adjust-control', { hasText: 'Elevation' }) - .locator('.fine-adjust-value-active'); + let elDisplay = page.locator('.fine-adjust-control', { hasText: 'Elevation' }).locator('.fine-adjust-value-active'); // Fallback: try finding by ID pattern if ((await elDisplay.count()) === 0) { @@ -428,10 +396,7 @@ async function configureSpectrumAnalyzer( * Configure receiver modem settings. * Element IDs: #frequency-input, #bandwidth-input, #modulation-select, #fec-select, #apply-btn */ -async function configureRxModem( - page: import('@playwright/test').Page, - config: { frequency?: number; bandwidth?: number; modulation?: string; fec?: string } -): Promise { +async function configureRxModem(page: import('@playwright/test').Page, config: { frequency?: number; bandwidth?: number; modulation?: string; fec?: string }): Promise { // Configure frequency (in MHz) if specified if (config.frequency !== undefined) { const freqInput = page.locator('#frequency-input'); @@ -606,10 +571,7 @@ async function enableTransmitPath(page: import('@playwright/test').Page): Promis * Wait for receiver to lock with good SNR. * Element IDs: #signal-status (badge showing lock state), #cn-effective-display (C/N ratio) */ -async function waitForRxLock( - page: import('@playwright/test').Page, - timeout = 30000 -): Promise { +async function waitForRxLock(page: import('@playwright/test').Page, timeout = 30000): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeout) { @@ -638,11 +600,7 @@ async function waitForRxLock( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario4Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario4Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -655,6 +613,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': diff --git a/e2e/specs/scenario5-full-completion.spec.ts b/e2e/specs/scenario5-full-completion.spec.ts index e6f146a9..4055d36e 100644 --- a/e2e/specs/scenario5-full-completion.spec.ts +++ b/e2e/specs/scenario5-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 5 objectives - Interference Hunt: Spectrum Analysis and Mitigation @@ -17,12 +12,7 @@ import { * - 'configure-speca': Requires configuring spectrum analyzer settings * - 'configure-notch-filter': Requires configuring notch filter settings */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'configure-speca' - | 'configure-notch-filter'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'configure-speca' | 'configure-notch-filter'; interface Scenario5Objective { id: string; @@ -92,8 +82,7 @@ const SCENARIO_5_OBJECTIVES: Scenario5Objective[] = [ id: 'verify-speca-initial-state', title: 'Assess Current Configuration', type: 'quiz', - correctAnswer: - "The narrow span only shows the beacon, not our 36 MHz wideband signal where the problem likely exists", + correctAnswer: 'The narrow span shows only the beacon, not the 36 MHz wideband carrier with the problem', }, { id: 'phase-2-configure-and-locate', @@ -200,8 +189,7 @@ const SCENARIO_5_OBJECTIVES: Scenario5Objective[] = [ id: 'document-interference-quiz', title: 'Understand Documentation Requirements', type: 'quiz', - correctAnswer: - 'Interference frequency, bandwidth, apparent source, time of occurrence, and mitigation applied', + correctAnswer: 'Interference frequency, bandwidth, apparent source, time of occurrence, and mitigation applied', }, ]; @@ -333,11 +321,7 @@ async function configureNotchFilter( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario5Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario5Objective): Promise { switch (objective.type) { case 'quiz': // Wait for quiz to appear and answer it @@ -353,6 +337,10 @@ async function executeObjective( case 'click-tab': // Click on the specified tab await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'configure-speca': @@ -425,12 +413,12 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Review Mission Brief', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'review-mission-brief')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'review-mission-brief')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Select Vermont Ground Station', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'select-vermont-station')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'select-vermont-station')!; await executeObjective(page, missionControlPage, objective); }); @@ -439,17 +427,17 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Navigate to Receiver', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'navigate-rx-analysis')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'navigate-rx-analysis')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm Signal Degradation', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-1-observe-degradation')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-1-observe-degradation')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Assess Full Impact', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'verify-receiver-state-quiz')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'verify-receiver-state-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -458,12 +446,12 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Assess Current Configuration', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'verify-speca-initial-state')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'verify-speca-initial-state')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Configure Spectrum View', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-2-configure-and-locate')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-2-configure-and-locate')!; await executeObjective(page, missionControlPage, objective); }); @@ -472,22 +460,22 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Identify Interference', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-4-identify-interference')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-4-identify-interference')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Characterize the Interference', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-5-characterize-interference')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-5-characterize-interference')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Record Interference Frequency', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'measure-interference-frequency')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'measure-interference-frequency')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Frequency Domain for Notch Filter', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'understand-notch-frequency-domain')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'understand-notch-frequency-domain')!; await executeObjective(page, missionControlPage, objective); }); @@ -496,12 +484,12 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Understand the Interference Source', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-6-understand-cause')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-6-understand-cause')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand the AGC Impact', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-7-understand-impact')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-7-understand-impact')!; await executeObjective(page, missionControlPage, objective); }); @@ -510,7 +498,7 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Evaluate Mitigation Approaches', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'understand-mitigation-options')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'understand-mitigation-options')!; await executeObjective(page, missionControlPage, objective); }); @@ -519,7 +507,7 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Configure Notch Filter', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-8-apply-notch-filter')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-8-apply-notch-filter')!; await executeObjective(page, missionControlPage, objective); }); @@ -528,12 +516,12 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Verify Spectrum Cleared', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'verify-spectrum-cleared')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'verify-spectrum-cleared')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Service Restored', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'phase-9-verify-restoration')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'phase-9-verify-restoration')!; await executeObjective(page, missionControlPage, objective); }); @@ -542,7 +530,7 @@ test.describe('Scenario 5 Full Completion', () => { // ============================================================ test('Objective: Understand Documentation Requirements', async () => { - const objective = SCENARIO_5_OBJECTIVES.find(o => o.id === 'document-interference-quiz')!; + const objective = SCENARIO_5_OBJECTIVES.find((o) => o.id === 'document-interference-quiz')!; await executeObjective(page, missionControlPage, objective); }); diff --git a/e2e/specs/scenario6-full-completion.spec.ts b/e2e/specs/scenario6-full-completion.spec.ts index a557089d..29b9e4af 100644 --- a/e2e/specs/scenario6-full-completion.spec.ts +++ b/e2e/specs/scenario6-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 6: "Old Faithful" - Step-Track Operations on Inclined Orbit @@ -27,15 +22,7 @@ import { * - 'configure-tx-modem': Configure TX modem frequency * - 'enable-tx-path': Unmute BUC and enable HPA */ -type ObjectiveType = - | 'quiz' - | 'set-tracking-mode' - | 'wait-beacon-lock' - | 'configure-speca' - | 'configure-rx-modem' - | 'wait-rx-lock' - | 'configure-tx-modem' - | 'enable-tx-path'; +type ObjectiveType = 'quiz' | 'set-tracking-mode' | 'wait-beacon-lock' | 'configure-speca' | 'configure-rx-modem' | 'wait-rx-lock' | 'configure-tx-modem' | 'enable-tx-path'; interface Scenario6Objective { id: string; @@ -79,8 +66,7 @@ const SCENARIO_6_OBJECTIVES: Scenario6Objective[] = [ id: 'understand-inclined-orbit', title: 'Understand Inclined Orbits', type: 'quiz', - correctAnswer: - 'Inclined orbit causes the satellite to drift in az/el; step-track follows the beacon', + correctAnswer: 'Inclined orbit causes the satellite to drift in az/el; step-track follows the beacon', }, { id: 'recognize-wrong-satellite', @@ -99,8 +85,7 @@ const SCENARIO_6_OBJECTIVES: Scenario6Objective[] = [ id: 'quiz-program-track-limitation', title: 'Understand Program-Track Limitations', type: 'quiz', - correctAnswer: - "AURORA-7's inclined orbit causes drift - ephemeris predictions aren't accurate enough", + correctAnswer: "AURORA-7's inclined orbit causes drift - ephemeris predictions aren't accurate enough", }, // ============================================================ @@ -196,8 +181,7 @@ const SCENARIO_6_OBJECTIVES: Scenario6Objective[] = [ id: 'final-verification', title: 'Full Duplex Established', type: 'quiz', - correctAnswer: - 'Step-track maintaining lock on beacon, RX at 1422 MHz IF, TX at 1447 MHz IF, AES-256 encrypted', + correctAnswer: 'Step-track maintaining lock on beacon, RX at 1422 MHz IF, TX at 1447 MHz IF, AES-256 encrypted', }, ]; @@ -210,10 +194,7 @@ const SCENARIO_6_OBJECTIVES: Scenario6Objective[] = [ * For step-track, toggle the step-track checkbox (requires program-track mode). * For program-track, selects AURORA-7 and clicks Move to Target. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { // Step-track is enabled via a toggle checkbox, not a mode button if (trackingMode === 'step-track') { // The step-track toggle is a checkbox that enables step-track optimization @@ -265,10 +246,7 @@ async function selectAurora7AndMove(page: import('@playwright/test').Page): Prom /** * Wait for antenna movement to complete by monitoring position stability. */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 60000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 60000): Promise { const startTime = Date.now(); let lastPosition = ''; let stableCount = 0; @@ -280,9 +258,7 @@ async function waitForAntennaMovement( await page.waitForTimeout(1000); // Get current elevation from the fine-adjust control display - let elDisplay = page - .locator('.fine-adjust-control', { hasText: 'Elevation' }) - .locator('.fine-adjust-value-active'); + let elDisplay = page.locator('.fine-adjust-control', { hasText: 'Elevation' }).locator('.fine-adjust-value-active'); // Fallback: try finding by ID pattern if ((await elDisplay.count()) === 0) { @@ -316,10 +292,7 @@ async function waitForAntennaMovement( * Wait for step-track beacon lock to be acquired. * The objective requires maintaining lock for 10 seconds. */ -async function waitForBeaconLock( - page: import('@playwright/test').Page, - timeout = 90000 -): Promise { +async function waitForBeaconLock(page: import('@playwright/test').Page, timeout = 90000): Promise { const startTime = Date.now(); while (Date.now() - startTime < timeout) { @@ -460,10 +433,7 @@ async function configureRxModem( * Objective requires 15 seconds of maintained SNR > 8 dB. * Uses cn-effective-display element from receiver adapter. */ -async function waitForRxLock( - page: import('@playwright/test').Page, - timeout = 90000 -): Promise { +async function waitForRxLock(page: import('@playwright/test').Page, timeout = 90000): Promise { const startTime = Date.now(); let lockedTime = 0; @@ -503,11 +473,7 @@ async function waitForRxLock( * Configure TX modem frequency. * Element IDs: tx-frequency-input, tx-apply-btn */ -async function configureTxModem( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - config: { frequency: number } -): Promise { +async function configureTxModem(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, config: { frequency: number }): Promise { // Navigate to TX Chain tab await missionControlPage.selectTab('tx-chain'); await page.waitForTimeout(300); @@ -555,11 +521,7 @@ async function enableTransmitPath(page: import('@playwright/test').Page): Promis /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario6Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario6Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -683,29 +645,29 @@ test.describe('Scenario 6 Full Completion', () => { // ============================================================ test('Objective: Review Mission Brief', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'review-mission-brief')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'review-mission-brief')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Inclined Orbits', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'understand-inclined-orbit')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'understand-inclined-orbit')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Identify Current Target', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'recognize-wrong-satellite')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'recognize-wrong-satellite')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Acquire AURORA-7 via Program-Track', async () => { // Antenna movement can take up to 90 seconds test.setTimeout(120000); - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'program-track-aurora7')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'program-track-aurora7')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Understand Program-Track Limitations', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'quiz-program-track-limitation')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'quiz-program-track-limitation')!; await executeObjective(page, missionControlPage, objective); }); @@ -714,19 +676,19 @@ test.describe('Scenario 6 Full Completion', () => { // ============================================================ test('Objective: Verify Beacon Configuration', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'verify-beacon-config')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'verify-beacon-config')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Enable Step-Track Mode', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'enable-step-track')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'enable-step-track')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Acquire Beacon Lock', async () => { // Beacon lock can take time test.setTimeout(120000); - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'acquire-beacon-lock')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'acquire-beacon-lock')!; await executeObjective(page, missionControlPage, objective); }); @@ -735,19 +697,19 @@ test.describe('Scenario 6 Full Completion', () => { // ============================================================ test('Objective: Configure Spectrum Analyzer for Downlink', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'configure-speca-downlink')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'configure-speca-downlink')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Configure RX Modem', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'configure-rx-modem')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'configure-rx-modem')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify RX Signal Lock', async () => { // RX lock with SNR threshold can take time test.setTimeout(120000); - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'verify-rx-lock')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'verify-rx-lock')!; await executeObjective(page, missionControlPage, objective); }); @@ -756,7 +718,7 @@ test.describe('Scenario 6 Full Completion', () => { // ============================================================ test('Objective: Verify Encryption Understanding', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'quiz-encryption')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'quiz-encryption')!; await executeObjective(page, missionControlPage, objective); }); @@ -765,17 +727,17 @@ test.describe('Scenario 6 Full Completion', () => { // ============================================================ test('Objective: Calculate TX IF Frequency', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'calculate-tx-if')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'calculate-tx-if')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Configure TX Modem', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'configure-tx-modem')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'configure-tx-modem')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Enable Transmit Path', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'enable-transmit-path')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'enable-transmit-path')!; await executeObjective(page, missionControlPage, objective); }); @@ -784,7 +746,7 @@ test.describe('Scenario 6 Full Completion', () => { // ============================================================ test('Objective: Full Duplex Established', async () => { - const objective = SCENARIO_6_OBJECTIVES.find(o => o.id === 'final-verification')!; + const objective = SCENARIO_6_OBJECTIVES.find((o) => o.id === 'final-verification')!; await executeObjective(page, missionControlPage, objective); }); diff --git a/e2e/specs/scenario7-full-completion.spec.ts b/e2e/specs/scenario7-full-completion.spec.ts index ccf7662c..6f025ee2 100644 --- a/e2e/specs/scenario7-full-completion.spec.ts +++ b/e2e/specs/scenario7-full-completion.spec.ts @@ -1,10 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 7 objectives - Uplink Validation: Transmit Enable Sequence & Power Verification. @@ -328,11 +324,7 @@ const SCENARIO_7_OBJECTIVES: Scenario7Objective[] = [ /** * Toggle a switch to a desired state. */ -async function toggleSwitch( - page: import('@playwright/test').Page, - switchId: string, - desiredState: boolean -): Promise { +async function toggleSwitch(page: import('@playwright/test').Page, switchId: string, desiredState: boolean): Promise { const switchEl = page.locator(`#${switchId}`); await expect(switchEl).toBeVisible({ timeout: 5000 }); const isChecked = await switchEl.isChecked(); @@ -354,10 +346,7 @@ async function toggleSwitch( * Configure spectrum analyzer settings. * Uses same element IDs as scenario3: #sa-center-freq, #sa-span, #sa-min-amp, #sa-max-amp */ -async function configureSpectrumAnalyzer( - page: import('@playwright/test').Page, - config: NonNullable -): Promise { +async function configureSpectrumAnalyzer(page: import('@playwright/test').Page, config: NonNullable): Promise { // Center frequency (in MHz) if (config.centerFrequency !== undefined) { const centerInput = page.locator('#sa-center-freq'); @@ -411,10 +400,7 @@ async function configureSpectrumAnalyzer( /** * Configure LNB LO frequency. */ -async function configureLnbLo( - page: import('@playwright/test').Page, - loFrequency: number -): Promise { +async function configureLnbLo(page: import('@playwright/test').Page, loFrequency: number): Promise { const loInput = page.locator('#lnb-lo-frequency'); await expect(loInput).toBeVisible({ timeout: 5000 }); await loInput.fill(loFrequency.toString()); @@ -433,10 +419,7 @@ async function configureLnbLo( /** * Configure TX modem settings. */ -async function configureTxModem( - page: import('@playwright/test').Page, - config: NonNullable -): Promise { +async function configureTxModem(page: import('@playwright/test').Page, config: NonNullable): Promise { // Frequency (in MHz) if (config.frequency !== undefined) { const freqInput = page.locator('#tx-frequency-input'); @@ -502,10 +485,7 @@ async function configureTxModem( /** * Configure BUC gain. */ -async function configureBucGain( - page: import('@playwright/test').Page, - gain: number -): Promise { +async function configureBucGain(page: import('@playwright/test').Page, gain: number): Promise { const gainInput = page.locator('#buc-gain'); await expect(gainInput).toBeVisible({ timeout: 5000 }); await gainInput.fill(gain.toString()); @@ -523,10 +503,7 @@ async function configureBucGain( * Configure HPA backoff to achieve target power output. * Lower backoff = higher output power. */ -async function configureHpaBackoff( - page: import('@playwright/test').Page, - backoff: number -): Promise { +async function configureHpaBackoff(page: import('@playwright/test').Page, backoff: number): Promise { const backoffInput = page.locator('#hpa-backoff'); await expect(backoffInput).toBeVisible({ timeout: 5000 }); await backoffInput.fill(backoff.toString()); @@ -561,11 +538,7 @@ async function closeQuizModalIfPresent(page: import('@playwright/test').Page): P /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario7Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario7Objective): Promise { // For non-quiz objectives, close any blocking quiz modal first if (objective.type !== 'quiz') { await closeQuizModalIfPresent(page); @@ -641,6 +614,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'toggle-switch': @@ -732,7 +709,7 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Review Mission Brief', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'review-mission-brief')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'review-mission-brief')!; await executeObjective(page, missionControlPage, objective); }); @@ -741,67 +718,67 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Access Vermont Ground Station', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'select-vermont-station')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'select-vermont-station')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Check Dashboard for Alarms', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'check-dashboard-status')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'check-dashboard-status')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Identify Active Alarms', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'check-dashboard-status-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'check-dashboard-status-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Diagnose BUC High Current - Navigate to TX Chain', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'diagnose-buc-high-current')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'diagnose-buc-high-current')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Diagnose BUC High Current - Identify Cause', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'diagnose-buc-high-current-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'diagnose-buc-high-current-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Resolve BUC High Current - Mute BUC', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'resolve-buc-high-current-mute')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'resolve-buc-high-current-mute')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Resolve BUC High Current - Disable Loopback', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'resolve-buc-high-current-loopback')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'resolve-buc-high-current-loopback')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Fault Cleared - Navigate to Dashboard', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-fault-cleared')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-fault-cleared')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Fault Cleared - Confirm Alarm Cleared', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-fault-cleared-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-fault-cleared-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Antenna Status - Navigate to ACU Control', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-antenna-status')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-antenna-status')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Antenna Status - Confirm Tracking', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-antenna-status-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-antenna-status-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify LNB Operational - Navigate to RX Analysis', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-lnb-operational')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-lnb-operational')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify LNB Operational - Noise Temperature Understanding', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-lnb-operational-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-lnb-operational-quiz')!; await executeObjective(page, missionControlPage, objective); }); @@ -810,22 +787,22 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Acquire Beacon - Configure Spectrum Analyzer', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'acquire-beacon-speca')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'acquire-beacon-speca')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Acquire Beacon - Beacon Purpose Quiz', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'acquire-beacon-quiz1')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'acquire-beacon-quiz1')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Acquire Beacon - Beacon Identification Quiz', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'acquire-beacon-quiz2')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'acquire-beacon-quiz2')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm Beacon IF Frequency', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'quiz-beacon-frequency')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'quiz-beacon-frequency')!; await executeObjective(page, missionControlPage, objective); }); @@ -834,17 +811,17 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Calculate TX IF Frequency', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'calculate-tx-if')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'calculate-tx-if')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Configure TX Modem - Navigate to TX Chain', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'configure-tx-modem')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'configure-tx-modem')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Configure TX Modem - Set Parameters', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'configure-tx-modem-settings')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'configure-tx-modem-settings')!; await executeObjective(page, missionControlPage, objective); }); @@ -853,47 +830,47 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Reduce BUC Gain for Loopback', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'reduce-buc-gain')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'reduce-buc-gain')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Enable BUC Loopback', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'enable-loopback-switch')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'enable-loopback-switch')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Unmute BUC', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'enable-loopback-unmute')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'enable-loopback-unmute')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Loopback Mode Understanding', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'enable-loopback-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'enable-loopback-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Loopback Signal - Navigate to RX Analysis', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-loopback-signal-tab')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-loopback-signal-tab')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Loopback Signal - Set LNB LO', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-loopback-signal-lnb')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-loopback-signal-lnb')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Loopback Signal - Configure Spectrum Analyzer', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-loopback-signal-speca')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-loopback-signal-speca')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Loopback Signal - Confirm Signal', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-loopback-signal-quiz')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-loopback-signal-quiz')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Confirm Loopback Understanding', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'quiz-loopback-purpose')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'quiz-loopback-purpose')!; await executeObjective(page, missionControlPage, objective); }); @@ -902,22 +879,22 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Verify Encryption Status - Navigate to TX Chain', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'quiz-encryption-status-tab')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'quiz-encryption-status-tab')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Verify Encryption Status', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'quiz-encryption-status')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'quiz-encryption-status')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Disable Loopback Mode', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'disable-loopback')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'disable-loopback')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Enable HPA Output', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'enable-hpa-output')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'enable-hpa-output')!; await executeObjective(page, missionControlPage, objective); }); @@ -926,12 +903,12 @@ test.describe('Scenario 7 Full Completion', () => { // ============================================================ test('Objective: Increase HPA Output Power', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'verify-hpa-power')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'verify-hpa-power')!; await executeObjective(page, missionControlPage, objective); }); test('Objective: Final Configuration Verification', async () => { - const objective = SCENARIO_7_OBJECTIVES.find(o => o.id === 'final-verification')!; + const objective = SCENARIO_7_OBJECTIVES.find((o) => o.id === 'final-verification')!; await executeObjective(page, missionControlPage, objective); }); diff --git a/e2e/specs/scenario9-full-completion.spec.ts b/e2e/specs/scenario9-full-completion.spec.ts index 018f6538..2d250c8f 100644 --- a/e2e/specs/scenario9-full-completion.spec.ts +++ b/e2e/specs/scenario9-full-completion.spec.ts @@ -1,11 +1,6 @@ import { expect, test } from '@playwright/test'; import { MissionControlPage } from '../pages/mission-control.page'; -import { - answerQuizByText, - dismissDialogIfPresent, - waitForQuizToAppear, - waitForSimulationReady, -} from '../utils/simulation-helpers'; +import { answerQuizByText, dismissDialogIfPresent, waitForQuizToAppear, waitForSimulationReady } from '../utils/simulation-helpers'; /** * Scenario 9 - "Morning Rounds": Multi-Satellite Health Check. @@ -22,13 +17,7 @@ import { * - 'set-tracking-mode': Antenna tracking mode + satellite selection * - 'configure-speca': Spectrum analyzer center frequency tuning */ -type ObjectiveType = - | 'quiz' - | 'select-station' - | 'click-tab' - | 'auto' - | 'set-tracking-mode' - | 'configure-speca'; +type ObjectiveType = 'quiz' | 'select-station' | 'click-tab' | 'auto' | 'set-tracking-mode' | 'configure-speca'; interface Scenario9Objective { id: string; @@ -146,15 +135,13 @@ const SCENARIO_9_OBJECTIVES: Scenario9Objective[] = [ id: 'me-verify-tracking', title: 'ME-02 Antenna Tracking Check', type: 'quiz', - correctAnswer: - 'GEO satellite holding station - ephemeris is accurate enough; no need to hunt the beacon', + correctAnswer: 'GEO satellite holding station - ephemeris is accurate enough; no need to hunt the beacon', }, { id: 'me-tx-payload-spot', title: 'ME-02 Customer Traffic Indicator', type: 'quiz', - correctAnswer: - 'Frame sync locked + CRC valid + FEC engaged (Reed-Solomon active, no uncorrectables)', + correctAnswer: 'Frame sync locked + CRC valid + FEC engaged (Reed-Solomon active, no uncorrectables)', }, // ============================================================ @@ -198,8 +185,7 @@ const SCENARIO_9_OBJECTIVES: Scenario9Objective[] = [ id: 'tidemark3-beacon-quality', title: 'Interpret TIDEMARK-3 Beacon', type: 'quiz', - correctAnswer: - 'Antenna pointing is correct AND LNB LO is set correctly (5250 - 4172 = 1078)', + correctAnswer: 'Antenna pointing is correct AND LNB LO is set correctly (5250 - 4172 = 1078)', }, { id: 'return-acu-tab', @@ -243,15 +229,13 @@ const SCENARIO_9_OBJECTIVES: Scenario9Objective[] = [ id: 'final-alarm-sweep', title: 'Final Alarm Sweep', type: 'quiz', - correctAnswer: - 'TIDEMARK-1 healthy, TIDEMARK-2 healthy, TIDEMARK-3 beacon verified - all three nominal', + correctAnswer: 'TIDEMARK-1 healthy, TIDEMARK-2 healthy, TIDEMARK-3 beacon verified - all three nominal', }, { id: 'log-shift-summary', title: 'Log Shift Summary', type: 'quiz', - correctAnswer: - '0700 - Morning rounds complete. VT-01/TM-1, ME-02/TM-2, TM-3 beacon verified via VT-01 spot-check. No anomalies.', + correctAnswer: '0700 - Morning rounds complete. VT-01/TM-1, ME-02/TM-2, TM-3 beacon verified via VT-01 spot-check. No anomalies.', }, ]; @@ -263,10 +247,7 @@ const SCENARIO_9_OBJECTIVES: Scenario9Objective[] = [ * Set the antenna tracking mode by clicking the appropriate button. * ACU control tab must be active before calling this. */ -async function setTrackingMode( - page: import('@playwright/test').Page, - trackingMode: string -): Promise { +async function setTrackingMode(page: import('@playwright/test').Page, trackingMode: string): Promise { const modeButton = page.locator(`.btn-tracking[data-mode="${trackingMode}"]`); await expect(modeButton).toBeVisible({ timeout: 5000 }); await modeButton.click(); @@ -284,10 +265,7 @@ async function setTrackingMode( * Select a target satellite from the dropdown and click Move to Target. * Used after setTrackingMode('program-track'). */ -async function selectSatelliteAndMove( - page: import('@playwright/test').Page, - satelliteNoradId: string -): Promise { +async function selectSatelliteAndMove(page: import('@playwright/test').Page, satelliteNoradId: string): Promise { const satelliteSelect = page.locator('select[id$="satellite-select"]'); await expect(satelliteSelect).toBeVisible({ timeout: 5000 }); await satelliteSelect.selectOption({ value: satelliteNoradId }); @@ -302,10 +280,7 @@ async function selectSatelliteAndMove( /** * Wait for antenna movement to complete by watching elevation stability. */ -async function waitForAntennaMovement( - page: import('@playwright/test').Page, - timeout = 90000 -): Promise { +async function waitForAntennaMovement(page: import('@playwright/test').Page, timeout = 90000): Promise { const startTime = Date.now(); let lastPosition = ''; let stableCount = 0; @@ -315,9 +290,7 @@ async function waitForAntennaMovement( while (Date.now() - startTime < timeout) { await page.waitForTimeout(1000); - let elDisplay = page - .locator('.fine-adjust-control', { hasText: 'Elevation' }) - .locator('.fine-adjust-value-active'); + let elDisplay = page.locator('.fine-adjust-control', { hasText: 'Elevation' }).locator('.fine-adjust-value-active'); if ((await elDisplay.count()) === 0) { elDisplay = page.locator('[id*="el-fine"][id$="-value"]'); @@ -346,10 +319,7 @@ async function waitForAntennaMovement( /** * Configure spectrum analyzer center frequency. Span is optional. */ -async function configureSpectrumAnalyzer( - page: import('@playwright/test').Page, - config: { centerFrequency: number; span?: number } -): Promise { +async function configureSpectrumAnalyzer(page: import('@playwright/test').Page, config: { centerFrequency: number; span?: number }): Promise { const centerFreqInput = page.locator('#sa-center-freq'); await expect(centerFreqInput).toBeVisible({ timeout: 5000 }); await centerFreqInput.fill(config.centerFrequency.toString()); @@ -371,11 +341,7 @@ async function configureSpectrumAnalyzer( /** * Execute an objective based on its type. */ -async function executeObjective( - page: import('@playwright/test').Page, - missionControlPage: MissionControlPage, - objective: Scenario9Objective -): Promise { +async function executeObjective(page: import('@playwright/test').Page, missionControlPage: MissionControlPage, objective: Scenario9Objective): Promise { switch (objective.type) { case 'quiz': await waitForQuizToAppear(page); @@ -388,6 +354,10 @@ async function executeObjective( case 'click-tab': await missionControlPage.selectTab(objective.tabId!); + // Observation-gated conditions latch only after the default dwell on + // the tab (DEFAULT_OBSERVATION_DWELL_SECONDS); leaving at once would + // reset the read and strand the objective. + await page.waitForTimeout(3000); break; case 'set-tracking-mode': diff --git a/e2e/specs/signal-hunter-geolocation.spec.ts b/e2e/specs/signal-hunter-geolocation.spec.ts new file mode 100644 index 00000000..68ceb598 --- /dev/null +++ b/e2e/specs/signal-hunter-geolocation.spec.ts @@ -0,0 +1,105 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Campaign 5 (Signal Hunter) - geolocation console mechanics. + * + * Validates the new two-satellite TDOA/FDOA interference geolocation feature + * end to end against the sandbox scenario: + * - the black-ops campaign theme (slate accent) is applied, + * - the Geolocation tab is registered (opt-in via settings.geolocation), + * - the correlator captures measurements only while the duty-cycled jammer + * transmits (captures during OFF windows report NO CORRELATION and retry), + * - COMPUTE FIX crosses the accumulated lines of position into a position fix. + * + * Capture timing follows the jammer duty cycle (60 s on / 45 s off), so the + * capture step retries across cycles rather than asserting on the first press. + */ + +const CAMPAIGN_ID = 'signal-hunter'; +const SCENARIO_ID = 'signal-hunter-sandbox'; + +/** Number of visible measurement rows, excluding the empty-state placeholder */ +async function measurementCount(page: Page): Promise { + const placeholder = await page.locator('#geo-measurement-rows td[colspan]').count(); + if (placeholder > 0) { + return 0; + } + return page.locator('#geo-measurement-rows tr').count(); +} + +/** Press CAPTURE and wait for the integration window to resolve */ +async function captureUntil(page: Page, target: number): Promise { + const captureBtn = page.locator('#geo-capture-btn'); + let count = 0; + for (let attempt = 0; attempt < 40 && count < target; attempt++) { + if (await captureBtn.isEnabled().catch(() => false)) { + await captureBtn.click().catch(() => undefined); + } + // Wait for this capture to resolve (message updates to a terminal state) + for (let poll = 0; poll < 30; poll++) { + await page.waitForTimeout(500); + const msg = ( + (await page + .locator('#geo-capture-msg') + .textContent() + .catch(() => '')) ?? '' + ).trim(); + if (/^(CAPTURE|NO CORRELATION|CORRELATOR)/.test(msg)) { + break; + } + } + count = await measurementCount(page); + } + return count; +} + +test.describe('Signal Hunter geolocation console', () => { + test.setTimeout(180000); + + test('applies theme, exposes the console, and computes a fix', async ({ page }) => { + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + const missionControl = new MissionControlPage(page); + await page.goto(`/campaigns/${CAMPAIGN_ID}/scenarios/${SCENARIO_ID}`); + await waitForSimulationReady(page); + await dismissDialogIfPresent(page); + + // 1. Black-ops theme is applied via the per-campaign body class + await expect(page.locator('body')).toHaveClass(/campaign-signal-hunter/); + const accent = await page.evaluate(() => getComputedStyle(document.body).getPropertyValue('--mc-accent-red').trim()); + expect(accent.toLowerCase()).toBe('#56708a'); // slate accent from the per-campaign chrome + + // 2. Select the ground station and open the Geolocation tab + await missionControl.selectGroundStation('PA-22'); + await dismissDialogIfPresent(page); + const geoTab = page.locator('.nav-link[data-tab-id="geolocation"]'); + await expect(geoTab).toBeVisible(); + await geoTab.click(); + + // 3. Console renders: correlator controls + geographic map + await expect(page.locator('#geo-adjacent-select')).toBeVisible(); + await expect(page.locator('.geo-map-canvas')).toBeVisible(); + + // 4. Tune the correlator to the interferer uplink (6013 MHz, 5 MHz BW) + await page.locator('#geo-freq-value').fill('6013'); + await page.locator('#geo-freq-value').dispatchEvent('change'); + await page.locator('#geo-bw-value').fill('5'); + await page.locator('#geo-bw-value').dispatchEvent('change'); + + // 5. Capture across duty cycles until at least 3 measurements arrive + const count = await captureUntil(page, 3); + expect(count).toBeGreaterThanOrEqual(3); + + // 6. Compute the fix - the summary shows a coordinate and error ellipse + await page.locator('#geo-compute-btn').click(); + await page.waitForTimeout(1000); + const summary = ((await page.locator('#geo-fix-summary').textContent()) ?? '').trim(); + expect(summary).toMatch(/-?\d+\.\d+°,\s*-?\d+\.\d+°/); + }); +}); diff --git a/e2e/specs/signal-hunter-scenario1-full-completion.spec.ts b/e2e/specs/signal-hunter-scenario1-full-completion.spec.ts new file mode 100644 index 00000000..b5f01124 --- /dev/null +++ b/e2e/specs/signal-hunter-scenario1-full-completion.spec.ts @@ -0,0 +1,236 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { + answerPendingStatusChecks, + answerStatusCheck, + type CaptureContext, + closeWorkingDocumentIfOpen, + collectCaptures, + computeFixWithin, + type DutyCycle, + debugObjective, + ensureOnWindowStart, + greatCircleKm, + type InterfererPhase, + objectiveItem, + setConsoleInput, + waitForObjectiveComplete, +} from '../utils/signal-hunter-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Signal Hunter (Campaign 5) Scenario 1 "First Fix" - full completion. + * + * The hostile uplink (6021 MHz, 3 MHz, H-pol) rides SENTRY-7 TP-1 on a + * 40 s on / 90 s off cycle and the correlator integrates for 12 s, so every + * capture has to start early in an on-window. The helpers track the + * interferer phase through the window.debugObjective probe and skip the + * off-windows with window.advanceClock between captures; the clock + * jumps also move the inclined SENTRY pair, which rotates the FDOA lines and + * tightens the fix the way real cycles would. + * + * Objective flow: + * 1. review-mission-brief - open the incident package + * 2. detect-interference - interferer observed on RX Analysis while ON + * 3. characterize-duty-cycle - cadence quiz + * 4. open-geolocation-console - Geolocation tab + * 5. collect-measurements - >= 6 captures on the interferer + * 6. compute-fix - fix within 25 km + * 7. refine-fix (optional) - >= 12 captures and fix within 15 km, + * driven BEFORE the report so the Mission + * Complete modal does not freeze the console + * 8. file-incident-report - three characterization quizzes; last + * required objective, asserted via the modal + */ + +const CAMPAIGN_ID = 'signal-hunter'; +const SCENARIO_ID = 'signal-hunter-scenario1'; + +/** Interference event quay-hostile (scenario1.ts) */ +const DUTY: DutyCycle = { onSeconds: 40, periodSeconds: 130 }; +const CAPTURE_WINDOW_S = 12; +const INTERFERER_UPLINK_MHZ = 6021; +const INTERFERER_BW_MHZ = 3; +/** Emitter ground truth: Quay County ranch airstrip */ +const EMITTER = { lat: 35.17, lon: -103.72 }; +/** Objective whose signal-detected condition doubles as the ON/OFF probe */ +const PROBE_OBJECTIVE = 'detect-interference'; + +const log = (line: string): void => console.log(`[signal-hunter-1] ${line}`); + +test.describe('Signal Hunter Scenario 1 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + const phase: InterfererPhase = { onStartMs: null }; + let ctx: CaptureContext; + + let firstFixErrorKm = Number.NaN; + let firstFixCaptures = 0; + let refinedFixErrorKm = Number.NaN; + let refinedFixCaptures = 0; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + ctx = { page, phase, probeObjectiveId: PROBE_OBJECTIVE, duty: DUTY, windowS: CAPTURE_WINDOW_S, log }; + + // Direct navigation (bypasses the sandbox prerequisite card lock) + await missionControl.gotoScenario(CAMPAIGN_ID, SCENARIO_ID); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] opens the incident package', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Incident Package'); + }); + + test('[detect-interference] observes the hostile carrier on RX Analysis during an on-window', async () => { + await missionControl.selectGroundStation('PA-22'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + + // signal-detected latches only while the interferer is on the air AND the + // RX Analysis tab is active, so bring the clock to an on-window start. + await ensureOnWindowStart(ctx); + await missionControl.selectTab('rx-analysis'); + + try { + await waitForObjectiveComplete(missionControl, 'Find the Carrier', 30000); + } catch (error) { + log(`detect-interference debug: ${JSON.stringify(await debugObjective(page, 'detect-interference'))}`); + throw error; + } + }); + + test('[characterize-duty-cycle] reports the 40 s on / 90 s off cadence', async () => { + await answerStatusCheck(page, 'About 40 s on, then about 90 s off'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Time the Cadence'); + }); + + test('[open-geolocation-console] brings up the correlator', async () => { + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await expect(page.locator('#geo-adjacent-select')).toBeVisible(); + await waitForObjectiveComplete(missionControl, 'Bring Up the Correlator'); + }); + + test('[collect-measurements] tunes the correlator and captures six times inside the on-windows', async () => { + // Uplink worked back from the 1354 MHz IF: RF = 5150 - 1354 = 3796, + // uplink = 3796 + 2225 = 6021 MHz; correlation bandwidth matched to 3 MHz + await setConsoleInput(page, '#geo-freq-value', INTERFERER_UPLINK_MHZ); + await setConsoleInput(page, '#geo-bw-value', INTERFERER_BW_MHZ); + await expect(page.locator('#geo-freq-value')).toHaveValue(String(INTERFERER_UPLINK_MHZ)); + await expect(page.locator('#geo-bw-value')).toHaveValue(String(INTERFERER_BW_MHZ)); + + const count = await collectCaptures(ctx, 6); + expect(count).toBeGreaterThanOrEqual(6); + + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Capture Inside the Window'); + }); + + test('[compute-fix] solves a fix within 25 km of the emitter', async () => { + const result = await computeFixWithin(ctx, EMITTER, 25, { extraPerRound: 3, maxRounds: 4 }); + firstFixErrorKm = result.errorKm; + firstFixCaptures = result.captures; + + // Independent check of the console's grading: great-circle error against truth + expect(greatCircleKm(result.fix, EMITTER)).toBeLessThanOrEqual(25); + + await dismissDialogIfPresent(page); + try { + await waitForObjectiveComplete(missionControl, 'First Fix'); + } catch (error) { + log(`compute-fix debug: ${JSON.stringify(await debugObjective(page, 'compute-fix'))}`); + throw error; + } + }); + + test('[refine-fix] closes the ellipse to 15 km with twelve or more captures', async () => { + // Driven before the report quizzes: file-incident-report is the last + // REQUIRED objective and its completion pops the Mission Complete modal. + await dismissDialogIfPresent(page); + const count = await collectCaptures(ctx, 12); + expect(count).toBeGreaterThanOrEqual(12); + + const result = await computeFixWithin(ctx, EMITTER, 15, { extraPerRound: 3, maxRounds: 4 }); + refinedFixErrorKm = result.errorKm; + refinedFixCaptures = result.captures; + expect(greatCircleKm(result.fix, EMITTER)).toBeLessThanOrEqual(15); + + await dismissDialogIfPresent(page); + try { + await waitForObjectiveComplete(missionControl, 'Close the Ellipse'); + } catch (error) { + log(`refine-fix debug: ${JSON.stringify(await debugObjective(page, 'refine-fix'))}`); + throw error; + } + }); + + test('[file-incident-report] files the characterization and completes the mission', async () => { + // Three status-checks on one objective: the QuizManager surfaces the last + // registered (polarization) first, then promotes the remaining ones, so + // answer by question text rather than by condition order. + const seen = await answerPendingStatusChecks( + page, + [ + { question: /DUTY CYCLE/, answer: 'Approximately 30%' }, + { question: /OCCUPIED BANDWIDTH/, answer: 'About 3 MHz' }, + { question: /POLARIZATION/, answer: 'Horizontal - TP-1 is an H-pol transponder' }, + ], + 3 + ); + expect(new Set(seen).size).toBe(3); + await dismissDialogIfPresent(page); + + // Last required objective: its checklist row never repaints once the + // completion flow starts, so assert the Mission Complete modal instead. + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal .complete-modal__title')).toContainText('Mission Complete'); + }); + + test('verifies mission complete with the optional refine-fix objective completed', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + + const totalScore = levelCompleteModal.locator('.total-value'); + await expect(totalScore).toBeVisible(); + const score = parseInt((await totalScore.textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + + // The checklist is frozen in its final state under the modal: the + // optional stretch objective was driven and must show completed. + const refine = objectiveItem(missionControl, 'Close the Ellipse'); + await expect(refine).toHaveCount(1); + await expect(refine).toHaveClass(/completed/); + + log( + `first fix: ${firstFixErrorKm.toFixed(2)} km from ${firstFixCaptures} captures; ` + + `refined fix: ${refinedFixErrorKm.toFixed(2)} km from ${refinedFixCaptures} captures; score ${score}` + ); + }); +}); diff --git a/e2e/specs/signal-hunter-scenario2-full-completion.spec.ts b/e2e/specs/signal-hunter-scenario2-full-completion.spec.ts new file mode 100644 index 00000000..83a9e386 --- /dev/null +++ b/e2e/specs/signal-hunter-scenario2-full-completion.spec.ts @@ -0,0 +1,208 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { + answerPendingStatusChecks, + answerStatusCheck, + type CaptureContext, + captureOnce, + closeWorkingDocumentIfOpen, + collectCaptures, + computeFixWithin, + type DutyCycle, + debugObjective, + ensureOnWindowStart, + greatCircleKm, + type InterfererPhase, + objectiveItem, + setConsoleInput, + waitForObjectiveComplete, +} from '../utils/signal-hunter-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Signal Hunter (Campaign 5) Scenario 2 "Two Carriers" - full completion + * (phase 18 F). + * + * Two carriers off the plan on one trace: the uplink interferer through TP-1 + * (6001 MHz -> 1374 MHz IF, 36 s on / 84 s off) and a terrestrial + * fixed-wireless sector arriving at the dish directly (3752 MHz -> 1398 MHz + * IF, continuous). The correlator is tried on the terrestrial carrier first + * and reports NO CORRELATION (nothing on the bird to correlate); the path + * decision is graded on the transponder-/terrestrial-interference-active + * facts with both carriers observed as evidence; then the uplink carrier is + * captured, fixed inside 25 km, refined to 15 km, and the two dispositions + * are filed. + * + * Objective flow (8): + * 1. review-mission-brief 2. find-both-carriers 3. time-the-carriers + * 4. call-the-path 5. collect-measurements 6. compute-fix + * 7. refine-fix (optional) 8. file-the-disposition + */ + +const CAMPAIGN_ID = 'signal-hunter'; +const SCENARIO_ID = 'signal-hunter-scenario2'; + +/** Interference event dalhart-uplink (scenario2.ts) */ +const DUTY: DutyCycle = { onSeconds: 36, periodSeconds: 120 }; +const CAPTURE_WINDOW_S = 12; +const UPLINK_MHZ = 6001; +const UPLINK_BW_MHZ = 2.5; +/** The terrestrial carrier's would-be uplink (3752 + 2225): below TP-1, nothing to correlate */ +const TERRESTRIAL_WOULD_BE_UPLINK_MHZ = 5977; +const TERRESTRIAL_BW_MHZ = 5; +/** Emitter ground truth: grain elevator lot outside Dalhart, Texas */ +const EMITTER = { lat: 36.06, lon: -102.52 }; +/** Objective whose first signal-detected condition (the uplink carrier) is the ON/OFF probe */ +const PROBE_OBJECTIVE = 'find-both-carriers'; + +const log = (line: string): void => console.log(`[signal-hunter-2] ${line}`); + +test.describe('Signal Hunter Scenario 2 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + const phase: InterfererPhase = { onStartMs: null }; + let ctx: CaptureContext; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + ctx = { page, phase, probeObjectiveId: PROBE_OBJECTIVE, duty: DUTY, windowS: CAPTURE_WINDOW_S, log }; + + await missionControl.gotoScenario(CAMPAIGN_ID, SCENARIO_ID); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] opens the incident package', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Incident Package'); + }); + + test('[find-both-carriers] observes the cycling and the continuous carrier on RX Analysis', async () => { + await missionControl.selectGroundStation('PA-22'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + + // The terrestrial carrier is always up; the uplink carrier only inside its on-window + await ensureOnWindowStart(ctx); + await missionControl.selectTab('rx-analysis'); + try { + await waitForObjectiveComplete(missionControl, 'Find Both Carriers', 30000); + } catch (error) { + log(`find-both-carriers debug: ${JSON.stringify(await debugObjective(page, 'find-both-carriers'))}`); + throw error; + } + }); + + test('[time-the-carriers] logs the cadence of one and the absence of one', async () => { + await answerStatusCheck(page, 'The 1374 carrier is up about 36 s in every 120 s'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Time What Cycles'); + }); + + test('[call-the-path] the correlator cannot see the terrestrial carrier; the split call grades correct', async () => { + // Try the correlator on the continuous carrier: no event on the bird matches, so NO CORRELATION + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await setConsoleInput(page, '#geo-freq-value', TERRESTRIAL_WOULD_BE_UPLINK_MHZ); + await setConsoleInput(page, '#geo-bw-value', TERRESTRIAL_BW_MHZ); + const result = await captureOnce(page, CAPTURE_WINDOW_S); + log(`capture on the terrestrial carrier: ${result}`); + expect(result).toMatch(/NO CORRELATION/); + + // Evidence: both carriers observed again on RX Analysis inside an on-window + await ensureOnWindowStart(ctx); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(3500); + await answerDecision(page, 'The 1374 carrier is on the transponder', ['Cycling carrier on the trace', 'Continuous carrier on the trace']); + await dismissDialogIfPresent(page); + try { + await waitForObjectiveComplete(missionControl, 'Call the Path'); + } catch (error) { + log(`call-the-path debug: ${JSON.stringify(await debugObjective(page, 'call-the-path'))}`); + throw error; + } + }); + + test('[collect-measurements] captures the uplink carrier six times inside the on-windows', async () => { + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await setConsoleInput(page, '#geo-freq-value', UPLINK_MHZ); + await setConsoleInput(page, '#geo-bw-value', UPLINK_BW_MHZ); + await expect(page.locator('#geo-freq-value')).toHaveValue(String(UPLINK_MHZ)); + + const count = await collectCaptures(ctx, 6); + expect(count).toBeGreaterThanOrEqual(6); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Capture the Uplink Carrier'); + }); + + test('[compute-fix] solves a fix within 25 km of the emitter', async () => { + const result = await computeFixWithin(ctx, EMITTER, 25, { extraPerRound: 3, maxRounds: 4 }); + expect(greatCircleKm(result.fix, EMITTER)).toBeLessThanOrEqual(25); + await dismissDialogIfPresent(page); + try { + await waitForObjectiveComplete(missionControl, 'Fix the Uplink Carrier'); + } catch (error) { + log(`compute-fix debug: ${JSON.stringify(await debugObjective(page, 'compute-fix'))}`); + throw error; + } + }); + + test('[refine-fix] closes the ellipse to 15 km with twelve or more captures', async () => { + await dismissDialogIfPresent(page); + const count = await collectCaptures(ctx, 12); + expect(count).toBeGreaterThanOrEqual(12); + const result = await computeFixWithin(ctx, EMITTER, 15, { extraPerRound: 3, maxRounds: 4 }); + expect(greatCircleKm(result.fix, EMITTER)).toBeLessThanOrEqual(15); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Close the Ellipse'); + }); + + test('[file-the-disposition] files the frequency and the referral and completes the mission', async () => { + const seen = await answerPendingStatusChecks( + page, + [ + { question: /FREQUENCY/, answer: '6001 MHz (3776 MHz downlink' }, + { question: /DISPOSITION/, answer: 'Referred to spectrum management' }, + ], + 2 + ); + expect(new Set(seen).size).toBe(2); + await dismissDialogIfPresent(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal .complete-modal__title')).toContainText('Mission Complete'); + }); + + test('verifies mission complete with the optional refine-fix objective completed', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + const score = parseInt((await levelCompleteModal.locator('.total-value').textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + const refine = objectiveItem(missionControl, 'Close the Ellipse'); + await expect(refine).toHaveClass(/completed/); + log(`score ${score}`); + }); +}); diff --git a/e2e/specs/signal-hunter-scenario3-full-completion.spec.ts b/e2e/specs/signal-hunter-scenario3-full-completion.spec.ts new file mode 100644 index 00000000..589d6bb0 --- /dev/null +++ b/e2e/specs/signal-hunter-scenario3-full-completion.spec.ts @@ -0,0 +1,209 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { advanceMissionClockToElapsed } from '../utils/ccs-helpers'; +import { + answerPendingStatusChecks, + answerStatusCheck, + type CaptureContext, + closeWorkingDocumentIfOpen, + collectCaptures, + computeFixWithin, + type DutyCycle, + debugObjective, + domClick, + ensureOnWindowStart, + greatCircleKm, + type InterfererPhase, + measurementCount, + setConsoleInput, + waitForObjectiveComplete, +} from '../utils/signal-hunter-helpers'; +import { dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Signal Hunter (Campaign 5) Scenario 3 "Cold Trail" - full completion + * (phase 18 F). + * + * The first carrier (6018 MHz -> 1357 MHz IF, from a site near Clayton NM) + * is found, captured six times, fixed inside 25 km, and briefed to the team. + * Its envelope closes at T+1520; the clock is jumped past it, the cold-trail + * call is made (the objective is gated on interference-event-ended), and at + * T+1800 the second carrier (6001 MHz -> 1374 MHz IF, ~80 km south-west) + * appears. The console is CLEARed, retuned, the second carrier captured and + * fixed on its own, and the mobility and current-fix fields are filed. + * + * Objective flow (10): + * 1. review-mission-brief 2. find-carrier-a 3. capture-carrier-a + * 4. fix-carrier-a 5. brief-the-team 6. the-trail-goes-cold + * 7. find-carrier-b 8. capture-carrier-b 9. fix-carrier-b + * 10. file-the-trail + */ + +const CAMPAIGN_ID = 'signal-hunter'; +const SCENARIO_ID = 'signal-hunter-scenario3'; + +const DUTY: DutyCycle = { onSeconds: 36, periodSeconds: 120 }; +const CAPTURE_WINDOW_S = 12; +const FIRST = { uplinkMHz: 6018, bwMHz: 2.5, emitter: { lat: 36.45, lon: -103.18 }, endsAtS: 1520 }; +const SECOND = { uplinkMHz: 6001, bwMHz: 2.5, emitter: { lat: 36.02, lon: -103.9 }, startsAtS: 1800 }; + +const log = (line: string): void => console.log(`[signal-hunter-3] ${line}`); + +test.describe('Signal Hunter Scenario 3 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + const phaseA: InterfererPhase = { onStartMs: null }; + const phaseB: InterfererPhase = { onStartMs: null }; + let ctxA: CaptureContext; + let ctxB: CaptureContext; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + ctxA = { page, phase: phaseA, probeObjectiveId: 'find-carrier-a', duty: DUTY, windowS: CAPTURE_WINDOW_S, log }; + ctxB = { page, phase: phaseB, probeObjectiveId: 'find-carrier-b', duty: DUTY, windowS: CAPTURE_WINDOW_S, log }; + + await missionControl.gotoScenario(CAMPAIGN_ID, SCENARIO_ID); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] opens the incident package', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Incident Package'); + }); + + test('[find-carrier-a] observes the first carrier below the service carrier', async () => { + await missionControl.selectGroundStation('PA-22'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await ensureOnWindowStart(ctxA); + await missionControl.selectTab('rx-analysis'); + try { + await waitForObjectiveComplete(missionControl, 'Find the Carrier Below the Service', 30000); + } catch (error) { + log(`find-carrier-a debug: ${JSON.stringify(await debugObjective(page, 'find-carrier-a'))}`); + throw error; + } + }); + + test('[capture-carrier-a] captures the first carrier six times', async () => { + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await setConsoleInput(page, '#geo-freq-value', FIRST.uplinkMHz); + await setConsoleInput(page, '#geo-bw-value', FIRST.bwMHz); + const count = await collectCaptures(ctxA, 6); + expect(count).toBeGreaterThanOrEqual(6); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Capture the First Carrier'); + }); + + test('[fix-carrier-a] fixes the first carrier inside 25 km', async () => { + const result = await computeFixWithin(ctxA, FIRST.emitter, 25, { extraPerRound: 3, maxRounds: 4 }); + expect(greatCircleKm(result.fix, FIRST.emitter)).toBeLessThanOrEqual(25); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Fix the First Carrier'); + }); + + test('[brief-the-team] hands the team an ellipse, a cadence, and a standing instruction', async () => { + await answerStatusCheck(page, 'A 95% ellipse and its centre'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Brief the Team'); + }); + + test('[the-trail-goes-cold] the first carrier stops; the team is held short', async () => { + // Past the first carrier's envelope: two silent cycles and change + await advanceMissionClockToElapsed(page, FIRST.endsAtS + 250, 0); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(1500); + await answerStatusCheck(page, 'Tell the team to hold short and stand by'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + try { + await waitForObjectiveComplete(missionControl, 'The Trail Goes Cold'); + } catch (error) { + log(`the-trail-goes-cold debug: ${JSON.stringify(await debugObjective(page, 'the-trail-goes-cold'))}`); + throw error; + } + }); + + test('[find-carrier-b] observes the new carrier above the service carrier', async () => { + await advanceMissionClockToElapsed(page, SECOND.startsAtS + 2, 0); + await missionControl.selectTab('rx-analysis'); + await ensureOnWindowStart(ctxB); + await missionControl.selectTab('rx-analysis'); + try { + await waitForObjectiveComplete(missionControl, 'New Carrier Above the Service', 30000); + } catch (error) { + log(`find-carrier-b debug: ${JSON.stringify(await debugObjective(page, 'find-carrier-b'))}`); + throw error; + } + }); + + test('[capture-carrier-b] clears the console, retunes, and captures the second carrier six times', async () => { + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await domClick(page, '#geo-clear-btn'); + await expect.poll(() => measurementCount(page)).toBe(0); + await setConsoleInput(page, '#geo-freq-value', SECOND.uplinkMHz); + await setConsoleInput(page, '#geo-bw-value', SECOND.bwMHz); + const count = await collectCaptures(ctxB, 6); + expect(count).toBeGreaterThanOrEqual(6); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Clear and Recapture'); + }); + + test('[fix-carrier-b] fixes the second carrier inside 25 km on its own captures', async () => { + const result = await computeFixWithin(ctxB, SECOND.emitter, 25, { extraPerRound: 3, maxRounds: 4 }); + expect(greatCircleKm(result.fix, SECOND.emitter)).toBeLessThanOrEqual(25); + // The two sites are ~80 km apart: a clean second solve is nowhere near the first + expect(greatCircleKm(result.fix, FIRST.emitter)).toBeGreaterThan(40); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Fix the Second Carrier'); + }); + + test('[file-the-trail] files mobility and the current fix and completes the mission', async () => { + const seen = await answerPendingStatusChecks( + page, + [ + { question: /MOBILITY/, answer: 'Mobile: retuned from 6018 MHz to 6001 MHz' }, + { question: /FIX\./, answer: 'Fix 2 (6001 MHz) as current' }, + ], + 2 + ); + expect(new Set(seen).size).toBe(2); + await dismissDialogIfPresent(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal .complete-modal__title')).toContainText('Mission Complete'); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + const score = parseInt((await levelCompleteModal.locator('.total-value').textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + log(`score ${score}`); + }); +}); diff --git a/e2e/specs/signal-hunter-scenario4-full-completion.spec.ts b/e2e/specs/signal-hunter-scenario4-full-completion.spec.ts new file mode 100644 index 00000000..7db9a5fd --- /dev/null +++ b/e2e/specs/signal-hunter-scenario4-full-completion.spec.ts @@ -0,0 +1,234 @@ +import { expect, Page, test } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { + advanceMissionClockMs, + answerPendingStatusChecks, + answerStatusCheck, + type CaptureContext, + captureOnce, + closeWorkingDocumentIfOpen, + collectCaptures, + computeFix, + computeFixWithin, + type DutyCycle, + debugObjective, + domClick, + ensureOnWindowStart, + greatCircleKm, + type InterfererPhase, + measurementCount, + setConsoleInput, + waitForObjectiveComplete, +} from '../utils/signal-hunter-helpers'; +import { answerDecision, dismissDialogIfPresent, waitForSimulationReady } from '../utils/simulation-helpers'; + +/** + * Signal Hunter (Campaign 5) Scenario 4 "Prove It" - full completion + * (phase 18 F). + * + * The correlator is calibrated on the Cannon AFB terminal's continuous + * carrier (6003 MHz -> 1372 MHz IF): six captures two minutes apart, fix + * inside 20 km of the base. The console is CLEARed and the hostile + * (6021 MHz -> 1354 MHz IF, 36 s on / 84 s off, ~56 km east of Cannon) is + * captured inside its on-windows, fixed inside 20 km, and the ellipse closed + * to a 20 km semi-major axis (geolocation-ellipse-within). The call is graded + * on transponder-interference-active with the hostile observed as evidence, + * then the basis and confidence fields are filed. + * + * Objective flow (10): + * 1. review-mission-brief 2. find-both-carriers 3. capture-the-reference + * 4. fix-the-reference 5. read-the-calibration 6. clear-and-hunt + * 7. fix-the-hostile 8. prove-it 9. make-the-call + * 10. file-the-proof + */ + +const CAMPAIGN_ID = 'signal-hunter'; +const SCENARIO_ID = 'signal-hunter-scenario4'; + +const DUTY: DutyCycle = { onSeconds: 36, periodSeconds: 120 }; +const CAPTURE_WINDOW_S = 12; +const REFERENCE = { uplinkMHz: 6003, bwMHz: 2, site: { lat: 34.383, lon: -103.322 }, maxErrorKm: 20 }; +const HOSTILE = { uplinkMHz: 6021, bwMHz: 3, emitter: { lat: 34.75, lon: -102.9 }, maxErrorKm: 20, maxSemiMajorKm: 20 }; +/** Objective whose first signal-detected condition (the hostile) is the ON/OFF probe */ +const PROBE_OBJECTIVE = 'find-both-carriers'; + +const log = (line: string): void => console.log(`[signal-hunter-4] ${line}`); + +test.describe('Signal Hunter Scenario 4 Full Completion', () => { + test.describe.configure({ mode: 'serial' }); + + let page: Page; + let context: import('@playwright/test').BrowserContext; + let missionControl: MissionControlPage; + const phase: InterfererPhase = { onStartMs: null }; + let ctx: CaptureContext; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + + await page.addInitScript(() => { + (window as unknown as { AUTO_CLOSE_DIALOGS: boolean }).AUTO_CLOSE_DIALOGS = true; + localStorage.clear(); + sessionStorage.clear(); + }); + + missionControl = new MissionControlPage(page); + ctx = { page, phase, probeObjectiveId: PROBE_OBJECTIVE, duty: DUTY, windowS: CAPTURE_WINDOW_S, log }; + + await missionControl.gotoScenario(CAMPAIGN_ID, SCENARIO_ID); + await waitForSimulationReady(page); + await missionControl.dismissDialogIfPresent(); + }); + + test.afterAll(async () => { + await context.close(); + }); + + test.beforeEach(async () => { + test.setTimeout(300000); + }); + + test('[review-mission-brief] opens the incident package', async () => { + await missionControl.openMissionBrief(); + await missionControl.closeMissionBrief(); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Review the Incident Package'); + }); + + test('[find-both-carriers] observes the hostile and the Cannon reference on RX Analysis', async () => { + await missionControl.selectGroundStation('PA-22'); + await missionControl.selectTab('rx-analysis'); + await dismissDialogIfPresent(page); + await ensureOnWindowStart(ctx); + await missionControl.selectTab('rx-analysis'); + try { + await waitForObjectiveComplete(missionControl, 'Find the Reference and the Hostile', 30000); + } catch (error) { + log(`find-both-carriers debug: ${JSON.stringify(await debugObjective(page, 'find-both-carriers'))}`); + throw error; + } + }); + + test('[capture-the-reference] captures the continuous reference carrier six times, two minutes apart', async () => { + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await setConsoleInput(page, '#geo-freq-value', REFERENCE.uplinkMHz); + await setConsoleInput(page, '#geo-bw-value', REFERENCE.bwMHz); + for (let i = 0; i < 6; i++) { + const result = await captureOnce(page, CAPTURE_WINDOW_S); + log(`reference capture ${i + 1}: ${result}`); + expect(result).toMatch(/^CAPTURE/); + await advanceMissionClockMs(page, 120000); + } + expect(await measurementCount(page)).toBeGreaterThanOrEqual(6); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Capture the Reference'); + }); + + test('[fix-the-reference] the reference fix lands on Cannon inside 20 km', async () => { + let fix = await computeFix(page); + let errorKm = greatCircleKm(fix, REFERENCE.site); + log(`reference fix: ${fix.lat.toFixed(3)}, ${fix.lon.toFixed(3)} -> ${errorKm.toFixed(1)} km from Cannon`); + for (let round = 0; round < 3 && errorKm > REFERENCE.maxErrorKm; round++) { + for (let i = 0; i < 2; i++) { + await captureOnce(page, CAPTURE_WINDOW_S); + await advanceMissionClockMs(page, 120000); + } + fix = await computeFix(page); + errorKm = greatCircleKm(fix, REFERENCE.site); + log(`reference fix (round ${round + 1}): ${errorKm.toFixed(1)} km from Cannon`); + } + expect(errorKm).toBeLessThanOrEqual(REFERENCE.maxErrorKm); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Fix the Reference'); + }); + + test('[read-the-calibration] says what the calibration proves', async () => { + await answerStatusCheck(page, 'That the pair'); + await dismissDialogIfPresent(page); + await closeWorkingDocumentIfOpen(page); + await waitForObjectiveComplete(missionControl, 'Read the Calibration'); + }); + + test('[clear-and-hunt] clears the console and captures the hostile eight times', async () => { + await missionControl.selectTab('geolocation'); + await dismissDialogIfPresent(page); + await domClick(page, '#geo-clear-btn'); + await expect.poll(() => measurementCount(page)).toBe(0); + await setConsoleInput(page, '#geo-freq-value', HOSTILE.uplinkMHz); + await setConsoleInput(page, '#geo-bw-value', HOSTILE.bwMHz); + phase.onStartMs = null; // the clock has moved a long way since the last on-window was seen + const count = await collectCaptures(ctx, 8); + expect(count).toBeGreaterThanOrEqual(8); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Clear and Hunt'); + }); + + test('[fix-the-hostile] fixes the hostile inside 20 km on its own captures', async () => { + const result = await computeFixWithin(ctx, HOSTILE.emitter, HOSTILE.maxErrorKm, { extraPerRound: 3, maxRounds: 4 }); + expect(greatCircleKm(result.fix, HOSTILE.emitter)).toBeLessThanOrEqual(HOSTILE.maxErrorKm); + expect(greatCircleKm(result.fix, REFERENCE.site)).toBeGreaterThan(30); + await dismissDialogIfPresent(page); + await waitForObjectiveComplete(missionControl, 'Fix the Hostile'); + }); + + test('[prove-it] closes the 95% ellipse to 20 km with twelve or more captures', async () => { + let count = await collectCaptures(ctx, 12); + let fix = await computeFix(page); + log(`ellipse from ${count} captures: ±${fix.semiMajorKm}×${fix.semiMinorKm} km`); + for (let round = 0; round < 4 && (fix.semiMajorKm === null || fix.semiMajorKm > HOSTILE.maxSemiMajorKm); round++) { + count = await collectCaptures(ctx, count + 3); + fix = await computeFix(page); + log(`ellipse from ${count} captures: ±${fix.semiMajorKm}×${fix.semiMinorKm} km`); + } + expect(fix.semiMajorKm).not.toBeNull(); + expect(fix.semiMajorKm!).toBeLessThanOrEqual(HOSTILE.maxSemiMajorKm); + // The ellipse cannot reach the base + expect(greatCircleKm(fix, REFERENCE.site)).toBeGreaterThan(fix.semiMajorKm!); + await dismissDialogIfPresent(page); + try { + await waitForObjectiveComplete(missionControl, 'Close the Ellipse Past Cannon'); + } catch (error) { + log(`prove-it debug: ${JSON.stringify(await debugObjective(page, 'prove-it'))}`); + throw error; + } + }); + + test('[make-the-call] tasks the team with the hostile on the trace', async () => { + await ensureOnWindowStart(ctx); + await missionControl.selectTab('rx-analysis'); + await page.waitForTimeout(3500); + await answerDecision(page, 'Hostile, not Cannon', ['Hostile carrier on the trace']); + await dismissDialogIfPresent(page); + try { + await waitForObjectiveComplete(missionControl, 'Make the Call'); + } catch (error) { + log(`make-the-call debug: ${JSON.stringify(await debugObjective(page, 'make-the-call'))}`); + throw error; + } + }); + + test('[file-the-proof] files the basis and the confidence statement and completes the mission', async () => { + const seen = await answerPendingStatusChecks( + page, + [ + { question: /BASIS/, answer: 'Reference fix on the Cannon terminal' }, + { question: /CONFIDENCE/, answer: 'Emitter assessed inside the reported 95% ellipse' }, + ], + 2 + ); + expect(new Set(seen).size).toBe(2); + await dismissDialogIfPresent(page); + await expect(page.locator('#level-complete-modal')).toBeVisible({ timeout: 45000 }); + await expect(page.locator('#level-complete-modal .complete-modal__title')).toContainText('Mission Complete'); + }); + + test('verifies mission complete', async () => { + const levelCompleteModal = page.locator('#level-complete-modal'); + await expect(levelCompleteModal).toBeVisible({ timeout: 30000 }); + const score = parseInt((await levelCompleteModal.locator('.total-value').textContent()) || '0', 10); + expect(score).toBeGreaterThan(0); + log(`score ${score}`); + }); +}); diff --git a/e2e/utils/ccs-helpers.ts b/e2e/utils/ccs-helpers.ts new file mode 100644 index 00000000..40cca947 --- /dev/null +++ b/e2e/utils/ccs-helpers.ts @@ -0,0 +1,190 @@ +import { expect, Page } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForQuizToAppear } from './simulation-helpers'; + +/** + * Shared helpers for the ccs (Campaign 4, Counter Communications) specs. + * + * SANDSTORM (SS-01) is a professional station, so the operator drives it + * through the Mission Control tabs: one ACU tab per aperture (acu-control-0 + * for the jam dish, acu-control-1 for the look-through monitor), the TX Chain + * tab for the shared BUC/HPA and the two jam exciters (modem 1 = JAM-A, + * modem 2 = JAM-B), and the read-only EA Assessment tab for the denial effect. + * + * Timing rules: + * - objective maintainDuration windows tick on REAL time and must be waited + * out with the EA Assessment tab selected (requiresObservation conditions + * only count while their observationTab is active); + * - hardwareFaultEvents fire on the MISSION clock (missionNowMs), which + * window.advanceClock jumps, so a spec can bring a scheduled trip forward + * without waiting wall time. + * + * Every click goes through DOM dispatch: the checklist box stays open so the + * specs can poll it, and it floats over the canvas. + */ + +/** Click via DOM dispatch, immune to overlapping draggable boxes. */ +export async function domClick(page: Page, selector: string): Promise { + await page + .locator(selector) + .first() + .evaluate((el) => (el as HTMLElement).click()); +} + +/** Answer a Character.SYSTEM status-check quiz and dismiss its feedback panel. */ +export async function answerStatusCheck(page: Page, answerText: string): Promise { + await waitForQuizToAppear(page); + + const option = page.locator('.quiz-option-btn', { hasText: answerText }); + await expect(option).toBeVisible({ timeout: 10000 }); + await option.click(); + + const feedbackContinue = page.locator('#quiz-continue-btn'); + await expect(feedbackContinue).toBeVisible({ timeout: 5000 }); + await feedbackContinue.click(); +} + +/** + * Mission-elapsed seconds as the HardwareFaultManager sees them (the page's + * scenario clock: it pauses for the brief and quizzes, and includes skips). + */ +export async function missionElapsedS(page: Page): Promise { + const elapsedMs = await page.evaluate(() => { + const hook = (window as unknown as { missionElapsedMs?: () => number }).missionElapsedMs; + return typeof hook === 'function' ? hook() : 0; + }); + return elapsedMs / 1000; +} + +/** + * Skip the clock so mission-elapsed is at least `targetS` (plus `marginS`). + * No-op if already past it. Used to bring a scheduled hardwareFaultEvent + * forward; the fault fires on the next UPDATE tick. + */ +export async function advanceMissionClockToElapsed(page: Page, targetS: number, marginS = 10): Promise { + await page.waitForFunction(() => typeof (window as any).advanceClock === 'function'); + const elapsed = await missionElapsedS(page); + const deltaS = targetS + marginS - elapsed; + if (deltaS > 0) { + await page.evaluate((ms) => (window as any).advanceClock(ms), deltaS * 1000); + } + await page.waitForTimeout(2500); +} + +/** + * Slew an aperture in MANUAL mode from the ACU tab: click the fine-adjust + * step buttons to stage the delta, then APPLY. Resolves once the staged + * change is applied - the pedestal keeps slewing at maxRate_deg_s after that. + */ +export async function slewAntenna(page: Page, missionControl: MissionControlPage, antennaIndex: number, deltaAzDeg: number, deltaElDeg: number): Promise { + await missionControl.selectTab(`acu-control-${antennaIndex}`); + await page.waitForTimeout(500); + + const stepClicks = async (axis: 'az' | 'el', deltaDeg: number): Promise => { + const control = `[id^="${axis}-fine-"][id$="-ant${antennaIndex}"]`; + await expect(page.locator(control)).toBeVisible({ timeout: 10000 }); + const sign = deltaDeg < 0 ? '-' : ''; + let remaining = Math.abs(deltaDeg); + for (const step of [10, 1, 0.01]) { + const n = Math.floor(remaining / step + 1e-9); + for (let i = 0; i < n; i++) { + await domClick(page, `${control} .btn-fine[data-delta="${sign}${step}"]`); + await page.waitForTimeout(60); + } + remaining -= n * step; + } + }; + + await stepClicks('az', deltaAzDeg); + await stepClicks('el', deltaElDeg); + + const applyBtn = page.locator(`[id$="-ant${antennaIndex}-apply-changes-btn"]`); + await expect(applyBtn).toBeEnabled({ timeout: 5000 }); + await domClick(page, `[id$="-ant${antennaIndex}-apply-changes-btn"]`); +} + +/** Read the ACU tab's active (red) azimuth / elevation for an aperture. */ +export async function readAntennaPosition(page: Page, antennaIndex: number): Promise<{ az: number; el: number }> { + const read = async (axis: 'az' | 'el'): Promise => { + const text = (await page.locator(`[id^="${axis}-fine-"][id$="-ant${antennaIndex}-value"]`).textContent()) ?? ''; + return parseFloat(text.replace(/[^\d.+-]/g, '')); + }; + return { az: await read('az'), el: await read('el') }; +} + +/** Enable (or disable) the jam HPA on the TX Chain tab. */ +export async function setHpaEnabled(page: Page, missionControl: MissionControlPage, enabled: boolean): Promise { + await missionControl.selectTab('tx-chain'); + const hpaEnable = page.locator('#hpa-enable'); + await expect(hpaEnable).toBeVisible({ timeout: 10000 }); + if ((await hpaEnable.isChecked()) !== enabled) { + await domClick(page, '#hpa-enable'); + } + if (enabled) { + await expect(hpaEnable).toBeChecked({ timeout: 5000 }); + } else { + await expect(hpaEnable).not.toBeChecked({ timeout: 5000 }); + } + await page.waitForTimeout(300); +} + +/** Select a jam string (TX modem) on the TX Chain tab. */ +export async function selectTxModem(page: Page, missionControl: MissionControlPage, modemNumber: number): Promise { + await missionControl.selectTab('tx-chain'); + const btn = page.locator(`.modem-btn[data-modem="${modemNumber}"]`); + await expect(btn).toBeVisible({ timeout: 10000 }); + await domClick(page, `.modem-btn[data-modem="${modemNumber}"]`); + await page.waitForTimeout(300); +} + +/** + * Key (or un-key) the currently selected jam string. The exciters are + * pre-powered, so only the Transmit switch is touched; the power switch is + * raised first if a scenario ever ships one cold. + */ +export async function keyUpString(page: Page, missionControl: MissionControlPage, transmitting: boolean): Promise { + await missionControl.selectTab('tx-chain'); + const powerSwitch = page.locator('#tx-power-switch'); + await expect(powerSwitch).toBeVisible({ timeout: 10000 }); + if (transmitting && !(await powerSwitch.isChecked())) { + await domClick(page, '#tx-power-switch'); + await expect(powerSwitch).toBeChecked({ timeout: 8000 }); + await page.waitForTimeout(4500); // transmitter power-up delay + } + + const txSwitch = page.locator('#tx-transmit-switch'); + if ((await txSwitch.isChecked()) !== transmitting) { + await domClick(page, '#tx-transmit-switch'); + } + if (transmitting) { + await expect(txSwitch).toBeChecked({ timeout: 5000 }); + } else { + await expect(txSwitch).not.toBeChecked({ timeout: 5000 }); + } + await page.waitForTimeout(300); +} + +/** + * Run FAULT RESET on the currently selected string. The reset only takes + * with the string un-keyed and clears a beat later, so the caller should + * have the string un-keyed (a tripped exciter already is). + */ +export async function resetFault(page: Page, missionControl: MissionControlPage): Promise { + await missionControl.selectTab('tx-chain'); + await expect(page.locator('#tx-fault-reset-btn')).toBeVisible({ timeout: 10000 }); + await domClick(page, '#tx-fault-reset-btn'); + await page.waitForTimeout(1000); +} + +/** Live per-condition dump of one objective (window.debugObjective dev hook). */ +export async function debugObjective(page: Page, objectiveId: string): Promise { + return page.evaluate((id) => { + const hook = (window as unknown as { debugObjective?: (id: string) => unknown }).debugObjective; + return typeof hook === 'function' ? hook(id) : { error: 'debugObjective hook missing' }; + }, objectiveId); +} + +/** Assert the protected-band interlock (or any other failure) has not ended the mission. */ +export async function expectNoMissionFail(page: Page): Promise { + await expect(page.locator('#objective-failed-modal')).toBeHidden(); +} diff --git a/e2e/utils/ham-sdr-helpers.ts b/e2e/utils/ham-sdr-helpers.ts new file mode 100644 index 00000000..fff1c937 --- /dev/null +++ b/e2e/utils/ham-sdr-helpers.ts @@ -0,0 +1,150 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForQuizToAppear } from './simulation-helpers'; + +/** + * Shared helpers for the ham-sdr (Campaign 3) full-completion specs. + * + * The backyard stations expose only the SDR Console + Observations tabs, so + * everything an operator does - tuning, rotator, polarization, AFC - happens + * through the SkyWatcher console selectors here. + * + * Timing rule: objective maintain windows (maintainDuration) tick on + * simulation steps, not skipped time. advanceClock may be used to reach a + * pass, but never inside a maintain window - a jump leaps the Doppler, drops + * the lock, and resets the window. + */ + +/** Answer a Riley status-check quiz and dismiss the feedback panel. */ +export async function answerRileyQuiz(page: Page, answerText: string): Promise { + await waitForQuizToAppear(page); + + const option = page.locator('.quiz-option-btn', { hasText: answerText }); + await expect(option).toBeVisible({ timeout: 10000 }); + await option.click(); + + const feedbackContinue = page.locator('#quiz-continue-btn'); + await expect(feedbackContinue).toBeVisible({ timeout: 5000 }); + await feedbackContinue.click(); +} + +/** + * Skip the scenario clock forward (minutes) and let the sim settle. One clock + * since Phase 19.0: the sky and every mission-elapsed schedule (gnssThreat + * spoof windows, interference envelopes) move together. Objective + * maintainDuration windows run on simulation steps and are not skipped. + */ +export async function advanceClock(page: Page, minutes: number): Promise { + await page.waitForFunction(() => typeof (window as any).advanceClock === 'function'); + await page.evaluate((ms) => (window as any).advanceClock(ms), minutes * 60_000); + await page.waitForTimeout(3000); +} + +/** + * Skip the scenario clock forward to an absolute scenario UTC time (no-op if + * already past it). Removes the guesswork of chaining relative jumps across tests + * whose real-time duration varies (ride loops, quiz timing). + */ +export async function advanceMissionClockToUtc(page: Page, targetIsoUtc: string): Promise { + await page.waitForFunction(() => typeof (window as any).advanceClock === 'function' && typeof (window as any).simClockMs === 'function'); + await page.evaluate((targetMs) => { + const deltaMs = targetMs - (window as any).simClockMs(); + if (deltaMs > 0) (window as any).advanceClock(deltaMs); + }, Date.parse(targetIsoUtc)); + await page.waitForTimeout(2500); +} + +/** The checklist `.objective-item` whose title matches, regardless of collapse. */ +export function objectiveItem(missionControl: MissionControlPage, title: string): Locator { + return missionControl.objectivesChecklist.locator('.objective-item', { hasText: title }); +} + +/** Poll the checklist until the named objective carries the `completed` class. */ +export async function waitForObjectiveComplete(missionControl: MissionControlPage, title: string, timeout = 45000): Promise { + if (!(await missionControl.objectivesChecklist.isVisible().catch(() => false))) { + await missionControl.openChecklist(); + } + await expect(objectiveItem(missionControl, title)).toHaveClass(/completed/, { timeout }); +} + +/** Read the SDR console carrier-offset readout ("+4300 Hz"), NaN when idle. */ +export async function readOffsetHz(page: Page): Promise { + const text = (await page.locator('#sdr-offset-readout').textContent()) ?? ''; + return parseInt(text.replace(/[^\d+-]/g, ''), 10); +} + +/** + * One operator correction: read the carrier offset and click the tune buttons + * toward zero (coarse = 10 kHz, fine = 1 kHz). Positive offset = carrier + * above the VFO = tune up (same sign convention the AFC loop uses). + */ +export async function correctVfo(page: Page): Promise { + const offsetHz = await readOffsetHz(page); + if (!Number.isFinite(offsetHz)) return; + + const magnitude = Math.abs(offsetHz); + const up = offsetHz > 0; + const clicks: Array<{ id: string; n: number }> = []; + if (magnitude >= 10_000) { + clicks.push({ id: up ? '#sdr-tune-up-coarse' : '#sdr-tune-dn-coarse', n: Math.floor(magnitude / 10_000) }); + } + const fineHz = magnitude % 10_000; + if (fineHz >= 1_500) { + clicks.push({ id: up ? '#sdr-tune-up-fine' : '#sdr-tune-dn-fine', n: Math.round(fineHz / 1_000) }); + } + for (const { id, n } of clicks) { + for (let i = 0; i < n; i++) { + // DOM-dispatched click: the draggable checklist box (kept open so the + // ride loop can poll it) can float over the console and intercept + // pointer events; the tune buttons only need their click handler run. + await domClick(page, id); + await page.waitForTimeout(100); + } + } +} + +/** Click via DOM dispatch, immune to overlapping draggable boxes. */ +export async function domClick(page: Page, selector: string): Promise { + await page.locator(selector).evaluate((el) => (el as HTMLElement).click()); +} + +/** Select a rotator target by NORAD id and engage TRACK on the SDR console. */ +export async function engageTrack(page: Page, noradId: string): Promise { + const targetSelect = page.locator('#sdr-rot-target'); + await expect(targetSelect).toBeVisible({ timeout: 10000 }); + await targetSelect.selectOption({ value: noradId }); + await page.locator('#sdr-rot-track').check(); +} + +/** + * Ride the pass in real time until the named maintain-window objective + * completes: correct the VFO each cycle, track whether lock was ever seen, + * and stop as soon as the checklist flips. Returns whether lock was observed. + */ +export async function rideUntilObjectiveComplete( + page: Page, + missionControl: MissionControlPage, + objectiveTitle: string, + opts: { maxMs?: number; correct?: boolean } = {} +): Promise { + const { maxMs = 220_000, correct = true } = opts; + if (!(await missionControl.objectivesChecklist.isVisible().catch(() => false))) { + await missionControl.openChecklist(); + } + + const lockIndicator = page.locator('#sdr-lock-indicator'); + const deadline = Date.now() + maxMs; + let everLocked = false; + while (Date.now() < deadline) { + if (correct) await correctVfo(page); + const lockText = (await lockIndicator.textContent().catch(() => '')) ?? ''; + everLocked ||= lockText.trim() === 'LOCKED'; + + const done = await objectiveItem(missionControl, objectiveTitle) + .evaluate((el) => el.classList.contains('completed')) + .catch(() => false); + if (done) return everLocked; + await page.waitForTimeout(4000); + } + return everLocked; +} diff --git a/e2e/utils/nats-eu-helpers.ts b/e2e/utils/nats-eu-helpers.ts new file mode 100644 index 00000000..f8947687 --- /dev/null +++ b/e2e/utils/nats-eu-helpers.ts @@ -0,0 +1,332 @@ +import { expect, Page } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { domClick } from './ham-sdr-helpers'; +import { waitForQuizToAppear } from './simulation-helpers'; + +/** + * Shared console drivers for the nats-eu qualified-tier full-completion specs + * (S9 to S12). Each helper drives one operator console through its real DOM: + * the ACU program-track section, the Link Analysis worksheet, the TX chain + * (HPA back-off / enable, modem on air), the TT&C commanding console, the + * contact plan, the ephemeris panel and the RX modem tuning strip. + * + * Every click aimed at a console goes through domClick: the checklist + * box stays open so the specs can poll it, and as a draggable overlay it can + * sit on top of a console control and intercept a pointer click. Inputs are + * filled and then handed a synthetic change event, which is what each adapter + * listens for. + * + * Clock rule: contact windows, command windows and space events all run on + * the scenario clock; specs jump with advanceMissionClockToUtc. + */ + +/** + * Assert the Dashboard's Active Alarms card lists an entry containing `text`. + * Sweep quizzes are graded against the board the simulation actually shows, + * so read the board before answering instead of trusting the quiz's own copy. + */ +export async function expectDashboardAlarm(page: Page, text: string): Promise { + const entry = page.locator('#alarm-list .alarm-message', { hasText: text }); + await expect(entry.first()).toBeVisible({ timeout: 15000 }); +} + +/** Answer a SYSTEM status-check quiz by option text and dismiss the feedback. */ +export async function answerSystemQuiz(page: Page, answerText: string): Promise { + await waitForQuizToAppear(page); + + const option = page.locator('.quiz-option-btn', { hasText: answerText }); + await expect(option).toBeVisible({ timeout: 10000 }); + await option.click(); + + const feedbackContinue = page.locator('#quiz-continue-btn'); + await expect(feedbackContinue).toBeVisible({ timeout: 5000 }); + await feedbackContinue.click(); +} + +/** + * Answer whichever SYSTEM quiz is pending, choosing the answer whose question + * matches. For objectives with several status-check conditions the quiz + * manager presents them in its own order, not the declared one. + */ +export async function answerPendingQuizFrom(page: Page, answers: Array<{ questionHint: string; answerText: string }>): Promise { + await waitForQuizToAppear(page); + + const questionText = (await page.locator('#quiz-modal, .quiz-box').first().innerText()) ?? ''; + const match = answers.find((a) => questionText.includes(a.questionHint)); + expect(match, `no answer registered for quiz text: ${questionText.slice(0, 120)}`).toBeTruthy(); + + const option = page.locator('.quiz-option-btn', { hasText: match!.answerText }); + await expect(option).toBeVisible({ timeout: 10000 }); + await option.click(); + + const feedbackContinue = page.locator('#quiz-continue-btn'); + await expect(feedbackContinue).toBeVisible({ timeout: 5000 }); + await feedbackContinue.click(); + + return match!.questionHint; +} + +/** + * Close the Working Document box if a documentLine quiz just opened it. The + * box auto-shows on the first entry and floats over the consoles. + */ +export async function closeWorkingDocumentIfOpen(page: Page): Promise { + const box = page.locator('#draggable-html-box-working-document'); + if (await box.isVisible({ timeout: 1000 }).catch(() => false)) { + const closeBtn = box.locator('.draggable-box__close-btn, [id$="-close"]').first(); + if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) { + await closeBtn.click(); + } + } +} + +/** Fill a numeric input and fire the change event the adapters listen for. */ +export async function fillAndChange(page: Page, selector: string, value: string): Promise { + const input = page.locator(selector); + await expect(input).toBeVisible({ timeout: 10000 }); + await input.fill(value); + await input.dispatchEvent('change'); +} + +/** + * Enable program-track on the ACU tab (if not already) and select the target + * satellite. Selecting a target copies its beacon frequency into the ACU + * beacon field and slews the pedestal to the target's current position. + */ +export async function programTrack(page: Page, missionControl: MissionControlPage, noradId: string): Promise { + await missionControl.selectTab('acu-control'); + + const modeButton = page.locator('.btn-tracking[data-mode="program-track"]'); + await expect(modeButton).toBeVisible({ timeout: 10000 }); + if (!(await modeButton.evaluate((el) => el.classList.contains('active')))) { + await domClick(page, '.btn-tracking[data-mode="program-track"]'); + await page.waitForTimeout(300); + } + + const satelliteSelect = page.locator('select[id$="satellite-select"]'); + await expect(satelliteSelect).toBeVisible({ timeout: 10000 }); + await satelliteSelect.selectOption({ value: noradId }); + + const moveBtn = page.locator('button[id$="move-to-target-btn"]'); + if (await moveBtn.isEnabled({ timeout: 1000 }).catch(() => false)) { + await domClick(page, 'button[id$="move-to-target-btn"]'); + } +} + +export interface LinkBudgetWorksheet { + eirpDbm: number; + fsplDb: number; + rxGainDbi: number; + noiseTempK: number; + bandwidthMHz: number; + miscLossDb: number; +} + +/** Fill the Link Analysis worksheet, press Compute, and expect IN FAMILY. */ +export async function computeLinkBudget(page: Page, missionControl: MissionControlPage, worksheet: LinkBudgetWorksheet): Promise { + await missionControl.selectTab('link-budget'); + await expect(page.locator('#lb-compute')).toBeVisible({ timeout: 10000 }); + + await page.locator('#lb-eirp').fill(String(worksheet.eirpDbm)); + await page.locator('#lb-fspl').fill(String(worksheet.fsplDb)); + await page.locator('#lb-rxgain').fill(String(worksheet.rxGainDbi)); + await page.locator('#lb-noisetemp').fill(String(worksheet.noiseTempK)); + await page.locator('#lb-bandwidth').fill(String(worksheet.bandwidthMHz)); + await page.locator('#lb-miscloss').fill(String(worksheet.miscLossDb)); + + await domClick(page, '#lb-compute'); + await expect(page.locator('#lb-accept-badge')).toHaveText('IN FAMILY', { timeout: 5000 }); +} + +/** Read the live C/N shown on the Link Analysis tab (NaN when no modem lock). */ +export async function readLiveCnr(page: Page): Promise { + const text = (await page.locator('#lb-live-cnr').textContent()) ?? ''; + return parseFloat(text); +} + +/** + * Press Accept Link on the Link Analysis tab once the live C/N clears the + * margin the objective asks for, and expect the LINK GO badge. + */ +export async function commitLinkWithMargin(page: Page, missionControl: MissionControlPage, minLiveCnrDb: number, timeoutMs = 60000): Promise { + await missionControl.selectTab('link-budget'); + await expect(page.locator('#lb-commit')).toBeVisible({ timeout: 10000 }); + + const deadline = Date.now() + timeoutMs; + let live = NaN; + while (Date.now() < deadline) { + live = await readLiveCnr(page); + if (Number.isFinite(live) && live >= minLiveCnrDb) break; + await page.waitForTimeout(1000); + } + expect(live, `live C/N ${live} dB never reached ${minLiveCnrDb} dB`).toBeGreaterThanOrEqual(minLiveCnrDb); + + await expect(page.locator('#lb-commit')).toBeEnabled(); + await domClick(page, '#lb-commit'); + await expect(page.locator('#lb-margin-badge')).toHaveText('LINK GO', { timeout: 5000 }); +} + +/** Retune the active RX modem on the RX Analysis tab and apply. */ +export async function setRxModemFrequency(page: Page, missionControl: MissionControlPage, mhz: number): Promise { + await missionControl.selectTab('rx-analysis'); + const freqInput = page.locator('#frequency-input'); + await expect(freqInput).toBeVisible({ timeout: 10000 }); + await freqInput.fill(String(mhz)); + await freqInput.press('Tab'); + await page.waitForTimeout(200); + await domClick(page, '#apply-btn'); + await page.waitForTimeout(500); +} + +/** Stage an HPA back-off (dB) on the TX Chain tab and apply it. */ +export async function setHpaBackOff(page: Page, missionControl: MissionControlPage, backOffDb: number): Promise { + await missionControl.selectTab('tx-chain'); + await fillAndChange(page, '#hpa-backoff', String(backOffDb)); + await domClick(page, '#hpa-apply-btn'); + await expect(page.locator('#hpa-backoff')).toHaveValue(String(backOffDb)); +} + +/** Toggle a form switch to the wanted state via a DOM click (change fires). */ +export async function setSwitch(page: Page, selector: string, wanted: boolean): Promise { + const el = page.locator(selector); + await expect(el).toBeVisible({ timeout: 10000 }); + if ((await el.isChecked()) !== wanted) { + await domClick(page, selector); + } + if (wanted) { + await expect(el).toBeChecked(); + } else { + await expect(el).not.toBeChecked(); + } +} + +/** Put the transmit modem on air (power switch first if it is down). */ +export async function setTxModemOnAir(page: Page, missionControl: MissionControlPage): Promise { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#tx-power-switch', true); + await page.waitForTimeout(200); + await setSwitch(page, '#tx-transmit-switch', true); +} + +/** Enable the HPA. Only call with the modem already driving the BUC. */ +export async function enableHpa(page: Page, missionControl: MissionControlPage): Promise { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#hpa-enable', true); +} + +/** Disable the HPA (uplink cold). Safe in any modem state. */ +export async function disableHpa(page: Page, missionControl: MissionControlPage): Promise { + await missionControl.selectTab('tx-chain'); + await setSwitch(page, '#hpa-enable', false); +} + +/** Engage uplink Doppler compensation on the TT&C console. */ +export async function enableDopplerComp(page: Page, missionControl: MissionControlPage): Promise { + await missionControl.selectTab('commanding'); + await setSwitch(page, '#cmd-doppler', true); +} + +/** Transmit a canned command and wait for its ACK row in the command log. */ +export async function sendCommandAndExpectAck(page: Page, missionControl: MissionControlPage, commandId: string): Promise { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-window-badge')).toHaveText('OPEN', { timeout: 10000 }); + await domClick(page, `#cmd-send-panel button[data-command-id="${commandId}"]`); + + const row = page.locator('#cmd-log-body tr', { hasText: commandId }).first(); + await expect(row).toBeVisible({ timeout: 5000 }); + await expect(row).toContainText('ACK received'); +} + +/** + * Transmit a canned command and wait for its NAK row with the given reason + * label (e.g. 'Uplink denied - carrier jammed'). The command must be one the + * console has not sent before, or the first matching row is an older record. + */ +export async function sendCommandAndExpectNak(page: Page, missionControl: MissionControlPage, commandId: string, reasonLabel: string): Promise { + await missionControl.selectTab('commanding'); + await expect(page.locator('#cmd-window-badge')).toHaveText('OPEN', { timeout: 10000 }); + await domClick(page, `#cmd-send-panel button[data-command-id="${commandId}"]`); + + const row = page.locator('#cmd-log-body tr', { hasText: commandId }).first(); + await expect(row).toBeVisible({ timeout: 5000 }); + await expect(row).toContainText(reasonLabel); +} + +/** Allocate a contact to a station on the Contact Plan console. */ +export async function assignContact(page: Page, contactId: string, stationId: string): Promise { + const select = page.locator(`select.cs-station-select[data-contact-id="${contactId}"]`); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption({ value: stationId }); +} + +/** Retune the transmit modem on the TX Chain tab (MHz) and apply. */ +export async function setTxModemFrequency(page: Page, missionControl: MissionControlPage, mhz: number): Promise { + await missionControl.selectTab('tx-chain'); + await fillAndChange(page, '#tx-frequency-input', String(mhz)); + await domClick(page, '#tx-apply-btn'); + await expect(page.locator('#tx-frequency-current')).toContainText(`${mhz.toFixed(1)} MHz`, { timeout: 5000 }); +} + +/** Stage a BUC gain (dB) on the TX Chain tab and apply it. */ +export async function setBucGain(page: Page, missionControl: MissionControlPage, gainDb: number): Promise { + await missionControl.selectTab('tx-chain'); + await fillAndChange(page, '#buc-gain', String(gainDb)); + await domClick(page, '#buc-apply-btn'); + await expect(page.locator('#buc-gain')).toHaveValue(String(gainDb)); +} + +/** Pick a resolution bandwidth on the RX Analysis spectrum analyzer (select values are MHz strings, e.g. '0.01' = 10 kHz). */ +export async function setSpecaRbw(page: Page, missionControl: MissionControlPage, rbwOptionValue: string): Promise { + await missionControl.selectTab('rx-analysis'); + const select = page.locator('#sa-rbw'); + await expect(select).toBeVisible({ timeout: 10000 }); + await select.selectOption(rbwOptionValue); +} + +/** Stage repeated fine-adjust clicks on one axis of the selected station's ACU (apply separately). */ +export async function jogAxis(page: Page, axisPrefix: 'az-fine' | 'el-fine', delta: number, clicks: number): Promise { + const selector = `[id^="${axisPrefix}"] .btn-fine[data-delta="${delta}"]`; + await expect(page.locator(selector).first()).toBeVisible({ timeout: 10000 }); + for (let i = 0; i < clicks; i++) { + await domClick(page, selector); + } +} + +/** + * Park the selected station's tracker by jogging from its current staged + * azimuth/elevation to a target with the ACU fine buttons, then apply. Jogs + * in 10 deg steps and finishes with 1 deg steps; both axes. + */ +export async function parkAntenna(page: Page, missionControl: MissionControlPage, from: { az: number; el: number }, to: { az: number; el: number }): Promise { + await missionControl.selectTab('acu-control'); + const azDelta = to.az - from.az; + const elDelta = to.el - from.el; + const azCoarse = Math.trunc(azDelta / 10); + const azFine = azDelta - azCoarse * 10; + if (azCoarse !== 0) await jogAxis(page, 'az-fine', azCoarse > 0 ? 10 : -10, Math.abs(azCoarse)); + if (azFine !== 0) await jogAxis(page, 'az-fine', azFine > 0 ? 1 : -1, Math.abs(azFine)); + if (elDelta !== 0) await jogAxis(page, 'el-fine', elDelta > 0 ? 1 : -1, Math.abs(elDelta)); + await domClick(page, '[id$="apply-changes-btn"]'); +} + +/** Press Load Updated Ephemeris for a space event once its row reads STALE. */ +export async function loadEphemeris(page: Page, missionControl: MissionControlPage, eventId: string, timeoutMs = 60000): Promise { + await missionControl.selectTab('pass-schedule'); + const selector = `#ephemeris-panel [data-ephemeris-event="${eventId}"]`; + await expect(page.locator(selector)).toBeVisible({ timeout: timeoutMs }); + + // The panel rebuilds its innerHTML on the update tick, so a button resolved + // in one round trip and clicked in the next can be detached by the time the + // click fires and never reach the delegated handler. Resolve and click in + // one page task, and retry if a re-render swallowed it. + const updated = page.locator('#ephemeris-panel .ephemeris-badge-updated'); + for (let attempt = 0; attempt < 5; attempt++) { + await page.evaluate((sel) => (document.querySelector(sel) as HTMLElement | null)?.click(), selector); + const ok = await updated + .waitFor({ state: 'visible', timeout: 3000 }) + .then(() => true) + .catch(() => false); + if (ok) return; + } + await expect(updated).toBeVisible({ timeout: 10000 }); +} diff --git a/e2e/utils/signal-hunter-helpers.ts b/e2e/utils/signal-hunter-helpers.ts new file mode 100644 index 00000000..c781a80c --- /dev/null +++ b/e2e/utils/signal-hunter-helpers.ts @@ -0,0 +1,373 @@ +import { expect, Locator, Page } from '@playwright/test'; +import { MissionControlPage } from '../pages/mission-control.page'; +import { waitForQuizToAppear } from './simulation-helpers'; + +/** + * Shared helpers for the Campaign 5 (Signal Hunter) full-completion specs. + * + * The geolocation console only correlates while the duty-cycled interferer is + * transmitting (>= 70% of the integration window), so the capture helpers + * here track the interferer's on/off phase and start captures inside the + * on-window instead of retrying blind across cycles. + * + * Interferer state is read through the window.debugObjective dev hook: the + * `evaluatesNow` field of a signal-detected condition is the live "interferer + * present at the analyzer" answer, independent of the observation gate, so + * it works as an ON/OFF probe before and after the objective completes. + * + * Off-windows are skipped with window.advanceClock (one clock, so the interferer schedule, the capture integration clock, and + * the satellite geometry all agree). Jumps happen only BETWEEN captures: the + * console counts on-time in update ticks across the integration, and a jump + * inside one would end it with almost no ticks. + */ + +export interface LatLon { + lat: number; + lon: number; +} + +/** Interferer duty-cycle description used to plan captures */ +export interface DutyCycle { + onSeconds: number; + periodSeconds: number; +} + +/** Phase tracker state shared across capture calls within one page session */ +export interface InterfererPhase { + /** Mission-clock ms (Date.now() + skipped) at which the last observed on-window began */ + onStartMs: number | null; +} + +/** Everything the capture helpers need to plan captures against the duty cycle */ +export interface CaptureContext { + page: Page; + phase: InterfererPhase; + /** Objective whose signal-detected condition serves as the ON/OFF probe */ + probeObjectiveId: string; + duty: DutyCycle; + /** Correlator integration window, s */ + windowS: number; + log?: (line: string) => void; +} + +/** Console fix summary, parsed from #geo-fix-summary */ +export interface FixSummary { + lat: number; + lon: number; + semiMajorKm: number | null; + semiMinorKm: number | null; +} + +interface DebugObjectiveResult { + error?: string; + isActive?: boolean; + isCompleted?: boolean; + conditions?: Array<{ type: string; evaluatesNow: boolean; isSatisfied: boolean; observed: boolean | null }>; +} + +/** Great-circle distance between two lat/lon points, km (haversine) */ +export function greatCircleKm(a: LatLon, b: LatLon): number { + const R = 6371.0088; + const toRad = (deg: number) => (deg * Math.PI) / 180; + const dLat = toRad(b.lat - a.lat); + const dLon = toRad(b.lon - a.lon); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLon / 2) ** 2; + return 2 * R * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** Parse "35.170°, -103.720° · ±12×5 km (95%)" into its parts; null when no fix */ +export function parseFixSummary(text: string): FixSummary | null { + const coord = /(-?\d+\.\d+)°, ?(-?\d+\.\d+)°/.exec(text); + if (!coord) { + return null; + } + const ellipse = /±(\d+)×(\d+)\s*km/.exec(text); + return { + lat: parseFloat(coord[1]), + lon: parseFloat(coord[2]), + semiMajorKm: ellipse ? parseInt(ellipse[1], 10) : null, + semiMinorKm: ellipse ? parseInt(ellipse[2], 10) : null, + }; +} + +/** window.debugObjective('') dump, for diagnostics and the ON/OFF probe */ +export async function debugObjective(page: Page, objectiveId: string): Promise { + return page.evaluate((id) => { + const hook = (window as unknown as { debugObjective?: (id: string) => DebugObjectiveResult }).debugObjective; + return hook ? hook(id) : { error: 'debugObjective hook missing' }; + }, objectiveId); +} + +/** + * Whether the interferer is on the air right now, read as the live evaluation + * of the named objective's signal-detected condition. + */ +export async function isInterfererOn(page: Page, probeObjectiveId: string): Promise { + const dump = await debugObjective(page, probeObjectiveId); + const condition = dump.conditions?.find((c) => c.type === 'signal-detected'); + if (!condition) { + throw new Error(`No signal-detected condition on '${probeObjectiveId}': ${JSON.stringify(dump)}`); + } + return condition.evaluatesNow; +} + +/** Mission-clock now (scenario-elapsed ms) as the page sees it */ +export async function missionNowMs(page: Page): Promise { + return page.evaluate(() => { + const hooks = window as unknown as { missionElapsedMs?: () => number }; + return hooks.missionElapsedMs?.() ?? 0; + }); +} + +/** Skip the clock forward by `deltaMs` and let one update tick run */ +export async function advanceMissionClockMs(page: Page, deltaMs: number, settleMs = 250): Promise { + if (deltaMs <= 0) { + return; + } + await page.waitForFunction(() => typeof (window as any).advanceClock === 'function'); + await page.evaluate((ms) => (window as any).advanceClock(ms), deltaMs); + await page.waitForTimeout(settleMs); +} + +/** + * Bring the scenario to the start of an interferer on-window and return the + * mission-clock ms at which that window began (accurate to about one scan + * step plus a tick). + * + * With a known phase the helper jumps straight to the next on-window start + * and verifies with the probe. Without one (first call, or the probe + * disagreed with the prediction) it scans forward in `stepMs` jumps: through + * the rest of any current on-window, then through the off-window until the + * probe flips ON. + */ +export async function ensureOnWindowStart(ctx: CaptureContext, opts: { stepMs?: number; leadMs?: number } = {}): Promise { + const { page, phase, probeObjectiveId, duty } = ctx; + const { stepMs = 1000, leadMs = 400 } = opts; + const periodMs = duty.periodSeconds * 1000; + const onMs = duty.onSeconds * 1000; + + if (phase.onStartMs !== null) { + const now = await missionNowMs(page); + const elapsed = now - phase.onStartMs; + const cycles = Math.ceil(elapsed / periodMs); + const nextStart = phase.onStartMs + cycles * periodMs; + await advanceMissionClockMs(page, nextStart - now + leadMs); + if (await isInterfererOn(page, probeObjectiveId)) { + phase.onStartMs = nextStart; + return nextStart; + } + // Prediction drifted: fall through to a fresh scan + phase.onStartMs = null; + } + + // Scan out of any current on-window + let guard = Math.ceil(onMs / stepMs) + 5; + while (guard-- > 0 && (await isInterfererOn(page, probeObjectiveId))) { + await advanceMissionClockMs(page, stepMs); + } + // Scan through the off-window to the next rising edge + guard = Math.ceil((periodMs - onMs) / stepMs) + 5; + while (guard-- > 0 && !(await isInterfererOn(page, probeObjectiveId))) { + await advanceMissionClockMs(page, stepMs); + } + if (!(await isInterfererOn(page, probeObjectiveId))) { + throw new Error('Interferer never came on the air during the phase scan'); + } + phase.onStartMs = await missionNowMs(page); + return phase.onStartMs; +} + +/** Number of visible measurement rows, excluding the empty-state placeholder */ +export async function measurementCount(page: Page): Promise { + const placeholder = await page.locator('#geo-measurement-rows td[colspan]').count(); + if (placeholder > 0) { + return 0; + } + return page.locator('#geo-measurement-rows tr').count(); +} + +/** Click via DOM dispatch, immune to the draggable checklist floating over the console */ +export async function domClick(page: Page, selector: string): Promise { + await page.locator(selector).evaluate((el) => (el as HTMLElement).click()); +} + +/** Set a console numeric input and fire its change handler */ +export async function setConsoleInput(page: Page, selector: string, value: number): Promise { + await page.locator(selector).evaluate((el, v) => { + const input = el as HTMLInputElement; + input.value = String(v); + input.dispatchEvent(new Event('change')); + }, value); +} + +/** + * Press CAPTURE once and wait for the integration to resolve. Returns the + * terminal capture message ("CAPTURE n: ..." on success, "NO CORRELATION ..." + * or "CORRELATOR FAULT ..." otherwise). + */ +export async function captureOnce(page: Page, windowS: number): Promise { + const msg = page.locator('#geo-capture-msg'); + await expect(page.locator('#geo-capture-btn')).toBeEnabled({ timeout: 5000 }); + await domClick(page, '#geo-capture-btn'); + await expect(msg).toHaveText(/INTEGRATING/i, { timeout: 3000 }); + + const deadline = Date.now() + windowS * 1000 + 8000; + while (Date.now() < deadline) { + await page.waitForTimeout(250); + const text = ((await msg.textContent().catch(() => '')) ?? '').trim(); + if (/^(CAPTURE|NO CORRELATION|CORRELATOR)/.test(text)) { + return text; + } + } + throw new Error('Capture did not resolve inside the integration window'); +} + +/** + * Collect captures until the console holds at least `target` measurements. + * Captures are started only with enough of the on-window left for the + * integration (plus the phase-calibration slop), and off-windows are skipped + * with a clock jump. Returns the final measurement count. + */ +export async function collectCaptures(ctx: CaptureContext, target: number): Promise { + const { page, duty, windowS } = ctx; + const log = ctx.log ?? (() => undefined); + // Slop for the 1 s phase scan + a tick, on top of the full window + const minRemainingMs = windowS * 1000 + 2500; + let count = await measurementCount(page); + let windows = 0; + + while (count < target) { + if (windows++ > 12) { + throw new Error(`Needed more than 12 on-windows to reach ${target} captures (have ${count})`); + } + const onStart = await ensureOnWindowStart(ctx); + for (;;) { + const remaining = duty.onSeconds * 1000 - ((await missionNowMs(page)) - onStart); + if (count >= target || remaining < minRemainingMs) { + break; + } + const result = await captureOnce(page, windowS); + count = await measurementCount(page); + log(`window ${windows} phase +${((duty.onSeconds * 1000 - remaining) / 1000).toFixed(1)}s: ${result} (count=${count})`); + } + } + return count; +} + +/** Press COMPUTE FIX and return the parsed summary (throws when no fix appears) */ +export async function computeFix(page: Page): Promise { + await domClick(page, '#geo-compute-btn'); + const summary = page.locator('#geo-fix-summary'); + await expect(summary).toHaveText(/°,/, { timeout: 5000 }); + const parsed = parseFixSummary(((await summary.textContent()) ?? '').trim()); + if (!parsed) { + throw new Error('Fix summary had no coordinate pair'); + } + return parsed; +} + +/** + * Compute a fix and, while it misses `maxErrorKm`, capture `extraPerRound` + * more measurements and recompute (bounded). Returns the final fix, its + * great-circle error, and the capture count it was solved from. + */ +export async function computeFixWithin( + ctx: CaptureContext, + truth: LatLon, + maxErrorKm: number, + opts: { extraPerRound?: number; maxRounds?: number } = {} +): Promise<{ fix: FixSummary; errorKm: number; captures: number }> { + const { page } = ctx; + const log = ctx.log ?? (() => undefined); + const { extraPerRound = 3, maxRounds = 4 } = opts; + let captures = await measurementCount(page); + let fix = await computeFix(page); + let errorKm = greatCircleKm(fix, truth); + log(`fix from ${captures} captures: ${fix.lat.toFixed(3)}, ${fix.lon.toFixed(3)} -> ${errorKm.toFixed(1)} km (target ${maxErrorKm} km)`); + + for (let round = 0; round < maxRounds && errorKm > maxErrorKm; round++) { + captures = await collectCaptures(ctx, captures + extraPerRound); + fix = await computeFix(page); + errorKm = greatCircleKm(fix, truth); + log(`fix from ${captures} captures: ${fix.lat.toFixed(3)}, ${fix.lon.toFixed(3)} -> ${errorKm.toFixed(1)} km (target ${maxErrorKm} km)`); + } + return { fix, errorKm, captures }; +} + +/** Answer a SYSTEM status-check quiz (option matched by text) and dismiss its feedback */ +export async function answerStatusCheck(page: Page, answerText: string): Promise { + await waitForQuizToAppear(page); + + const option = page.locator('.quiz-option-btn', { hasText: answerText }); + await expect(option).toBeVisible({ timeout: 10000 }); + await option.click(); + + const feedbackContinue = page.locator('#quiz-continue-btn'); + await expect(feedbackContinue).toBeVisible({ timeout: 5000 }); + await feedbackContinue.click(); +} + +/** A status-check answer keyed by a fragment of its question text */ +export interface StatusCheckAnswer { + question: RegExp; + answer: string; +} + +/** + * Answer `count` pending status-check quizzes in whatever order the + * QuizManager surfaces them (the pending key is the LAST registered quiz, + * and completion promotes the next incomplete one), matching each by its + * #quiz-question text. Dismisses the feedback panel and any completion + * dialog / working-document box between quizzes. Returns the questions seen. + */ +export async function answerPendingStatusChecks(page: Page, answers: StatusCheckAnswer[], count: number): Promise { + const seen: string[] = []; + for (let i = 0; i < count; i++) { + await waitForQuizToAppear(page); + const question = ((await page.locator('#quiz-question').textContent()) ?? '').trim(); + const match = answers.find((a) => a.question.test(question)); + if (!match) { + throw new Error(`No answer configured for quiz question: ${question}`); + } + seen.push(question); + + const option = page.locator('.quiz-option-btn', { hasText: match.answer }); + await expect(option).toBeVisible({ timeout: 10000 }); + await option.click(); + + const feedbackContinue = page.locator('#quiz-continue-btn'); + await expect(feedbackContinue).toBeVisible({ timeout: 5000 }); + await feedbackContinue.click(); + + await page.waitForTimeout(500); + await closeWorkingDocumentIfOpen(page); + } + return seen; +} + +/** + * Close the Working Document box if a documentLine quiz just opened it. The + * box auto-shows on the first entry and floats over the consoles. + */ +export async function closeWorkingDocumentIfOpen(page: Page): Promise { + const box = page.locator('#draggable-html-box-working-document'); + if (await box.isVisible({ timeout: 1000 }).catch(() => false)) { + const closeBtn = box.locator('.draggable-box__close-btn, [id$="-close"]').first(); + if (await closeBtn.isVisible({ timeout: 500 }).catch(() => false)) { + await closeBtn.click(); + } + } +} + +/** The checklist `.objective-item` whose title matches, regardless of collapse */ +export function objectiveItem(missionControl: MissionControlPage, title: string): Locator { + return missionControl.objectivesChecklist.locator('.objective-item', { hasText: title }); +} + +/** Poll the checklist until the named objective carries the `completed` class */ +export async function waitForObjectiveComplete(missionControl: MissionControlPage, title: string, timeout = 45000): Promise { + if (!(await missionControl.objectivesChecklist.isVisible().catch(() => false))) { + await missionControl.openChecklist(); + } + await expect(objectiveItem(missionControl, title)).toHaveClass(/completed/, { timeout }); +} diff --git a/e2e/utils/simulation-helpers.ts b/e2e/utils/simulation-helpers.ts index 192cf678..112288bc 100644 --- a/e2e/utils/simulation-helpers.ts +++ b/e2e/utils/simulation-helpers.ts @@ -1,4 +1,4 @@ -import { Page, expect } from '@playwright/test'; +import { expect, Page } from '@playwright/test'; /** * Wait for the simulation to initialize and stabilize. @@ -20,12 +20,7 @@ export async function waitForSimulationReady(page: Page): Promise { * Wait for a specific DOM state in the simulation. * Useful for waiting on equipment state changes. */ -export async function waitForDomState( - page: Page, - selector: string, - expectedText: string, - timeout = 30000 -): Promise { +export async function waitForDomState(page: Page, selector: string, expectedText: string, timeout = 30000): Promise { await page.waitForFunction( ({ sel, text }) => { const element = document.querySelector(sel); @@ -57,8 +52,11 @@ export async function waitForObjectiveCompleted(page: Page, objectiveId: string) await page.waitForFunction( (id) => { const objective = document.querySelector(`[data-objective-id="${id}"]`); - return objective?.classList.contains('completed') || - objective?.querySelector('.completed') !== null; + // The element must EXIST before its state means anything: without this + // guard a missing objective resolved as `undefined !== null` -> true, + // so a typo'd id or an unrendered checklist passed instantly. + if (!objective) return false; + return objective.classList.contains('completed') || objective.querySelector('.completed') !== null; }, objectiveId, { timeout: 30000 } @@ -246,18 +244,14 @@ export async function answerQuizByText(page: Page, answerText: string): Promise< if (bestScore !== Number.POSITIVE_INFINITY && bestScore < 0.45) { const available = optionTexts.map((t) => `- ${t.trim()}`).join('\n'); - throw new Error( - `Could not match quiz answer.\nAnswer: ${answerText}\nOptions:\n${available}` - ); + throw new Error(`Could not match quiz answer.\nAnswer: ${answerText}\nOptions:\n${available}`); } await optionButtons.nth(bestIndex).click(); // Wait for the continue button to appear and click it // Exclude quiz option buttons that might contain text matching "Continue" (e.g., "continues") - const continueButton = quizModal.locator( - '#quiz-continue-btn, .quiz-continue-btn, .quiz-submit-btn, button:has-text("Continue"):not(.quiz-option-btn)' - ); + const continueButton = quizModal.locator('#quiz-continue-btn, .quiz-continue-btn, .quiz-submit-btn, button:has-text("Continue"):not(.quiz-option-btn)'); await expect(continueButton.first()).toBeVisible({ timeout: 5000 }); await continueButton.first().click(); @@ -265,6 +259,59 @@ export async function answerQuizByText(page: Page, answerText: string): Promise< await page.waitForTimeout(500); } +/** + * Assert that the open decision modal shows every named evidence item as + * latched. Decisions are graded on simulator state, so an e2e that answers + * by label without first proving the evidence was in hand proves nothing - + * the same rule as expectDashboardAlarm before a sweep quiz. + */ +/** + * Bring the pending decision modal on screen. Like a quiz, a decision is + * registered silently and surfaces through the pending indicator after a + * delay, so open it from there when it is not already visible. + */ +export async function waitForDecisionToAppear(page: Page, timeout = 30000): Promise { + const modal = page.locator('#decision-modal'); + try { + await expect(modal).toBeVisible({ timeout: 3000 }); + return; + } catch { + // not open yet - use the indicator + } + const pendingIndicator = page.locator('.pending-quiz-indicator__open-btn'); + await expect(pendingIndicator).toBeVisible({ timeout }); + await pendingIndicator.click(); + await expect(modal).toBeVisible({ timeout: 10000 }); +} + +export async function expectEvidenceLatched(page: Page, labels: string[]): Promise { + await waitForDecisionToAppear(page); + const modal = page.locator('#decision-modal'); + for (const label of labels) { + const item = modal.locator('.decision-evidence-item.ready', { hasText: label }); + await expect(item, `evidence "${label}" should be latched before deciding`).toBeVisible({ timeout: 15000 }); + } +} + +/** + * Choose a decision option by its text and press Continue. Refuses to run + * until every listed evidence item is latched, so a passing spec cannot be + * one that guessed. + */ +export async function answerDecision(page: Page, optionText: string, requiredEvidence: string[] = []): Promise { + await expectEvidenceLatched(page, requiredEvidence); + + const modal = page.locator('#decision-modal'); + const option = modal.locator('.quiz-option-btn', { hasText: optionText }); + await expect(option.first()).toBeVisible({ timeout: 10000 }); + await option.first().click(); + + const continueButton = modal.locator('#decision-continue-btn'); + await expect(continueButton, `decision option "${optionText}" should grade correct`).toBeVisible({ timeout: 5000 }); + await continueButton.click(); + await page.waitForTimeout(500); +} + /** * Dismiss dialog overlay if present. * Used to dismiss objective completion dialogs between quizzes. diff --git a/eslint.config.mjs b/eslint.config.mjs deleted file mode 100644 index 82c11330..00000000 --- a/eslint.config.mjs +++ /dev/null @@ -1,19 +0,0 @@ -import importPlugin from 'eslint-plugin-import'; - -export default [ - { - ignores: ['src/engine/**', 'dist/**', 'coverage/**'], - }, - { - languageOptions: { - ecmaVersion: 2020, - sourceType: 'module', - }, - plugins: { - import: importPlugin, - }, - rules: { - 'import/no-unresolved': 'error', - }, - }, -]; diff --git a/external-plugins.json b/external-plugins.json new file mode 100644 index 00000000..20f3fd11 --- /dev/null +++ b/external-plugins.json @@ -0,0 +1,4 @@ +{ + "formatVersion": 1, + "plugins": {} +} diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 004d25f8..00000000 --- a/package-lock.json +++ /dev/null @@ -1,11769 +0,0 @@ -{ - "name": "signal-range", - "version": "1.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "signal-range", - "version": "1.1.0", - "license": "AGPL-3.0", - "dependencies": { - "@supabase/supabase-js": "^2.81.1", - "@tabler/core": "^1.4.0", - "dotenv": "^17.2.3", - "draggabilly": "^3.0.0", - "gl-matrix": "^3.4.4", - "ootk": "^5.1.1", - "uuid": "^13.0.0" - }, - "devDependencies": { - "@playwright/test": "^1.48.0", - "@types/draggabilly": "^3.0.0", - "@types/node": "^20.19.24", - "@vitest/coverage-v8": "^4.0.18", - "baseline-browser-mapping": "^2.9.11", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "copy-webpack-plugin": "^13.0.1", - "cross-env": "^10.1.0", - "css-loader": "^6.8.1", - "eslint": "^9.39.2", - "eslint-plugin-import": "^2.32.0", - "file-loader": "^6.2.0", - "html-webpack-plugin": "^5.5.3", - "jsdom": "^28.0.0", - "style-loader": "^3.3.3", - "supabase": "^2.67.3", - "ts-loader": "^9.5.1", - "tsx": "^4.20.6", - "typescript": "^5.3.3", - "vitest": "^4.0.18", - "vitest-canvas-mock": "^1.1.3", - "webpack": "^5.89.0", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.2.5", - "wrangler": "^4.100.0" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@asamuzakjp/css-color": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", - "integrity": "sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.4", - "@csstools/css-color-parser": "^3.1.0", - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4", - "lru-cache": "^11.2.4" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.7.7", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.7.tgz", - "integrity": "sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.5" - } - }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@cloudflare/kv-asset-handler": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", - "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cloudflare/unenv-preset": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", - "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", - "dev": true, - "license": "MIT OR Apache-2.0", - "peerDependencies": { - "unenv": "2.0.0-rc.24", - "workerd": ">1.20260305.0 <2.0.0-0" - }, - "peerDependenciesMeta": { - "workerd": { - "optional": true - } - } - }, - "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20260611.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260611.1.tgz", - "integrity": "sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20260611.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260611.1.tgz", - "integrity": "sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20260611.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260611.1.tgz", - "integrity": "sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20260611.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260611.1.tgz", - "integrity": "sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20260611.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260611.1.tgz", - "integrity": "sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16" - } - }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", - "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", - "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", - "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", - "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.0.26", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.26.tgz", - "integrity": "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0" - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", - "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@epic-web/invariant": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", - "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", - "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/object-schema": "^2.1.7", - "debug": "^4.3.1", - "minimatch": "^3.1.2" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, - "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@eslint/core": "^0.17.0", - "levn": "^0.4.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.11.0.tgz", - "integrity": "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.4.0" - }, - "engines": { - "node": ">=18.18.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", - "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/source-map": { - "version": "0.3.11", - "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", - "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@jsonjoy.com/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/buffers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", - "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/codegen": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", - "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pack": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", - "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/base64": "^1.1.2", - "@jsonjoy.com/buffers": "^1.2.0", - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/json-pointer": "^1.0.2", - "@jsonjoy.com/util": "^1.9.0", - "hyperdyperid": "^1.2.0", - "thingies": "^2.5.0", - "tree-dump": "^1.1.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/json-pointer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", - "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/codegen": "^1.0.0", - "@jsonjoy.com/util": "^1.9.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@jsonjoy.com/util": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", - "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/buffers": "^1.0.0", - "@jsonjoy.com/codegen": "^1.0.0" - }, - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", - "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@peculiar/asn1-cms": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.8.0.tgz", - "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-csr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.8.0.tgz", - "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-ecc": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.8.0.tgz", - "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pfx": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.8.0.tgz", - "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-rsa": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.8.0.tgz", - "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.8.0.tgz", - "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.8.0", - "@peculiar/asn1-pfx": "^2.8.0", - "@peculiar/asn1-pkcs8": "^2.8.0", - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "@peculiar/asn1-x509-attr": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-rsa": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.8.0.tgz", - "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-schema": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.8.0.tgz", - "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.8.0.tgz", - "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/utils": "^2.0.2", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.8.0.tgz", - "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-schema": "^2.8.0", - "@peculiar/asn1-x509": "^2.8.0", - "asn1js": "^3.0.10", - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", - "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/@peculiar/x509": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", - "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-csr": "^2.6.0", - "@peculiar/asn1-ecc": "^2.6.0", - "@peculiar/asn1-pkcs9": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", - "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "pvtsutils": "^1.3.6", - "reflect-metadata": "^0.2.2", - "tslib": "^2.8.1", - "tsyringe": "^4.10.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.57.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@popperjs/core": { - "version": "2.11.8", - "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", - "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/popperjs" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", - "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^4.1.5" - } - }, - "node_modules/@poppinss/dumper": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@poppinss/dumper/-/dumper-0.6.5.tgz", - "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@sindresorhus/is": "^7.0.2", - "supports-color": "^10.0.0" - } - }, - "node_modules/@poppinss/dumper/node_modules/supports-color": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", - "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/@poppinss/exception": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@poppinss/exception/-/exception-1.2.3.tgz", - "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", - "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", - "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", - "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", - "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", - "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", - "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", - "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", - "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", - "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", - "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", - "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", - "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", - "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.10.0", - "@emnapi/runtime": "1.10.0", - "@napi-rs/wasm-runtime": "^1.1.4" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", - "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", - "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rtsao/scc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", - "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sindresorhus/is": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", - "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@speed-highlight/core": { - "version": "1.2.17", - "resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.17.tgz", - "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@supabase/auth-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.81.1.tgz", - "integrity": "sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/cli-darwin-arm64": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-arm64/-/cli-darwin-arm64-2.106.0.tgz", - "integrity": "sha512-xi/IAjjbebh6xA9Nkkb7ZwC5ACKJ38OqMoYk1y20ScW5Wt+pxTCcOcaWDWu+2u2Pwc3vTYnXhbknVcQt9UcChQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@supabase/cli-darwin-x64": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-darwin-x64/-/cli-darwin-x64-2.106.0.tgz", - "integrity": "sha512-AT2s/8/3Ffb7IkcUXK6sA/iPCzJhh4Fh/XqzAGNGsVd1xCA/ZGniqU8BL03TqPkN1sR0iDaShjMLLIlHEflyPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@supabase/cli-linux-arm64": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64/-/cli-linux-arm64-2.106.0.tgz", - "integrity": "sha512-L/pX7fkV31x7zPrUOcD6KMtZ03j16o41XxgkACFoOAE9lFz8KaJOBSHvn2QLeWEa2kLz7jwpisvpshPSMjuxOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-linux-arm64-musl": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.106.0.tgz", - "integrity": "sha512-2LExuOHuU6odXorNTvq4M/OvueW+wOs3KDsaCpk+scQMnB6LbmqNidEjXAsKpZu3zyE8MmmtdbJBlRNN/KLdcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-linux-x64": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64/-/cli-linux-x64-2.106.0.tgz", - "integrity": "sha512-+ihtqFNURc8ssULgQoHQBR4AtUmxez9dtqjclqOV0y5r65SX7Ginmiby4XDW9LPlqHT+MB7OyD1vk0T6pQUtPA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-linux-x64-musl": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-linux-x64-musl/-/cli-linux-x64-musl-2.106.0.tgz", - "integrity": "sha512-wz7heB3wwzmRdLRgqngd2VjPPNIdv6/Y8Q0dX/ZvjcL9/Nk8BYSZ7yjypapLpra+swRnbEJ4jAU6eAShiaB1Hg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@supabase/cli-windows-arm64": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-arm64/-/cli-windows-arm64-2.106.0.tgz", - "integrity": "sha512-QkEfRDiuuQwcfASo2DIJZ59AFV6xpqhPNo4r0kSVt38StYk3iL7ZcSOkZvGQWLlJ8kXKPBWEMhLnfHQF9r+Xfg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@supabase/cli-windows-x64": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/@supabase/cli-windows-x64/-/cli-windows-x64-2.106.0.tgz", - "integrity": "sha512-1tQFbrH1KJhWJqHrY087XPz1WRi20eKG1pebG+6PjSD6XN+j0+x1PTOtBZrylXgY1fOg8BqgU8kl3KX8hI5l9w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@supabase/functions-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.81.1.tgz", - "integrity": "sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/postgrest-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.81.1.tgz", - "integrity": "sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/realtime-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.81.1.tgz", - "integrity": "sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==", - "license": "MIT", - "dependencies": { - "@types/phoenix": "^1.6.6", - "@types/ws": "^8.18.1", - "tslib": "2.8.1", - "ws": "^8.18.2" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/storage-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.81.1.tgz", - "integrity": "sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==", - "license": "MIT", - "dependencies": { - "tslib": "2.8.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@supabase/supabase-js": { - "version": "2.81.1", - "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.81.1.tgz", - "integrity": "sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==", - "license": "MIT", - "dependencies": { - "@supabase/auth-js": "2.81.1", - "@supabase/functions-js": "2.81.1", - "@supabase/postgrest-js": "2.81.1", - "@supabase/realtime-js": "2.81.1", - "@supabase/storage-js": "2.81.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/@tabler/core": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.4.0.tgz", - "integrity": "sha512-5BigzOlbOH9N0Is4u0rYNRCiwtnUXWO57K9zwuscygcicAa8UV9MGaS4zTgQsZEtZ9tsNANhN/YD8gCBGKYCiw==", - "license": "MIT", - "dependencies": { - "@popperjs/core": "^2.11.8", - "bootstrap": "5.3.7" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/codecalm" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/draggabilly": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@types/draggabilly/-/draggabilly-3.0.0.tgz", - "integrity": "sha512-TymnKZbC3bBXHnE2AE1buGKrNLSpxq7J9qff2egNd+nN26E1p/elh0fyi3RpmR9mG6/OmdbPOlqVXqpUGTdmSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", - "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/express/node_modules/@types/express-serve-static-core": { - "version": "4.19.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", - "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/json-schema": { - "version": "7.0.15", - "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", - "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.24.tgz", - "integrity": "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/phoenix": { - "version": "1.6.6", - "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz", - "integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==", - "license": "MIT" - }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", - "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.9", - "ast-v8-to-istanbul": "^1.0.0", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-reports": "^3.2.0", - "magicast": "^0.5.2", - "obug": "^2.1.1", - "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "4.1.9", - "vitest": "4.1.9" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/coverage-v8/node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", - "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", - "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", - "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", - "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.9", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", - "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "@vitest/utils": "4.1.9", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", - "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", - "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.9", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@webassemblyjs/ast": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", - "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/helper-numbers": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2" - } - }, - "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", - "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", - "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", - "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", - "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.13.2", - "@webassemblyjs/helper-api-error": "1.13.2", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", - "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", - "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/wasm-gen": "1.14.1" - } - }, - "node_modules/@webassemblyjs/ieee754": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", - "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@xtuc/ieee754": "^1.2.0" - } - }, - "node_modules/@webassemblyjs/leb128": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", - "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webassemblyjs/utf8": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", - "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", - "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/helper-wasm-section": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-opt": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1", - "@webassemblyjs/wast-printer": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", - "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", - "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-buffer": "1.14.1", - "@webassemblyjs/wasm-gen": "1.14.1", - "@webassemblyjs/wasm-parser": "1.14.1" - } - }, - "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", - "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@webassemblyjs/helper-api-error": "1.13.2", - "@webassemblyjs/helper-wasm-bytecode": "1.13.2", - "@webassemblyjs/ieee754": "1.13.2", - "@webassemblyjs/leb128": "1.13.2", - "@webassemblyjs/utf8": "1.13.2" - } - }, - "node_modules/@webassemblyjs/wast-printer": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", - "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@webassemblyjs/ast": "1.14.1", - "@xtuc/long": "4.2.2" - } - }, - "node_modules/@webpack-cli/configtest": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-2.1.1.tgz", - "integrity": "sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "node_modules/@webpack-cli/info": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-2.0.2.tgz", - "integrity": "sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - } - }, - "node_modules/@webpack-cli/serve": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-2.0.5.tgz", - "integrity": "sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.15.0" - }, - "peerDependencies": { - "webpack": "5.x.x", - "webpack-cli": "5.x.x" - }, - "peerDependenciesMeta": { - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/@xtuc/ieee754": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/@xtuc/long": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/ajv-formats/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/ajv-keywords": { - "version": "3.5.2", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", - "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "ajv": "^6.9.1" - } - }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "dev": true, - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "license": "Python-2.0" - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/array-includes": { - "version": "3.1.9", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", - "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.24.0", - "es-object-atoms": "^1.1.1", - "get-intrinsic": "^1.3.0", - "is-string": "^1.1.1", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", - "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-shim-unscopables": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", - "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", - "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", - "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.1", - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "is-array-buffer": "^3.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/asn1js": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", - "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.5", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", - "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/async-function": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", - "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "dev": true, - "license": "MIT" - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/big.js": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", - "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/blake3-wasm": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/blake3-wasm/-/blake3-wasm-2.1.5.tgz", - "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==", - "dev": true, - "license": "MIT" - }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/body-parser/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/bootstrap": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.7.tgz", - "integrity": "sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT", - "peerDependencies": { - "@popperjs/core": "^2.11.8" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", - "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/bytestreamjs": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/bytestreamjs/-/bytestreamjs-2.0.1.tgz", - "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camel-case": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", - "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pascal-case": "^3.1.2", - "tslib": "^2.0.3" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/case-sensitive-paths-webpack-plugin": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/case-sensitive-paths-webpack-plugin/-/case-sensitive-paths-webpack-plugin-2.4.0.tgz", - "integrity": "sha512-roIFONhcxog0JSSWbvVAh3OocukmSgpqOH6YpMkCvav/ySIV3JKg4Dc8vYtQjYi/UxpNE36r/9v+VqTQqgkYmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chrome-trace-event": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", - "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0" - } - }, - "node_modules/clean-css": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-5.3.3.tgz", - "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "source-map": "~0.6.0" - }, - "engines": { - "node": ">= 10.0" - } - }, - "node_modules/clone-deep": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", - "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-plain-object": "^2.0.4", - "kind-of": "^6.0.2", - "shallow-clone": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", - "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/copy-webpack-plugin": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz", - "integrity": "sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob-parent": "^6.0.1", - "normalize-path": "^3.0.0", - "schema-utils": "^4.2.0", - "serialize-javascript": "^6.0.2", - "tinyglobby": "^0.2.12" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/copy-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/copy-webpack-plugin/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/copy-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/copy-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-env": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", - "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@epic-web/invariant": "^1.0.0", - "cross-spawn": "^7.0.6" - }, - "bin": { - "cross-env": "dist/bin/cross-env.js", - "cross-env-shell": "dist/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-loader": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.11.0.tgz", - "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "icss-utils": "^5.1.0", - "postcss": "^8.4.33", - "postcss-modules-extract-imports": "^3.1.0", - "postcss-modules-local-by-default": "^4.0.5", - "postcss-modules-scope": "^3.2.0", - "postcss-modules-values": "^4.0.0", - "postcss-value-parser": "^4.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/css-select": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-4.3.0.tgz", - "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.0.1", - "domhandler": "^4.3.1", - "domutils": "^2.8.0", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssfontparser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/cssfontparser/-/cssfontparser-1.2.1.tgz", - "integrity": "sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", - "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^4.1.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.21", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.4" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.5", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", - "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/data-view-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", - "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/data-view-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", - "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/inspect-js" - } - }, - "node_modules/data-view-byte-offset": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", - "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-data-view": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", - "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-is": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", - "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/default-browser": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.4.0.tgz", - "integrity": "sha512-XDuvSq38Hr1MdN47EDvYtx3U0MTqpCEn+F6ft8z2vYDzMrvQhVp0ui9oQdqW3MvK3vqUETglt1tVGgjLuJ5izg==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", - "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "dev": true, - "license": "MIT" - }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-converter": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz", - "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", - "dev": true, - "license": "MIT", - "dependencies": { - "utila": "~0.4" - } - }, - "node_modules/dom-serializer": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", - "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.2.0", - "entities": "^2.0.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-4.3.1.tgz", - "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.2.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz", - "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^1.0.1", - "domelementtype": "^2.2.0", - "domhandler": "^4.2.0" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/dot-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", - "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/dotenv": { - "version": "17.2.3", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", - "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/draggabilly": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/draggabilly/-/draggabilly-3.0.0.tgz", - "integrity": "sha512-aEs+B6prbMZQMxc9lgTpCBfyCUhRur/VFucHhIOvlvvdARTj7TcDmX/cdOUtqbjJJUh7+agyJXR5Z6IFe1MxwQ==", - "license": "MIT", - "dependencies": { - "get-size": "^3.0.0", - "unidragger": "^3.0.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.373", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.373.tgz", - "integrity": "sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==", - "dev": true, - "license": "ISC" - }, - "node_modules/emojis-list": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", - "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/enhanced-resolve": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.0.tgz", - "integrity": "sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/entities": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", - "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", - "dev": true, - "license": "BSD-2-Clause", - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/envinfo": { - "version": "7.19.0", - "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.19.0.tgz", - "integrity": "sha512-DoSM9VyG6O3vqBf+p3Gjgr/Q52HYBBtO3v+4koAxt1MnWr+zEnxE+nke/yXS4lt2P4SYCHQ4V3f1i88LQVOpAw==", - "dev": true, - "license": "MIT", - "bin": { - "envinfo": "dist/cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/error-stack-parser-es": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/error-stack-parser-es/-/error-stack-parser-es-1.0.5.tgz", - "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "array-buffer-byte-length": "^1.0.2", - "arraybuffer.prototype.slice": "^1.0.4", - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "data-view-buffer": "^1.0.2", - "data-view-byte-length": "^1.0.2", - "data-view-byte-offset": "^1.0.1", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "es-set-tostringtag": "^2.1.0", - "es-to-primitive": "^1.3.0", - "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.3.0", - "get-proto": "^1.0.1", - "get-symbol-description": "^1.1.0", - "globalthis": "^1.0.4", - "gopd": "^1.2.0", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "internal-slot": "^1.1.0", - "is-array-buffer": "^3.0.5", - "is-callable": "^1.2.7", - "is-data-view": "^1.0.2", - "is-negative-zero": "^2.0.3", - "is-regex": "^1.2.1", - "is-set": "^2.0.3", - "is-shared-array-buffer": "^1.0.4", - "is-string": "^1.1.1", - "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.1", - "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.4", - "object-keys": "^1.1.1", - "object.assign": "^4.1.7", - "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.4", - "safe-array-concat": "^1.1.3", - "safe-push-apply": "^1.0.0", - "safe-regex-test": "^1.1.0", - "set-proto": "^1.0.0", - "stop-iteration-iterator": "^1.1.0", - "string.prototype.trim": "^1.2.10", - "string.prototype.trimend": "^1.0.9", - "string.prototype.trimstart": "^1.0.8", - "typed-array-buffer": "^1.0.3", - "typed-array-byte-length": "^1.0.3", - "typed-array-byte-offset": "^1.0.4", - "typed-array-length": "^1.0.7", - "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.19" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-shim-unscopables": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", - "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", - "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7", - "is-date-object": "^1.0.5", - "is-symbol": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", - "@humanfs/node": "^0.16.6", - "@humanwhocodes/module-importer": "^1.0.1", - "@humanwhocodes/retry": "^0.4.2", - "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.6", - "debug": "^4.3.2", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^8.0.0", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - }, - "peerDependencies": { - "jiti": "*" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - } - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.32.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", - "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@rtsao/scc": "^1.1.0", - "array-includes": "^3.1.9", - "array.prototype.findlastindex": "^1.2.6", - "array.prototype.flat": "^1.3.3", - "array.prototype.flatmap": "^1.3.3", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.12.1", - "hasown": "^2.0.2", - "is-core-module": "^2.16.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.8", - "object.groupby": "^1.0.3", - "object.values": "^1.2.1", - "semver": "^6.3.1", - "string.prototype.trimend": "^1.0.9", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "acorn": "^8.15.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/esquery/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", - "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "estraverse": "^5.2.0" - }, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/esrecurse/node_modules/estraverse": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", - "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ev-emitter": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ev-emitter/-/ev-emitter-2.1.2.tgz", - "integrity": "sha512-jQ5Ql18hdCQ4qS+RCrbLfz1n+Pags27q5TwMKvZyhp5hh2UULUYZUy1keqj6k6SYsdqIYjnmz7xyyEY0V67B8Q==", - "license": "MIT" - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true, - "license": "MIT" - }, - "node_modules/events": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", - "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.x" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fastest-levenshtein": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", - "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/file-entry-cache": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", - "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "flat-cache": "^4.0.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/file-loader": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", - "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "loader-utils": "^2.0.0", - "schema-utils": "^3.0.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^4.0.0 || ^5.0.0" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/flat": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", - "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", - "dev": true, - "license": "BSD-3-Clause", - "bin": { - "flat": "cli.js" - } - }, - "node_modules/flat-cache": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", - "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "flatted": "^3.2.9", - "keyv": "^4.5.4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/flatted": { - "version": "3.4.2", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", - "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", - "dev": true, - "license": "ISC" - }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/function.prototype.name": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", - "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "functions-have-names": "^1.2.3", - "hasown": "^2.0.2", - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generator-function": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", - "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-size": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/get-size/-/get-size-3.0.0.tgz", - "integrity": "sha512-Y8aiXLq4leR7807UY0yuKEwif5s3kbVp1nTv+i4jBeoUzByTLKkLWu/HorS6/pB+7gsB0o7OTogC8AoOOeT0Hw==", - "license": "MIT" - }, - "node_modules/get-symbol-description": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", - "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gl-matrix": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", - "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", - "license": "MIT" - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/glob-to-regexp": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/globalthis": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", - "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-properties": "^1.2.1", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "dev": true, - "license": "MIT" - }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", - "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "dev": true, - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "dev": true, - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-minifier-terser": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz", - "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "camel-case": "^4.1.2", - "clean-css": "^5.2.2", - "commander": "^8.3.0", - "he": "^1.2.0", - "param-case": "^3.0.4", - "relateurl": "^0.2.7", - "terser": "^5.10.0" - }, - "bin": { - "html-minifier-terser": "cli.js" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/html-webpack-plugin": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/html-webpack-plugin/-/html-webpack-plugin-5.6.4.tgz", - "integrity": "sha512-V/PZeWsqhfpE27nKeX9EO2sbR+D17A+tLf6qU+ht66jdUsN0QLKJN27Z+1+gHrVMKgndBahes0PU6rRihDgHTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/html-minifier-terser": "^6.0.0", - "html-minifier-terser": "^6.0.2", - "lodash": "^4.17.21", - "pretty-error": "^4.0.0", - "tapable": "^2.0.0" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/html-webpack-plugin" - }, - "peerDependencies": { - "@rspack/core": "0.x || 1.x", - "webpack": "^5.20.0" - }, - "peerDependenciesMeta": { - "@rspack/core": { - "optional": true - }, - "webpack": { - "optional": true - } - } - }, - "node_modules/htmlparser2": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-6.1.0.tgz", - "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.0.1", - "domhandler": "^4.0.0", - "domutils": "^2.5.2", - "entities": "^2.0.0" - } - }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", - "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "2.0.0", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/hyperdyperid": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", - "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/icss-utils": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", - "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/ignore": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", - "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-fresh/node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/interpret": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", - "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/ipaddr.js": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", - "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-async-function": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", - "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "async-function": "^1.0.0", - "call-bound": "^1.0.3", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-data-view": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", - "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "is-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "dev": true, - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-finalizationregistry": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", - "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-generator-function": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", - "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.4", - "generator-function": "^2.0.0", - "get-proto": "^1.0.1", - "has-tostringtag": "^1.0.2", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", - "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-network-error": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.0.tgz", - "integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-plain-object": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", - "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", - "dev": true, - "license": "MIT", - "dependencies": { - "isobject": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", - "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", - "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-worker": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", - "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz", - "integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsdom": { - "version": "28.0.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", - "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.7.6", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^5.3.7", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.20.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/json-buffer": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", - "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", - "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/keyv": { - "version": "4.5.4", - "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", - "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" - } - }, - "node_modules/kind-of": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", - "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/launch-editor": { - "version": "2.14.1", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.1.tgz", - "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picocolors": "^1.1.1", - "shell-quote": "^1.8.4" - } - }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", - "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", - "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", - "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", - "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", - "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", - "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/loader-runner": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", - "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/loader-utils": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", - "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "big.js": "^5.2.2", - "emojis-list": "^3.0.0", - "json5": "^2.1.2" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lower-case": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", - "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.0.3" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", - "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.3", - "@babel/types": "^7.29.0", - "source-map-js": "^1.2.1" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/memfs": { - "version": "4.51.1", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.51.1.tgz", - "integrity": "sha512-Eyt3XrufitN2ZL9c/uIRMyDwXanLI88h/L3MoWqNY747ha3dMR9dWqp8cRT5ntjZ0U1TNuq4U91ZXK0sMBjYOQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jsonjoy.com/json-pack": "^1.11.0", - "@jsonjoy.com/util": "^1.9.0", - "glob-to-regex.js": "^1.0.1", - "thingies": "^2.5.0", - "tree-dump": "^1.0.3", - "tslib": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/miniflare": { - "version": "4.20260611.0", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-4.20260611.0.tgz", - "integrity": "sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@cspotcode/source-map-support": "0.8.1", - "sharp": "0.34.5", - "undici": "7.24.8", - "workerd": "1.20260611.1", - "ws": "8.20.1", - "youch": "4.1.0-beta.10" - }, - "bin": { - "miniflare": "bootstrap.js" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/miniflare/node_modules/undici": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.8.tgz", - "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/moo-color": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/moo-color/-/moo-color-1.0.3.tgz", - "integrity": "sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "^1.1.4" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "dev": true, - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/no-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", - "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "lower-case": "^2.0.2", - "tslib": "^2.0.3" - } - }, - "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.values": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", - "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "dev": true, - "license": "MIT" - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/ootk": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/ootk/-/ootk-5.1.1.tgz", - "integrity": "sha512-ZKERCit0ZFYDhu+BXyKqjU9C0pU1+zj53zBaboDK4c7Qvs+Fota/N/sQICoCX4cVZ7s5FSkRfuzU/svRkSuVeQ==", - "license": "APGL-3.0" - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/optionator": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", - "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0", - "word-wrap": "^1.2.5" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/own-keys": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", - "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.6", - "object-keys": "^1.1.1", - "safe-push-apply": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/param-case": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", - "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", - "dev": true, - "license": "MIT", - "dependencies": { - "dot-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/pascal-case": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", - "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", - "dev": true, - "license": "MIT", - "dependencies": { - "no-case": "^3.0.4", - "tslib": "^2.0.3" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkijs": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz", - "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@noble/hashes": "1.4.0", - "asn1js": "^3.0.6", - "bytestreamjs": "^2.0.1", - "pvtsutils": "^1.3.6", - "pvutils": "^1.1.3", - "tslib": "^2.8.1" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/pkijs/node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.57.0" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", - "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", - "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "icss-utils": "^5.0.0", - "postcss-selector-parser": "^7.0.0", - "postcss-value-parser": "^4.1.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", - "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", - "dev": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", - "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "icss-utils": "^5.0.0" - }, - "engines": { - "node": "^10 || ^12 || >= 14" - }, - "peerDependencies": { - "postcss": "^8.1.0" - } - }, - "node_modules/postcss-selector-parser": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", - "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pretty-error": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/pretty-error/-/pretty-error-4.0.0.tgz", - "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lodash": "^4.17.20", - "renderkid": "^3.0.0" - } - }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "dev": true, - "license": "MIT" - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/pvtsutils": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", - "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - } - }, - "node_modules/pvutils": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", - "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body/node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/rechoir": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", - "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve": "^1.20.0" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", - "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.9", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.1", - "which-builtin-type": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/relateurl": { - "version": "0.2.7", - "resolved": "https://registry.npmjs.org/relateurl/-/relateurl-0.2.7.tgz", - "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/renderkid": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/renderkid/-/renderkid-3.0.0.tgz", - "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-select": "^4.1.3", - "dom-converter": "^0.2.0", - "htmlparser2": "^6.1.0", - "lodash": "^4.17.21", - "strip-ansi": "^6.0.1" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/resolve": { - "version": "1.22.11", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", - "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/retry": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", - "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/rolldown": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", - "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.133.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.3", - "@rolldown/binding-darwin-arm64": "1.0.3", - "@rolldown/binding-darwin-x64": "1.0.3", - "@rolldown/binding-freebsd-x64": "1.0.3", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", - "@rolldown/binding-linux-arm64-gnu": "1.0.3", - "@rolldown/binding-linux-arm64-musl": "1.0.3", - "@rolldown/binding-linux-ppc64-gnu": "1.0.3", - "@rolldown/binding-linux-s390x-gnu": "1.0.3", - "@rolldown/binding-linux-x64-gnu": "1.0.3", - "@rolldown/binding-linux-x64-musl": "1.0.3", - "@rolldown/binding-openharmony-arm64": "1.0.3", - "@rolldown/binding-wasm32-wasi": "1.0.3", - "@rolldown/binding-win32-arm64-msvc": "1.0.3", - "@rolldown/binding-win32-x64-msvc": "1.0.3" - } - }, - "node_modules/run-applescript": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", - "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-array-concat": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", - "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "get-intrinsic": "^1.2.6", - "has-symbols": "^1.1.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safe-push-apply": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", - "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-push-apply/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", - "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/schema-utils": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", - "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.8", - "ajv": "^6.12.5", - "ajv-keywords": "^3.5.2" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "dev": true, - "license": "MIT" - }, - "node_modules/selfsigned": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-5.5.0.tgz", - "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@peculiar/x509": "^1.14.2", - "pkijs": "^3.3.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/serialize-javascript": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", - "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/serve-index": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.1.tgz", - "integrity": "sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "~1.3.4", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.6.2", - "mime-types": "~2.1.17", - "parseurl": "~1.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.6.3.tgz", - "integrity": "sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.3", - "setprototypeof": "1.1.0", - "statuses": ">= 1.4.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/inherits": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==", - "dev": true, - "license": "ISC" - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true, - "license": "MIT" - }, - "node_modules/serve-index/node_modules/setprototypeof": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.0.tgz", - "integrity": "sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-proto": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", - "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "dev": true, - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", - "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, - "node_modules/sockjs/node_modules/uuid": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", - "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.10", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", - "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-data-property": "^1.1.4", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.5", - "es-object-atoms": "^1.0.0", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", - "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.2", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", - "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/style-loader": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", - "integrity": "sha512-0WqXzrsMTyb8yjZJHDqwmnwRJvhALK9LfRtRc6B4UTWe8AijYLZYZ9thuJTZc2VfQWINADW/j+LiJnfy2RoC1w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - } - }, - "node_modules/supabase": { - "version": "2.106.0", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-2.106.0.tgz", - "integrity": "sha512-jV0PJi5LqXUMZ4kriRLvST3DX1YDhrxOgnFJLOn6oUw3KQEFJPXhA60yLQ9aWG1E1uZCp4vUJwSuSGq3HPfaSQ==", - "dev": true, - "license": "MIT", - "bin": { - "supabase": "dist/supabase.js" - }, - "optionalDependencies": { - "@supabase/cli-darwin-arm64": "2.106.0", - "@supabase/cli-darwin-x64": "2.106.0", - "@supabase/cli-linux-arm64": "2.106.0", - "@supabase/cli-linux-arm64-musl": "2.106.0", - "@supabase/cli-linux-x64": "2.106.0", - "@supabase/cli-linux-x64-musl": "2.106.0", - "@supabase/cli-windows-arm64": "2.106.0", - "@supabase/cli-windows-x64": "2.106.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", - "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", - "dev": true, - "license": "MIT" - }, - "node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser": { - "version": "5.44.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.44.0.tgz", - "integrity": "sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "@jridgewell/source-map": "^0.3.3", - "acorn": "^8.15.0", - "commander": "^2.20.0", - "source-map-support": "~0.5.20" - }, - "bin": { - "terser": "bin/terser" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/terser-webpack-plugin": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.6.1.tgz", - "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "jest-worker": "^27.4.5", - "schema-utils": "^4.3.0", - "terser": "^5.31.1" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.1.0" - }, - "peerDependenciesMeta": { - "@minify-html/node": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "@swc/css": { - "optional": true - }, - "@swc/html": { - "optional": true - }, - "clean-css": { - "optional": true - }, - "cssnano": { - "optional": true - }, - "csso": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "html-minifier-terser": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "postcss": { - "optional": true - }, - "uglify-js": { - "optional": true - } - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/terser-webpack-plugin/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/terser/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/thingies": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.5.0.tgz", - "integrity": "sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "^2" - } - }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", - "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.21", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.21.tgz", - "integrity": "sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.21" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.21", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.21.tgz", - "integrity": "sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==", - "dev": true, - "license": "MIT" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/tree-dump": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", - "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/streamich" - }, - "peerDependencies": { - "tslib": "2" - } - }, - "node_modules/ts-loader": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", - "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.0", - "enhanced-resolve": "^5.0.0", - "micromatch": "^4.0.0", - "semver": "^7.3.4", - "source-map": "^0.7.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "typescript": "*", - "webpack": "^5.0.0" - } - }, - "node_modules/ts-loader/node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsyringe": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", - "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tslib": "^1.9.3" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/tsyringe/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "es-errors": "^1.3.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", - "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.14" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", - "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "for-each": "^0.3.3", - "gopd": "^1.2.0", - "has-proto": "^1.2.0", - "is-typed-array": "^1.1.15", - "reflect.getprototypeof": "^1.0.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", - "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "is-typed-array": "^1.1.13", - "possible-typed-array-names": "^1.0.0", - "reflect.getprototypeof": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/unbox-primitive": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", - "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.3", - "has-bigints": "^1.0.2", - "has-symbols": "^1.1.0", - "which-boxed-primitive": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/unenv": { - "version": "2.0.0-rc.24", - "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", - "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "pathe": "^2.0.3" - } - }, - "node_modules/unidragger": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/unidragger/-/unidragger-3.0.1.tgz", - "integrity": "sha512-RngbGSwBFmqGBWjkaH+yB677uzR95blSQyxq6hYbrQCejH3Mx1nm8DVOuh3M9k2fQyTstWUG5qlgCnNqV/9jVw==", - "license": "MIT", - "dependencies": { - "ev-emitter": "^2.0.0" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uri-js": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", - "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/utila": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/utila/-/utila-0.4.0.tgz", - "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", - "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist-node/bin/uuid" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "8.0.16", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", - "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.4", - "postcss": "^8.5.15", - "rolldown": "1.0.3", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.18", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", - "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.9", - "@vitest/mocker": "4.1.9", - "@vitest/pretty-format": "4.1.9", - "@vitest/runner": "4.1.9", - "@vitest/snapshot": "4.1.9", - "@vitest/spy": "4.1.9", - "@vitest/utils": "4.1.9", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.9", - "@vitest/browser-preview": "4.1.9", - "@vitest/browser-webdriverio": "4.1.9", - "@vitest/coverage-istanbul": "4.1.9", - "@vitest/coverage-v8": "4.1.9", - "@vitest/ui": "4.1.9", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest-canvas-mock": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/vitest-canvas-mock/-/vitest-canvas-mock-1.1.3.tgz", - "integrity": "sha512-zlKJR776Qgd+bcACPh0Pq5MG3xWq+CdkACKY/wX4Jyija0BSz8LH3aCCgwFKYFwtm565+050YFEGG9Ki0gE/Hw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssfontparser": "^1.2.1", - "moo-color": "^1.0.3" - }, - "peerDependencies": { - "vitest": "^3.0.0 || ^4.0.0" - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", - "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/watchpack": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", - "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/webpack": { - "version": "5.107.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.107.2.tgz", - "integrity": "sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.8", - "@types/json-schema": "^7.0.15", - "@webassemblyjs/ast": "^1.14.1", - "@webassemblyjs/wasm-edit": "^1.14.1", - "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.16.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.28.1", - "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.22.0", - "es-module-lexer": "^2.1.0", - "eslint-scope": "5.1.1", - "events": "^3.2.0", - "glob-to-regexp": "^0.4.1", - "graceful-fs": "^4.2.11", - "loader-runner": "^4.3.2", - "mime-db": "^1.54.0", - "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", - "terser-webpack-plugin": "^5.5.0", - "watchpack": "^2.5.1", - "webpack-sources": "^3.5.0" - }, - "bin": { - "webpack": "bin/webpack.js" - }, - "engines": { - "node": ">=10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependenciesMeta": { - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-cli": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", - "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.0", - "@webpack-cli/configtest": "^2.1.1", - "@webpack-cli/info": "^2.0.2", - "@webpack-cli/serve": "^2.0.5", - "colorette": "^2.0.14", - "commander": "^10.0.1", - "cross-spawn": "^7.0.3", - "envinfo": "^7.7.3", - "fastest-levenshtein": "^1.0.12", - "import-local": "^3.0.2", - "interpret": "^3.1.1", - "rechoir": "^0.8.0", - "webpack-merge": "^5.7.3" - }, - "bin": { - "webpack-cli": "bin/cli.js" - }, - "engines": { - "node": ">=14.15.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "5.x.x" - }, - "peerDependenciesMeta": { - "@webpack-cli/generators": { - "optional": true - }, - "webpack-bundle-analyzer": { - "optional": true - }, - "webpack-dev-server": { - "optional": true - } - } - }, - "node_modules/webpack-cli/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack-dev-middleware/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-middleware/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack-dev-middleware/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack-dev-middleware/node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.5.tgz", - "integrity": "sha512-4wZtCquSuv9CKX8oybo+mqxtxZqWz47uM1Ch94lxowBztOhWCbhqvRbfC/mODOwxgV2brY+JGZpHq58/SuVFYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.25", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.8.1", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.22.1", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^5.5.0", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/@types/express-serve-static-core": { - "version": "4.19.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.7.tgz", - "integrity": "sha512-FvPtiIf1LfhzsaIXhv/PHan/2FeQBbtBDtfX2QfvPxdUelMDEckK08SM6nqo1MIZY3RUlfA+HV8+hFUSio78qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/webpack-dev-server/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack-dev-server/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack-dev-server/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpack-merge": { - "version": "5.10.0", - "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-5.10.0.tgz", - "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "clone-deep": "^4.0.1", - "flat": "^5.0.2", - "wildcard": "^2.0.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/webpack-sources": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz", - "integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/webpack/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/webpack/node_modules/ajv-keywords": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3" - }, - "peerDependencies": { - "ajv": "^8.8.2" - } - }, - "node_modules/webpack/node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/webpack/node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", - "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", - "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "function.prototype.name": "^1.1.6", - "has-tostringtag": "^1.0.2", - "is-async-function": "^2.0.0", - "is-date-object": "^1.1.0", - "is-finalizationregistry": "^1.1.0", - "is-generator-function": "^1.0.10", - "is-regex": "^1.2.1", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.1.0", - "which-collection": "^1.0.2", - "which-typed-array": "^1.1.16" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "license": "MIT" - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wildcard": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", - "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/word-wrap": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", - "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/workerd": { - "version": "1.20260611.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260611.1.tgz", - "integrity": "sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "bin": { - "workerd": "bin/workerd" - }, - "engines": { - "node": ">=16" - }, - "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20260611.1", - "@cloudflare/workerd-darwin-arm64": "1.20260611.1", - "@cloudflare/workerd-linux-64": "1.20260611.1", - "@cloudflare/workerd-linux-arm64": "1.20260611.1", - "@cloudflare/workerd-windows-64": "1.20260611.1" - } - }, - "node_modules/wrangler": { - "version": "4.100.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.100.0.tgz", - "integrity": "sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==", - "dev": true, - "license": "MIT OR Apache-2.0", - "dependencies": { - "@cloudflare/kv-asset-handler": "0.5.0", - "@cloudflare/unenv-preset": "2.16.1", - "blake3-wasm": "2.1.5", - "esbuild": "0.27.3", - "miniflare": "4.20260611.0", - "path-to-regexp": "6.3.0", - "unenv": "2.0.0-rc.24", - "workerd": "1.20260611.1" - }, - "bin": { - "cf-wrangler": "bin/cf-wrangler.js", - "wrangler": "bin/wrangler.js", - "wrangler2": "bin/wrangler.js" - }, - "engines": { - "node": ">=22.0.0" - }, - "optionalDependencies": { - "fsevents": "2.3.3" - }, - "peerDependencies": { - "@cloudflare/workers-types": "^4.20260611.1" - }, - "peerDependenciesMeta": { - "@cloudflare/workers-types": { - "optional": true - } - } - }, - "node_modules/wrangler/node_modules/path-to-regexp": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", - "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", - "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, - "license": "MIT" - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/youch": { - "version": "4.1.0-beta.10", - "resolved": "https://registry.npmjs.org/youch/-/youch-4.1.0-beta.10.tgz", - "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/colors": "^4.1.5", - "@poppinss/dumper": "^0.6.4", - "@speed-highlight/core": "^1.2.7", - "cookie": "^1.0.2", - "youch-core": "^0.3.3" - } - }, - "node_modules/youch-core": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/youch-core/-/youch-core-0.3.3.tgz", - "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@poppinss/exception": "^1.2.2", - "error-stack-parser-es": "^1.0.5" - } - }, - "node_modules/youch/node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - } - } -} diff --git a/package.json b/package.json index fbb858f7..f869218e 100644 --- a/package.json +++ b/package.json @@ -3,29 +3,49 @@ "version": "1.1.0", "description": "Signal Range: Space Electronic Warfare Lab", "main": "dist/index.js", + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=24", + "pnpm": ">=10" + }, + "volta": { + "node": "24.14.0" + }, "scripts": { - "build": "cross-env NODE_ENV=production webpack --mode production", - "dev": "cross-env NODE_ENV=development webpack serve --mode development", - "deploy": "npm run build && wrangler deploy", - "deploy:dry-run": "npm run build && wrangler deploy --dry-run", - "deploy:full": "npm run build && npm run r2:sync && wrangler deploy", + "prepare": "husky", + "prebuild": "tsx ./scripts/plugin/index.ts sync", + "build": "cross-env NODE_ENV=production rspack build --mode production", + "predev": "tsx ./scripts/plugin/index.ts sync", + "dev": "cross-env NODE_ENV=development rspack serve --mode development", + "plugin": "tsx ./scripts/plugin/index.ts", + "plugin:export-template": "tsx ./scripts/plugin/export-template.ts", + "deploy": "pnpm run build && wrangler deploy", + "deploy:dry-run": "pnpm run build && wrangler deploy --dry-run", + "deploy:full": "pnpm run build && pnpm run r2:sync && wrangler deploy", "preview": "wrangler dev", "r2:sync": "node scripts/sync-r2-assets.js", "r2:sync:dry": "node scripts/sync-r2-assets.js --dry-run", "r2:sync:verbose": "node scripts/sync-r2-assets.js --verbose", "r2:pull": "node scripts/sync-r2-assets.js --pull", "r2:pull:dry": "node scripts/sync-r2-assets.js --pull --dry-run", - "type-check": "tsc --noEmit", + "nice-coverage": "node scripts/nice-coverage.mjs", + "nice-catalog": "node scripts/gen-nice-catalog.mjs", "clean": "rm -rf dist", + "typecheck": "tsgo --noEmit -p tsconfig.json", + "typecheck:tsc": "tsc --noEmit -p tsconfig.json", + "typecheck:test": "tsgo --noEmit -p tsconfig.test.json", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", - "lint": "npx eslint", - "lcov": "npx tsx build/open-lcov.ts", + "lint": "biome check ./src ./test ./e2e ./build ./scripts/plugin", + "lcov": "pnpm exec tsx build/open-lcov.ts", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", - "test:e2e:report": "playwright show-report" + "test:e2e:report": "playwright show-report", + "lint:fix": "biome check --write ./src ./test ./e2e ./build ./scripts/plugin", + "format": "biome format --write ./src ./test ./e2e ./build ./scripts/plugin", + "vo-manifest": "node scripts/vo-manifest.mjs" }, "keywords": [ "space", @@ -35,30 +55,29 @@ "author": "Theodore Kruczek", "license": "AGPL-3.0", "devDependencies": { + "@biomejs/biome": "2.5.4", "@playwright/test": "^1.48.0", + "@rspack/cli": "2.0.5", + "@rspack/core": "2.0.5", + "@rspack/dev-server": "2.2.1", "@types/draggabilly": "^3.0.0", "@types/node": "^20.19.24", + "@types/semver": "7.7.0", + "@typescript/native-preview": "7.0.0-dev.20260707.2", "@vitest/coverage-v8": "^4.0.18", "baseline-browser-mapping": "^2.9.11", - "case-sensitive-paths-webpack-plugin": "^2.4.0", - "copy-webpack-plugin": "^13.0.1", "cross-env": "^10.1.0", - "css-loader": "^6.8.1", - "eslint": "^9.39.2", - "eslint-plugin-import": "^2.32.0", - "file-loader": "^6.2.0", - "html-webpack-plugin": "^5.5.3", + "css-loader": "^7.1.4", + "express": "4.22.2", + "husky": "^9.1.7", "jsdom": "^28.0.0", - "style-loader": "^3.3.3", + "semver": "7.7.2", + "style-loader": "^4.0.0", "supabase": "^2.67.3", - "ts-loader": "^9.5.1", "tsx": "^4.20.6", "typescript": "^5.3.3", "vitest": "^4.0.18", "vitest-canvas-mock": "^1.1.3", - "webpack": "^5.89.0", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^5.2.5", "wrangler": "^4.100.0" }, "dependencies": { @@ -70,12 +89,21 @@ "ootk": "^5.1.1", "uuid": "^13.0.0" }, - "overrides": { - "esbuild": "^0.28.1", - "ws": "^8.21.0", - "serialize-javascript": "^7.0.5", - "sockjs": { - "uuid": "^11.1.1" - } + "pnpm": { + "overrides": { + "esbuild": "^0.28.1", + "ws": "^8.21.0", + "serialize-javascript": "^7.0.5", + "sockjs>uuid": "^11.1.1" + }, + "ignoredBuiltDependencies": [ + "core-js", + "esbuild", + "supabase" + ], + "onlyBuiltDependencies": [ + "sharp", + "workerd" + ] } } diff --git a/playwright.config.ts b/playwright.config.ts index 4a41abdd..d909fa75 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -17,7 +17,7 @@ export default defineConfig({ }, use: { // 127.0.0.1 (not localhost) so another dev server bound to IPv6 ::1:3000 - // cannot shadow the webpack server, which binds the IPv4/dual stack. + // cannot shadow the rspack server, which binds the IPv4/dual stack. baseURL: 'http://127.0.0.1:3000', trace: 'on-first-retry', screenshot: 'only-on-failure', @@ -30,7 +30,7 @@ export default defineConfig({ }, ], webServer: { - command: 'npm run dev', + command: 'pnpm run dev', url: 'http://127.0.0.1:3000', reuseExistingServer: !process.env.CI, timeout: 120000, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 00000000..8be687e5 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,4377 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + esbuild: ^0.28.1 + ws: ^8.21.0 + serialize-javascript: ^7.0.5 + sockjs>uuid: ^11.1.1 + +importers: + + .: + dependencies: + '@supabase/supabase-js': + specifier: ^2.81.1 + version: 2.81.1 + '@tabler/core': + specifier: ^1.4.0 + version: 1.4.0 + dotenv: + specifier: ^17.2.3 + version: 17.2.3 + draggabilly: + specifier: ^3.0.0 + version: 3.0.0 + gl-matrix: + specifier: ^3.4.4 + version: 3.4.4 + ootk: + specifier: ^5.1.1 + version: 5.1.1 + uuid: + specifier: ^13.0.0 + version: 13.0.2 + devDependencies: + '@biomejs/biome': + specifier: 2.5.4 + version: 2.5.4 + '@playwright/test': + specifier: ^1.48.0 + version: 1.57.0 + '@rspack/cli': + specifier: 2.0.5 + version: 2.0.5(@rspack/core@2.0.5)(@rspack/dev-server@2.2.1(@rspack/core@2.0.5)(selfsigned@5.5.0)) + '@rspack/core': + specifier: 2.0.5 + version: 2.0.5 + '@rspack/dev-server': + specifier: 2.2.1 + version: 2.2.1(@rspack/core@2.0.5)(selfsigned@5.5.0) + '@types/draggabilly': + specifier: ^3.0.0 + version: 3.0.0 + '@types/node': + specifier: ^20.19.24 + version: 20.19.24 + '@types/semver': + specifier: 7.7.0 + version: 7.7.0 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260707.2 + version: 7.0.0-dev.20260707.2 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.1.9(vitest@4.1.9) + baseline-browser-mapping: + specifier: ^2.9.11 + version: 2.10.37 + cross-env: + specifier: ^10.1.0 + version: 10.1.0 + css-loader: + specifier: ^7.1.4 + version: 7.1.4(@rspack/core@2.0.5)(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)) + express: + specifier: 4.22.2 + version: 4.22.2 + husky: + specifier: ^9.1.7 + version: 9.1.7 + jsdom: + specifier: ^28.0.0 + version: 28.0.0 + semver: + specifier: 7.7.2 + version: 7.7.2 + style-loader: + specifier: ^4.0.0 + version: 4.0.0(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)) + supabase: + specifier: ^2.67.3 + version: 2.106.0 + tsx: + specifier: ^4.20.6 + version: 4.22.4 + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.1.9(@types/node@20.19.24)(@vitest/coverage-v8@4.1.9)(jsdom@28.0.0)(vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4)) + vitest-canvas-mock: + specifier: ^1.1.3 + version: 1.1.3(vitest@4.1.9) + wrangler: + specifier: ^4.100.0 + version: 4.100.0 + +packages: + + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@asamuzakjp/css-color@4.1.1': + resolution: {integrity: sha512-B0Hv6G3gWGMn0xKJ0txEi/jM5iFpT3MfDxmhZFb4W047GvytCf1DHQ1D69W3zHI4yWe2aTZAA0JnbMZ7Xc8DuQ==} + + '@asamuzakjp/dom-selector@6.7.7': + resolution: {integrity: sha512-8CO/UQ4tzDd7ula+/CVimJIVWez99UJlbMyIgk8xOnhAVPKLnBZmUFYVgugS441v2ZqUq5EnSh6B0Ua0liSFAA==} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@biomejs/biome@2.5.4': + resolution: {integrity: sha512-xy5FNE5kQJKyK5MR1gJy6ztXYx4WBAbYGlK04lMEgmyPRWKybY9NFwiG9yo0XdzOU8Xvhj41u034J1ywfoWfMw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.4': + resolution: {integrity: sha512-4o3NFRobXHynkgcFVrlZsoDAFtF2ldlEGN8sORSws5ZQqyY4PXnPUIylu4ksfyHuwkfvDREuWh3JK+niRwGq3w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.4': + resolution: {integrity: sha512-D32P5HkU2Y6PySuC/WsVDTOgsDwVFmujzhhhOQjajtATpVWFDXuVd3oRbsWNSEA+aaFzyzZm22szsyydBYlSyQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.4': + resolution: {integrity: sha512-Rpm5/AT1m+DlJmUoYvS4/vXc+0tXJPJ2NQz25TGPyHVF5JrWy75PE0GH6kVxsKtQDuCH4OgzquZq0R4kj/wCVg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.4': + resolution: {integrity: sha512-pSEfW7B8kTsXUjUxC1xVVK+y85Ht3C5XxZ9gclmC7/3Ku9Vqz8jmI7k0p/BNIjQ6t4sFERI2sFeH73ybiZl6YQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.4': + resolution: {integrity: sha512-aby/PohmmgbShcHqFsZVzG8H6D98+P+A6xRWRrQcLW1pCjabcov5UUlke4UqNQBYTkDQav+jB4zyyDDeKB2GaA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.4': + resolution: {integrity: sha512-FNxojWJkL7EajAuzBgoLe0T2G0y112M4lBrDIFl/DomFTx8yqenYOIdsRLNXvOvBBofE8hJi85LjzLmBDpY7/Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.4': + resolution: {integrity: sha512-emoXexPZIPAZkz2RKmA95WJUqK3I5MJNYtwEbL5ESciRzhmFMMyekDhNG8hpeOaK+ZGRDxAU4wvGuA5IHQ0h0w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.4': + resolution: {integrity: sha512-U1jaluLw1qQc2Tx7/CeSoL9N5XcqIH+GWjpUAy1ouB5nVjSCMNO+NNHdY3RAs8zxNurLWAdj6pehQdCA2zyU+Q==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260611.1': + resolution: {integrity: sha512-iJICldmi4sBGgi7IrQles8cStOGXM/Tmv95C4OODVs6VIbMsJPqThUM5h3uYVQNULuJ8I/aVvnJ3Eh/wZCKwuA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + resolution: {integrity: sha512-yBbVXvbZyltR3I7NJdC4C4ItkItjZSiabcA/3HzEWOUQjLVKFqRh4so6ToHr70VCYh8VGeR8EDZL23igLhXqFQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260611.1': + resolution: {integrity: sha512-PfNjpxOlaIgZFYuhD7+neEEewCN2Ud993wEEN0fmbtSOax1AK53LGqmXUDvFhnbkHxJLFAxYCSNISW8QbzaAIg==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + resolution: {integrity: sha512-GEp4XbuIKjlF8pakqXcUDJfKiJosD/Q7S83J0d+r+z9XIlYGfF3ntm08e2aiF5TFTwp3fnG4yMoPUAKNhNJpvQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260611.1': + resolution: {integrity: sha512-S6JkS0kEbcCKs19RGqEPhjCRbP8GBkQwqYLp2fhBJtD/KTlwqLzOJ9E6PQ7gQKgWHtxy1NBG3oXarlNFRNU/dw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.26': + resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@exodus/bytes@1.11.0': + resolution: {integrity: sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@noble/hashes@1.4.0': + resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} + engines: {node: '>= 16'} + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@peculiar/asn1-cms@2.8.0': + resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} + + '@peculiar/asn1-csr@2.8.0': + resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} + + '@peculiar/asn1-ecc@2.8.0': + resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} + + '@peculiar/asn1-pfx@2.8.0': + resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} + + '@peculiar/asn1-rsa@2.8.0': + resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} + + '@peculiar/asn1-schema@2.8.0': + resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + + '@peculiar/asn1-x509-attr@2.8.0': + resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} + + '@peculiar/asn1-x509@2.8.0': + resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} + + '@peculiar/utils@2.0.3': + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} + + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + + '@playwright/test@1.57.0': + resolution: {integrity: sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==} + engines: {node: '>=18'} + hasBin: true + + '@popperjs/core@2.11.8': + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rspack/binding-darwin-arm64@2.0.5': + resolution: {integrity: sha512-++wjLQjQ20GcR0DwbzQmVXg9qy4XCX5NlfSzkzj2icHoDxr3KkrXhyVrQkdWuNG6l/bQrGLPnvLEAqkroC2Y7A==} + cpu: [arm64] + os: [darwin] + + '@rspack/binding-darwin-x64@2.0.5': + resolution: {integrity: sha512-JBD5mCN3JKjV64Mh9nDYx8lLUrWDfEl5tLBuMkREUnqEKbo+z4nfwotyqHHM8/XgZwL+Gr7ps4GLWuQQrZB8+Q==} + cpu: [x64] + os: [darwin] + + '@rspack/binding-linux-arm64-gnu@2.0.5': + resolution: {integrity: sha512-JI8+//woanJPNsfL7iGjX39zyiWumnrKHznWQM/7lEtE5nPmk+j+X7TYXxczSWC9zfZegiqI74D3L5JPDC84Fw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-arm64-musl@2.0.5': + resolution: {integrity: sha512-5LujilxLtJFRiiPz5i5iWcWJriK9oy4gN7gZtTo8YRB7wwmwA8LMypTjjO0GLbkPS4/KeCfY4fDfTC29KmK+tA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rspack/binding-linux-x64-gnu@2.0.5': + resolution: {integrity: sha512-241wqE132jh+/U/pn97qUPV4KpIy4bSrTH0tqfzQCocgw+8hrUj02GqNG+3MXVC3qtwaQeJFYgEBy3TqFKsrIQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rspack/binding-linux-x64-musl@2.0.5': + resolution: {integrity: sha512-BhaXZD064Lci3Kia0kLDAb4TyxO2C+0UidMlj44e8+ctasxIfFZgnrhCJrhTFHAtOiAwqhU3FHun2UuxPqX0Eg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rspack/binding-wasm32-wasi@2.0.5': + resolution: {integrity: sha512-duEkRoXrl9SW8uGHv7JURJ5lgKu87qFDQ4Exy6UQPvsUJVXhtRXTfvMHCb/CejVJuW2Bw2D632/axZq3qRSuBQ==} + cpu: [wasm32] + + '@rspack/binding-win32-arm64-msvc@2.0.5': + resolution: {integrity: sha512-q2WT3HFoWL+2g84l3s2kY7CiE1gEZ1bwB3txx3eZzQQ6YKP7bE82z6sl6S/pTOHGjHdAO4snQXpSaHwUt3LX5g==} + cpu: [arm64] + os: [win32] + + '@rspack/binding-win32-ia32-msvc@2.0.5': + resolution: {integrity: sha512-nMJGIY7kvgbyMolEE7tXDe+Z9jSItDshTIqMQQkkD3WTHdjlBQozHxk4kBtKLsunO+3NkCLe5Oa3hXg1yyStIg==} + cpu: [ia32] + os: [win32] + + '@rspack/binding-win32-x64-msvc@2.0.5': + resolution: {integrity: sha512-vP0BR6fxdPL9cb02HAuZATg/CjR07aecWel3s1vqRwW1aDffgXh9PVmqEKIHTgyaNsNR55kSKNJsB9AcQ8/QrA==} + cpu: [x64] + os: [win32] + + '@rspack/binding@2.0.5': + resolution: {integrity: sha512-Ta1y4WXJA87wM1OstqaMddoPsBGv7Cu779bYToKxEAqR/Yy9DxLkp7bdgBaAx2JH++BwVjV+toWts2V9AaiTFQ==} + + '@rspack/cli@2.0.5': + resolution: {integrity: sha512-AAyuY/8sfegb0IcsFEhn5gLOTxy1uDmwjP49RY81PENz2pCk/7NU0zDdt0ke5wmpOH5s6IxS+Kw4aWdvl8FGpQ==} + hasBin: true + peerDependencies: + '@rspack/core': ^2.0.0-0 + '@rspack/dev-server': ^2.0.0-0 + peerDependenciesMeta: + '@rspack/dev-server': + optional: true + + '@rspack/core@2.0.5': + resolution: {integrity: sha512-9tv2HAnSiTote5WPH2tmz1hLZ1zKbzkiZc1eYp7LP/8jcsiJBuf40ihiWidAgbbuYtJo3kWET6q+qOm5UhNiGA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@module-federation/runtime-tools': ^0.24.1 || ^2.0.0 + '@swc/helpers': ^0.5.23 + peerDependenciesMeta: + '@module-federation/runtime-tools': + optional: true + '@swc/helpers': + optional: true + + '@rspack/dev-middleware@2.0.4': + resolution: {integrity: sha512-VCdT8hv9YvMPiBohhukD/OkGB+2GJ7mPRbeuDJ/jIT6hv5gvmJCnydMetJVARh6vXoBtfSHEd2gX78Dqaxh1NQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rspack/core': ^2.0.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + + '@rspack/dev-server@2.2.1': + resolution: {integrity: sha512-Il8HbYGK3rH5rM0XmUOsOGZVw69BKRpBZ61P28Fy8ry35CtfniDBWtbo/rvJtIT/sK0qwijmNrhF498ltsV+uA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rspack/core': ^2.0.0 + selfsigned: ^5.0.0 + peerDependenciesMeta: + selfsigned: + optional: true + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.17': + resolution: {integrity: sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@supabase/auth-js@2.81.1': + resolution: {integrity: sha512-K20GgiSm9XeRLypxYHa5UCnybWc2K0ok0HLbqCej/wRxDpJxToXNOwKt0l7nO8xI1CyQ+GrNfU6bcRzvdbeopQ==} + engines: {node: '>=20.0.0'} + + '@supabase/cli-darwin-arm64@2.106.0': + resolution: {integrity: sha512-xi/IAjjbebh6xA9Nkkb7ZwC5ACKJ38OqMoYk1y20ScW5Wt+pxTCcOcaWDWu+2u2Pwc3vTYnXhbknVcQt9UcChQ==} + cpu: [arm64] + os: [darwin] + + '@supabase/cli-darwin-x64@2.106.0': + resolution: {integrity: sha512-AT2s/8/3Ffb7IkcUXK6sA/iPCzJhh4Fh/XqzAGNGsVd1xCA/ZGniqU8BL03TqPkN1sR0iDaShjMLLIlHEflyPQ==} + cpu: [x64] + os: [darwin] + + '@supabase/cli-linux-arm64-musl@2.106.0': + resolution: {integrity: sha512-2LExuOHuU6odXorNTvq4M/OvueW+wOs3KDsaCpk+scQMnB6LbmqNidEjXAsKpZu3zyE8MmmtdbJBlRNN/KLdcw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@supabase/cli-linux-arm64@2.106.0': + resolution: {integrity: sha512-L/pX7fkV31x7zPrUOcD6KMtZ03j16o41XxgkACFoOAE9lFz8KaJOBSHvn2QLeWEa2kLz7jwpisvpshPSMjuxOw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@supabase/cli-linux-x64-musl@2.106.0': + resolution: {integrity: sha512-wz7heB3wwzmRdLRgqngd2VjPPNIdv6/Y8Q0dX/ZvjcL9/Nk8BYSZ7yjypapLpra+swRnbEJ4jAU6eAShiaB1Hg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@supabase/cli-linux-x64@2.106.0': + resolution: {integrity: sha512-+ihtqFNURc8ssULgQoHQBR4AtUmxez9dtqjclqOV0y5r65SX7Ginmiby4XDW9LPlqHT+MB7OyD1vk0T6pQUtPA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@supabase/cli-windows-arm64@2.106.0': + resolution: {integrity: sha512-QkEfRDiuuQwcfASo2DIJZ59AFV6xpqhPNo4r0kSVt38StYk3iL7ZcSOkZvGQWLlJ8kXKPBWEMhLnfHQF9r+Xfg==} + cpu: [arm64] + os: [win32] + + '@supabase/cli-windows-x64@2.106.0': + resolution: {integrity: sha512-1tQFbrH1KJhWJqHrY087XPz1WRi20eKG1pebG+6PjSD6XN+j0+x1PTOtBZrylXgY1fOg8BqgU8kl3KX8hI5l9w==} + cpu: [x64] + os: [win32] + + '@supabase/functions-js@2.81.1': + resolution: {integrity: sha512-sYgSO3mlgL0NvBFS3oRfCK4OgKGQwuOWJLzfPyWg0k8MSxSFSDeN/JtrDJD5GQrxskP6c58+vUzruBJQY78AqQ==} + engines: {node: '>=20.0.0'} + + '@supabase/postgrest-js@2.81.1': + resolution: {integrity: sha512-DePpUTAPXJyBurQ4IH2e42DWoA+/Qmr5mbgY4B6ZcxVc/ZUKfTVK31BYIFBATMApWraFc8Q/Sg+yxtfJ3E0wSg==} + engines: {node: '>=20.0.0'} + + '@supabase/realtime-js@2.81.1': + resolution: {integrity: sha512-ViQ+Kxm8BuUP/TcYmH9tViqYKGSD1LBjdqx2p5J+47RES6c+0QHedM0PPAjthMdAHWyb2LGATE9PD2++2rO/tw==} + engines: {node: '>=20.0.0'} + + '@supabase/storage-js@2.81.1': + resolution: {integrity: sha512-UNmYtjnZnhouqnbEMC1D5YJot7y0rIaZx7FG2Fv8S3hhNjcGVvO+h9We/tggi273BFkiahQPS/uRsapo1cSapw==} + engines: {node: '>=20.0.0'} + + '@supabase/supabase-js@2.81.1': + resolution: {integrity: sha512-KSdY7xb2L0DlLmlYzIOghdw/na4gsMcqJ8u4sD6tOQJr+x3hLujU9s4R8N3ob84/1bkvpvlU5PYKa1ae+OICnw==} + engines: {node: '>=20.0.0'} + + '@tabler/core@1.4.0': + resolution: {integrity: sha512-5BigzOlbOH9N0Is4u0rYNRCiwtnUXWO57K9zwuscygcicAa8UV9MGaS4zTgQsZEtZ9tsNANhN/YD8gCBGKYCiw==} + engines: {node: '>=20'} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/draggabilly@3.0.0': + resolution: {integrity: sha512-TymnKZbC3bBXHnE2AE1buGKrNLSpxq7J9qff2egNd+nN26E1p/elh0fyi3RpmR9mG6/OmdbPOlqVXqpUGTdmSQ==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@20.19.24': + resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==} + + '@types/phoenix@1.6.6': + resolution: {integrity: sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==} + + '@types/semver@7.7.0': + resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-wny2pgKjGbiZtnOIHVa3tXC1UfDqxNEFzyPGmiqybedG8hipG2Nfp0l5UxbaKCjkLacUpH/W5bP2hBOMVhCOzg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-Afc7M5zOwo+GpfcYwz5Z8HMB2tPVsui7nNIqEuuFB73MPdVqNn/Wmpe4tP4MRri0AtJnJknoHBaTJ/VDAp/Jhw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-iITBa2WjjTI5N9t5l7Z4KoOSI+2zBlhbvFzsD/f8qX8QoKjz/Y4DPyBDgezYi8nkqjjksbgSOJ3/ykzhwrB9cg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-hJm/UOqZTr9FHmR7uNm8VGX4oKtfWk0Jem0zPeJFNC8ckGUfSBueyiEYMZB+XmRc1aG4x1E46y3CplP4CLHvGQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-du0dzi6y97Po5vDNdPJTyyijHCpaS22JLRnKZEJXBDaO9gCIymOv/5QQokFRuOlQm0bWl3i9PF4OVdGP6uAOQA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-SsAwfhyHJ1akgBc+99z4+hwdbHsdWaKB8EwCNIMA6JfSLMeUjffrYvxu+vfMyxVtOVOz7RrRXRoiDiu4a2sCtg==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-DL4u27stv0fo71sVhOzHSwE+YMZsbBijVI+kg5dLDLilSH79WFTJ8RSQ46vJrCMt+Gjlv/JOZP1PuLJDfioYeQ==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@typescript/native-preview@7.0.0-dev.20260707.2': + resolution: {integrity: sha512-oUGp+Rep/hqMhPunyinsALUwSlzHINSxitifPiSaeqoKOKD2OlR9NE3TaPqwsl4NlGslsOSUXI1JotWQzpYCPg==} + engines: {node: '>=16.20.0'} + hasBin: true + + '@vitest/coverage-v8@4.1.9': + resolution: {integrity: sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==} + peerDependencies: + '@vitest/browser': 4.1.9 + vitest: 4.1.9 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/expect@4.1.9': + resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==} + + '@vitest/mocker@4.1.9': + resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.9': + resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==} + + '@vitest/runner@4.1.9': + resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==} + + '@vitest/snapshot@4.1.9': + resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==} + + '@vitest/spy@4.1.9': + resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==} + + '@vitest/utils@4.1.9': + resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==} + + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + + acorn-import-phases@1.0.4: + resolution: {integrity: sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==} + engines: {node: '>=10.13.0'} + peerDependencies: + acorn: ^8.14.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + + asn1js@3.0.10: + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} + engines: {node: '>=12.0.0'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.4: + resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + + baseline-browser-mapping@2.10.37: + resolution: {integrity: sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==} + engines: {node: '>=6.0.0'} + hasBin: true + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + body-parser@1.20.8: + resolution: {integrity: sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + bootstrap@5.3.7: + resolution: {integrity: sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==} + peerDependencies: + '@popperjs/core': ^2.11.8 + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + bytestreamjs@2.0.1: + resolution: {integrity: sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==} + engines: {node: '>=6.0.0'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + css-loader@7.1.4: + resolution: {integrity: sha512-vv3J9tlOl04WjiMvHQI/9tmIrCxVrj6PFbHemBB1iihpeRbi/I4h033eoFIhwxBBqLhI0KYFS7yvynBFhIZfTw==} + engines: {node: '>= 18.12.0'} + peerDependencies: + '@rspack/core': 0.x || ^1.0.0 || ^2.0.0-0 + webpack: ^5.27.0 + peerDependenciesMeta: + '@rspack/core': + optional: true + webpack: + optional: true + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + cssfontparser@1.2.1: + resolution: {integrity: sha512-6tun4LoZnj7VN6YeegOVb67KBX/7JJsqvj+pv3ZA7F878/eN33AbGa5b/S/wXxS/tcp8nc40xRUrsPlxIyNUPg==} + + cssstyle@5.3.7: + resolution: {integrity: sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==} + engines: {node: '>=20'} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dotenv@17.2.3: + resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} + engines: {node: '>=12'} + + draggabilly@3.0.0: + resolution: {integrity: sha512-aEs+B6prbMZQMxc9lgTpCBfyCUhRur/VFucHhIOvlvvdARTj7TcDmX/cdOUtqbjJJUh7+agyJXR5Z6IFe1MxwQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.373: + resolution: {integrity: sha512-G2Hym8JIf/QreuseqkDibgH8Ci8KfJzqGDKdakbhSx9UltwRBH2cBLAWU/lBX0sCdv0TlhyxQyDCnSfxgMWsjA==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.24.0: + resolution: {integrity: sha512-SkE2t82KlkkxQRVMVLAGKxLfORGQfrkx5dkj+vlgXRVNEdPc4eZcR+J/Fvj8C+yKSFH5L0q3NFlyufOVQnCcYQ==} + engines: {node: '>=10.13.0'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + ev-emitter@2.1.2: + resolution: {integrity: sha512-jQ5Ql18hdCQ4qS+RCrbLfz1n+Pags27q5TwMKvZyhp5hh2UULUYZUy1keqj6k6SYsdqIYjnmz7xyyEY0V67B8Q==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + express@4.22.2: + resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} + engines: {node: '>= 0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + finalhandler@1.3.2: + resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} + engines: {node: '>= 0.8'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-size@3.0.0: + resolution: {integrity: sha512-Y8aiXLq4leR7807UY0yuKEwif5s3kbVp1nTv+i4jBeoUzByTLKkLWu/HorS6/pB+7gsB0o7OTogC8AoOOeT0Hw==} + + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + + glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + husky@9.1.7: + resolution: {integrity: sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==} + engines: {node: '>=18'} + hasBin: true + + iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + + icss-utils@5.1.0: + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + jsdom@28.0.0: + resolution: {integrity: sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + loader-runner@4.3.2: + resolution: {integrity: sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==} + engines: {node: '>=6.11.5'} + + lru-cache@11.2.5: + resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + miniflare@4.20260611.0: + resolution: {integrity: sha512-i+JwEo8vN96naz1WL3ntFgFyRluBDYL408zwhHKvR2jefJ464KsZ/gCmJAQ5k+oaWeb5Ug+s7yne5AyiAEswjg==} + engines: {node: '>=22.0.0'} + hasBin: true + + moo-color@1.0.3: + resolution: {integrity: sha512-i/+ZKXMDf6aqYtBhuOcej71YSlbjT3wCO/4H1j8rPvxDJEifdwgg5MaFyu6iYAT8GBZJg2z0dkgK4YMzvURALQ==} + + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + ootk@5.1.1: + resolution: {integrity: sha512-ZKERCit0ZFYDhu+BXyKqjU9C0pU1+zj53zBaboDK4c7Qvs+Fota/N/sQICoCX4cVZ7s5FSkRfuzU/svRkSuVeQ==} + + parse5@8.0.0: + resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@0.1.13: + resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pkijs@3.4.0: + resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==} + engines: {node: '>=16.0.0'} + + playwright-core@1.57.0: + resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.57.0: + resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + engines: {node: '>=18'} + hasBin: true + + postcss-modules-extract-imports@3.1.0: + resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-local-by-default@4.2.0: + resolution: {integrity: sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-scope@3.2.1: + resolution: {integrity: sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-modules-values@4.0.0: + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + + postcss-selector-parser@7.1.0: + resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + pvtsutils@1.3.6: + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} + + pvutils@1.1.5: + resolution: {integrity: sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==} + engines: {node: '>=16.0.0'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@2.5.3: + resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} + engines: {node: '>= 0.8'} + + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + + selfsigned@5.5.0: + resolution: {integrity: sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==} + engines: {node: '>=18'} + + semver@7.7.2: + resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + + send@0.19.2: + resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} + engines: {node: '>= 0.8.0'} + + serve-static@1.16.3: + resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} + engines: {node: '>= 0.8.0'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + style-loader@4.0.0: + resolution: {integrity: sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.27.0 + + supabase@2.106.0: + resolution: {integrity: sha512-jV0PJi5LqXUMZ4kriRLvST3DX1YDhrxOgnFJLOn6oUw3KQEFJPXhA60yLQ9aWG1E1uZCp4vUJwSuSGq3HPfaSQ==} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + terser-webpack-plugin@5.6.1: + resolution: {integrity: sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + + terser@5.44.0: + resolution: {integrity: sha512-nIVck8DK+GM/0Frwd+nIhZ84pR/BX7rmXMfYwyg+Sri5oGVE99/E3KvXqpC2xHFxyqXyGHTKBSioxxplrO4I4w==} + engines: {node: '>=10'} + hasBin: true + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.0.21: + resolution: {integrity: sha512-oVOMdHvgjqyzUZH1rOESgJP1uNe2bVrfK0jUHHmiM2rpEiRbf3j4BrsIc6JigJRbHGanQwuZv/R+LTcHsw+bLA==} + + tldts@7.0.21: + resolution: {integrity: sha512-Plu6V8fF/XU6d2k8jPtlQf5F4Xx2hAin4r2C2ca7wR8NK5MbRTo9huLUWRe28f3Uk8bYZfg74tit/dSjc18xnw==} + hasBin: true + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tough-cookie@6.0.0: + resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.22.4: + resolution: {integrity: sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==} + engines: {node: '>=18.0.0'} + hasBin: true + + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + undici@7.24.8: + resolution: {integrity: sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==} + engines: {node: '>=20.18.1'} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unidragger@3.0.1: + resolution: {integrity: sha512-RngbGSwBFmqGBWjkaH+yB677uzR95blSQyxq6hYbrQCejH3Mx1nm8DVOuh3M9k2fQyTstWUG5qlgCnNqV/9jVw==} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + + uuid@13.0.2: + resolution: {integrity: sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==} + hasBin: true + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.28.1 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest-canvas-mock@1.1.3: + resolution: {integrity: sha512-zlKJR776Qgd+bcACPh0Pq5MG3xWq+CdkACKY/wX4Jyija0BSz8LH3aCCgwFKYFwtm565+050YFEGG9Ki0gE/Hw==} + peerDependencies: + vitest: ^3.0.0 || ^4.0.0 + + vitest@4.1.9: + resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.9 + '@vitest/browser-preview': 4.1.9 + '@vitest/browser-webdriverio': 4.1.9 + '@vitest/coverage-istanbul': 4.1.9 + '@vitest/coverage-v8': 4.1.9 + '@vitest/ui': 4.1.9 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + webpack-sources@3.5.0: + resolution: {integrity: sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==} + engines: {node: '>=10.13.0'} + + webpack@5.107.2: + resolution: {integrity: sha512-v7RhXaJbpMlV0D7hC7lb2EbnxkoeUqf9qhKr6lozx3Q48pmFrqqNRmZFUEGmi7pSwm6fCQ2H1IjvCkHqdpVdjQ==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.0: + resolution: {integrity: sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + workerd@1.20260611.1: + resolution: {integrity: sha512-CS/640T7pIJ2HYX6x2DwKFGbcSckAWN3tgcdq+ptB6SaqjWUhlzIgA/YhPuwIU+/NnMnGpqOFX/hC18Oyge63w==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.100.0: + resolution: {integrity: sha512-dSQO7DO+mD6XDzkVWIWBoGLO3yw+lacWSc/KhFvd7pgfpth+kX98qb5SGRHZN8ACCDhhfwzDLXwB6qHsIHhfBg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^4.20260611.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + +snapshots: + + '@acemir/cssom@0.9.31': {} + + '@asamuzakjp/css-color@4.1.1': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 11.2.5 + + '@asamuzakjp/dom-selector@6.7.7': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.1.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.5 + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@biomejs/biome@2.5.4': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.4 + '@biomejs/cli-darwin-x64': 2.5.4 + '@biomejs/cli-linux-arm64': 2.5.4 + '@biomejs/cli-linux-arm64-musl': 2.5.4 + '@biomejs/cli-linux-x64': 2.5.4 + '@biomejs/cli-linux-x64-musl': 2.5.4 + '@biomejs/cli-win32-arm64': 2.5.4 + '@biomejs/cli-win32-x64': 2.5.4 + + '@biomejs/cli-darwin-arm64@2.5.4': + optional: true + + '@biomejs/cli-darwin-x64@2.5.4': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.4': + optional: true + + '@biomejs/cli-linux-arm64@2.5.4': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.4': + optional: true + + '@biomejs/cli-linux-x64@2.5.4': + optional: true + + '@biomejs/cli-win32-arm64@2.5.4': + optional: true + + '@biomejs/cli-win32-x64@2.5.4': + optional: true + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260611.1 + + '@cloudflare/workerd-darwin-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260611.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260611.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260611.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.26': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@epic-web/invariant@1.0.0': {} + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@exodus/bytes@1.11.0': {} + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@noble/hashes@1.4.0': + optional: true + + '@oxc-project/types@0.133.0': {} + + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-schema@2.8.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/utils@2.0.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + optional: true + + '@playwright/test@1.57.0': + dependencies: + playwright: 1.57.0 + + '@popperjs/core@2.11.8': {} + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@rspack/binding-darwin-arm64@2.0.5': + optional: true + + '@rspack/binding-darwin-x64@2.0.5': + optional: true + + '@rspack/binding-linux-arm64-gnu@2.0.5': + optional: true + + '@rspack/binding-linux-arm64-musl@2.0.5': + optional: true + + '@rspack/binding-linux-x64-gnu@2.0.5': + optional: true + + '@rspack/binding-linux-x64-musl@2.0.5': + optional: true + + '@rspack/binding-wasm32-wasi@2.0.5': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rspack/binding-win32-arm64-msvc@2.0.5': + optional: true + + '@rspack/binding-win32-ia32-msvc@2.0.5': + optional: true + + '@rspack/binding-win32-x64-msvc@2.0.5': + optional: true + + '@rspack/binding@2.0.5': + optionalDependencies: + '@rspack/binding-darwin-arm64': 2.0.5 + '@rspack/binding-darwin-x64': 2.0.5 + '@rspack/binding-linux-arm64-gnu': 2.0.5 + '@rspack/binding-linux-arm64-musl': 2.0.5 + '@rspack/binding-linux-x64-gnu': 2.0.5 + '@rspack/binding-linux-x64-musl': 2.0.5 + '@rspack/binding-wasm32-wasi': 2.0.5 + '@rspack/binding-win32-arm64-msvc': 2.0.5 + '@rspack/binding-win32-ia32-msvc': 2.0.5 + '@rspack/binding-win32-x64-msvc': 2.0.5 + + '@rspack/cli@2.0.5(@rspack/core@2.0.5)(@rspack/dev-server@2.2.1(@rspack/core@2.0.5)(selfsigned@5.5.0))': + dependencies: + '@rspack/core': 2.0.5 + optionalDependencies: + '@rspack/dev-server': 2.2.1(@rspack/core@2.0.5)(selfsigned@5.5.0) + + '@rspack/core@2.0.5': + dependencies: + '@rspack/binding': 2.0.5 + + '@rspack/dev-middleware@2.0.4(@rspack/core@2.0.5)': + optionalDependencies: + '@rspack/core': 2.0.5 + + '@rspack/dev-server@2.2.1(@rspack/core@2.0.5)(selfsigned@5.5.0)': + dependencies: + '@rspack/core': 2.0.5 + '@rspack/dev-middleware': 2.0.4(@rspack/core@2.0.5) + optionalDependencies: + selfsigned: 5.5.0 + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.17': {} + + '@standard-schema/spec@1.1.0': {} + + '@supabase/auth-js@2.81.1': + dependencies: + tslib: 2.8.1 + + '@supabase/cli-darwin-arm64@2.106.0': + optional: true + + '@supabase/cli-darwin-x64@2.106.0': + optional: true + + '@supabase/cli-linux-arm64-musl@2.106.0': + optional: true + + '@supabase/cli-linux-arm64@2.106.0': + optional: true + + '@supabase/cli-linux-x64-musl@2.106.0': + optional: true + + '@supabase/cli-linux-x64@2.106.0': + optional: true + + '@supabase/cli-windows-arm64@2.106.0': + optional: true + + '@supabase/cli-windows-x64@2.106.0': + optional: true + + '@supabase/functions-js@2.81.1': + dependencies: + tslib: 2.8.1 + + '@supabase/postgrest-js@2.81.1': + dependencies: + tslib: 2.8.1 + + '@supabase/realtime-js@2.81.1': + dependencies: + '@types/phoenix': 1.6.6 + '@types/ws': 8.18.1 + tslib: 2.8.1 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@supabase/storage-js@2.81.1': + dependencies: + tslib: 2.8.1 + + '@supabase/supabase-js@2.81.1': + dependencies: + '@supabase/auth-js': 2.81.1 + '@supabase/functions-js': 2.81.1 + '@supabase/postgrest-js': 2.81.1 + '@supabase/realtime-js': 2.81.1 + '@supabase/storage-js': 2.81.1 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + '@tabler/core@1.4.0': + dependencies: + '@popperjs/core': 2.11.8 + bootstrap: 5.3.7(@popperjs/core@2.11.8) + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/draggabilly@3.0.0': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@20.19.24': + dependencies: + undici-types: 6.21.0 + + '@types/phoenix@1.6.6': {} + + '@types/semver@7.7.0': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 20.19.24 + + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-arm@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-linux-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview-win32-x64@7.0.0-dev.20260707.2': + optional: true + + '@typescript/native-preview@7.0.0-dev.20260707.2': + optionalDependencies: + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260707.2 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260707.2 + + '@vitest/coverage-v8@4.1.9(vitest@4.1.9)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/utils': 4.1.9 + ast-v8-to-istanbul: 1.0.4 + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-reports: 3.2.0 + magicast: 0.5.3 + obug: 2.1.1 + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.9(@types/node@20.19.24)(@vitest/coverage-v8@4.1.9)(jsdom@28.0.0)(vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4)) + + '@vitest/expect@4.1.9': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.9(vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4))': + dependencies: + '@vitest/spy': 4.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4) + + '@vitest/pretty-format@4.1.9': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.9': + dependencies: + '@vitest/utils': 4.1.9 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + '@vitest/utils': 4.1.9 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.9': {} + + '@vitest/utils@4.1.9': + dependencies: + '@vitest/pretty-format': 4.1.9 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + acorn-import-phases@1.0.4(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-keywords@5.1.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + fast-deep-equal: 3.1.3 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + array-flatten@1.1.1: {} + + asn1js@3.0.10: + dependencies: + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + optional: true + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.4: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + baseline-browser-mapping@2.10.37: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + blake3-wasm@2.1.5: {} + + body-parser@1.20.8: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.16.0 + raw-body: 2.5.3 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + bootstrap@5.3.7(@popperjs/core@2.11.8): + dependencies: + '@popperjs/core': 2.11.8 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.37 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.373 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + buffer-from@1.1.2: {} + + bytes@3.1.2: {} + + bytestreamjs@2.0.1: + optional: true + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + caniuse-lite@1.0.30001799: {} + + chai@6.2.2: {} + + chrome-trace-event@1.0.4: {} + + color-name@1.1.4: {} + + commander@2.20.3: {} + + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + + content-type@1.0.5: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.0.7: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + css-loader@7.1.4(@rspack/core@2.0.5)(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)): + dependencies: + icss-utils: 5.1.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-modules-extract-imports: 3.1.0(postcss@8.5.15) + postcss-modules-local-by-default: 4.2.0(postcss@8.5.15) + postcss-modules-scope: 3.2.1(postcss@8.5.15) + postcss-modules-values: 4.0.0(postcss@8.5.15) + postcss-value-parser: 4.2.0 + semver: 7.7.2 + optionalDependencies: + '@rspack/core': 2.0.5 + webpack: 5.107.2(esbuild@0.28.1)(postcss@8.5.15) + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + cssfontparser@1.2.1: {} + + cssstyle@5.3.7: + dependencies: + '@asamuzakjp/css-color': 4.1.1 + '@csstools/css-syntax-patches-for-csstree': 1.0.26 + css-tree: 3.1.0 + lru-cache: 11.2.5 + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + debug@2.6.9: + dependencies: + ms: 2.0.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + depd@2.0.0: {} + + destroy@1.2.0: {} + + detect-libc@2.1.2: {} + + dotenv@17.2.3: {} + + draggabilly@3.0.0: + dependencies: + get-size: 3.0.0 + unidragger: 3.0.1 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.373: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.24.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@6.0.1: {} + + error-stack-parser-es@1.0.5: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.1.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@4.3.0: {} + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + etag@1.8.1: {} + + ev-emitter@2.1.2: {} + + events@3.3.0: {} + + expect-type@1.3.0: {} + + express@4.22.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.8 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.2 + fresh: 0.5.2 + http-errors: 2.0.1 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.13 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.2 + serve-static: 1.16.3 + setprototypeof: 1.2.0 + statuses: 2.0.2 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + finalhandler@1.3.2: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@0.5.2: {} + + fsevents@2.3.2: + optional: true + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-size@3.0.0: {} + + gl-matrix@3.4.4: {} + + glob-to-regexp@0.4.1: {} + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.11.0 + transitivePeerDependencies: + - '@noble/hashes' + + html-escaper@2.0.2: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + husky@9.1.7: {} + + iconv-lite@0.4.24: + dependencies: + safer-buffer: 2.1.2 + + icss-utils@5.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-potential-custom-element-name@1.0.1: {} + + isexe@2.0.0: {} + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + + jest-worker@27.5.1: + dependencies: + '@types/node': 20.19.24 + merge-stream: 2.0.0 + supports-color: 8.1.1 + + js-tokens@10.0.0: {} + + jsdom@28.0.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.7.7 + '@exodus/bytes': 1.11.0 + cssstyle: 5.3.7 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.0 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + + json-schema-traverse@1.0.0: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + loader-runner@4.3.2: {} + + lru-cache@11.2.5: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.7.2 + + math-intrinsics@1.1.0: {} + + mdn-data@2.12.2: {} + + media-typer@0.3.0: {} + + merge-descriptors@1.0.3: {} + + merge-stream@2.0.0: {} + + methods@1.1.2: {} + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime@1.6.0: {} + + miniflare@4.20260611.0: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.34.5 + undici: 7.24.8 + workerd: 1.20260611.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + moo-color@1.0.3: + dependencies: + color-name: 1.1.4 + + ms@2.0.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + negotiator@0.6.3: {} + + neo-async@2.6.2: {} + + node-releases@2.0.47: {} + + object-inspect@1.13.4: {} + + obug@2.1.1: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + ootk@5.1.1: {} + + parse5@8.0.0: + dependencies: + entities: 6.0.1 + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@0.1.13: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pkijs@3.4.0: + dependencies: + '@noble/hashes': 1.4.0 + asn1js: 3.0.10 + bytestreamjs: 2.0.1 + pvtsutils: 1.3.6 + pvutils: 1.1.5 + tslib: 2.8.1 + optional: true + + playwright-core@1.57.0: {} + + playwright@1.57.0: + dependencies: + playwright-core: 1.57.0 + optionalDependencies: + fsevents: 2.3.2 + + postcss-modules-extract-imports@3.1.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + + postcss-modules-local-by-default@4.2.0(postcss@8.5.15): + dependencies: + icss-utils: 5.1.0(postcss@8.5.15) + postcss: 8.5.15 + postcss-selector-parser: 7.1.0 + postcss-value-parser: 4.2.0 + + postcss-modules-scope@3.2.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.0 + + postcss-modules-values@4.0.0(postcss@8.5.15): + dependencies: + icss-utils: 5.1.0(postcss@8.5.15) + postcss: 8.5.15 + + postcss-selector-parser@7.1.0: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + punycode@2.3.1: {} + + pvtsutils@1.3.6: + dependencies: + tslib: 2.8.1 + optional: true + + pvutils@1.1.5: + optional: true + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + qs@6.16.0: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + range-parser@1.2.1: {} + + raw-body@2.5.3: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + + reflect-metadata@0.2.2: + optional: true + + require-from-string@2.0.2: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + safe-buffer@5.2.1: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + ajv-keywords: 5.1.0(ajv@8.20.0) + + selfsigned@5.5.0: + dependencies: + '@peculiar/x509': 1.14.3 + pkijs: 3.4.0 + optional: true + + semver@7.7.2: {} + + semver@7.7.3: {} + + send@0.19.2: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.1 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@1.16.3: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.2 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} + + stackback@0.0.2: {} + + statuses@2.0.2: {} + + std-env@4.1.0: {} + + style-loader@4.0.0(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)): + dependencies: + webpack: 5.107.2(esbuild@0.28.1)(postcss@8.5.15) + + supabase@2.106.0: + optionalDependencies: + '@supabase/cli-darwin-arm64': 2.106.0 + '@supabase/cli-darwin-x64': 2.106.0 + '@supabase/cli-linux-arm64': 2.106.0 + '@supabase/cli-linux-arm64-musl': 2.106.0 + '@supabase/cli-linux-x64': 2.106.0 + '@supabase/cli-linux-x64-musl': 2.106.0 + '@supabase/cli-windows-arm64': 2.106.0 + '@supabase/cli-windows-x64': 2.106.0 + + supports-color@10.2.2: {} + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + symbol-tree@3.2.4: {} + + tapable@2.3.3: {} + + terser-webpack-plugin@5.6.1(esbuild@0.28.1)(postcss@8.5.15)(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.44.0 + webpack: 5.107.2(esbuild@0.28.1)(postcss@8.5.15) + optionalDependencies: + esbuild: 0.28.1 + postcss: 8.5.15 + + terser@5.44.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.17.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tldts-core@7.0.21: {} + + tldts@7.0.21: + dependencies: + tldts-core: 7.0.21 + + toidentifier@1.0.1: {} + + tough-cookie@6.0.0: + dependencies: + tldts: 7.0.21 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + tslib@1.14.1: + optional: true + + tslib@2.8.1: {} + + tsx@4.22.4: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + optional: true + + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + undici@7.24.8: {} + + undici@7.28.0: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unidragger@3.0.1: + dependencies: + ev-emitter: 2.1.2 + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + utils-merge@1.0.1: {} + + uuid@13.0.2: {} + + vary@1.1.2: {} + + vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.24 + esbuild: 0.28.1 + fsevents: 2.3.3 + terser: 5.44.0 + tsx: 4.22.4 + + vitest-canvas-mock@1.1.3(vitest@4.1.9): + dependencies: + cssfontparser: 1.2.1 + moo-color: 1.0.3 + vitest: 4.1.9(@types/node@20.19.24)(@vitest/coverage-v8@4.1.9)(jsdom@28.0.0)(vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4)) + + vitest@4.1.9(@types/node@20.19.24)(@vitest/coverage-v8@4.1.9)(jsdom@28.0.0)(vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4)): + dependencies: + '@vitest/expect': 4.1.9 + '@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4)) + '@vitest/pretty-format': 4.1.9 + '@vitest/runner': 4.1.9 + '@vitest/snapshot': 4.1.9 + '@vitest/spy': 4.1.9 + '@vitest/utils': 4.1.9 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.0.16(@types/node@20.19.24)(esbuild@0.28.1)(terser@5.44.0)(tsx@4.22.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 20.19.24 + '@vitest/coverage-v8': 4.1.9(vitest@4.1.9) + jsdom: 28.0.0 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + + webidl-conversions@8.0.1: {} + + webpack-sources@3.5.0: {} + + webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15): + dependencies: + '@types/estree': 1.0.8 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.17.0 + acorn-import-phases: 1.0.4(acorn@8.17.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + loader-runner: 4.3.2 + mime-db: 1.54.0 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(esbuild@0.28.1)(postcss@8.5.15)(webpack@5.107.2(esbuild@0.28.1)(postcss@8.5.15)) + watchpack: 2.5.2 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.0: + dependencies: + '@exodus/bytes': 1.11.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + workerd@1.20260611.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260611.1 + '@cloudflare/workerd-darwin-arm64': 1.20260611.1 + '@cloudflare/workerd-linux-64': 1.20260611.1 + '@cloudflare/workerd-linux-arm64': 1.20260611.1 + '@cloudflare/workerd-windows-64': 1.20260611.1 + + wrangler@4.100.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260611.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 4.20260611.0 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260611.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.17 + cookie: 1.1.1 + youch-core: 0.3.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..a35073f4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +# pnpm settings. These live here (not in .npmrc) so that plain `npm`/`npx` +# invocations don't warn "Unknown config" about pnpm-only keys. pnpm 10 reads +# settings from this file; npm ignores it cleanly. +# +# Flat, npm-like node_modules layout. Chosen for the migration off npm so that +# webpack (and later rspack) loaders keep resolving without path changes. +nodeLinker: hoisted + +# Match npm's lenient peer-dependency behavior so installs don't hard-fail on +# peer mismatches that npm previously tolerated. +strictPeerDependencies: false +autoInstallPeers: true diff --git a/public/fonts/LICENSE-roboto-condensed.txt b/public/fonts/LICENSE-roboto-condensed.txt new file mode 100644 index 00000000..9c0b6ddb --- /dev/null +++ b/public/fonts/LICENSE-roboto-condensed.txt @@ -0,0 +1,93 @@ +Copyright 2011 Google Inc. All Rights Reserved. RobotoCondensed-Italic[wght].ttf: Copyright 2011 Google Inc. All Rights Reserved. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/LICENSE-roboto.txt b/public/fonts/LICENSE-roboto.txt new file mode 100644 index 00000000..e96f4bc8 --- /dev/null +++ b/public/fonts/LICENSE-roboto.txt @@ -0,0 +1,93 @@ +Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) Roboto-Italic[wdth,wght].ttf: Copyright 2011 The Roboto Project Authors (https://github.com/googlefonts/roboto-classic) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/fonts/roboto-condensed-latin-400-normal.woff2 b/public/fonts/roboto-condensed-latin-400-normal.woff2 new file mode 100644 index 00000000..3317628c Binary files /dev/null and b/public/fonts/roboto-condensed-latin-400-normal.woff2 differ diff --git a/public/fonts/roboto-condensed-latin-700-normal.woff2 b/public/fonts/roboto-condensed-latin-700-normal.woff2 new file mode 100644 index 00000000..fda2796e Binary files /dev/null and b/public/fonts/roboto-condensed-latin-700-normal.woff2 differ diff --git a/public/fonts/roboto-condensed-latin-ext-400-normal.woff2 b/public/fonts/roboto-condensed-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..9a4c1437 Binary files /dev/null and b/public/fonts/roboto-condensed-latin-ext-400-normal.woff2 differ diff --git a/public/fonts/roboto-condensed-latin-ext-700-normal.woff2 b/public/fonts/roboto-condensed-latin-ext-700-normal.woff2 new file mode 100644 index 00000000..08ea3d45 Binary files /dev/null and b/public/fonts/roboto-condensed-latin-ext-700-normal.woff2 differ diff --git a/public/fonts/roboto-latin-300-normal.woff2 b/public/fonts/roboto-latin-300-normal.woff2 new file mode 100644 index 00000000..fb6526fa Binary files /dev/null and b/public/fonts/roboto-latin-300-normal.woff2 differ diff --git a/public/fonts/roboto-latin-400-normal.woff2 b/public/fonts/roboto-latin-400-normal.woff2 new file mode 100644 index 00000000..77e42594 Binary files /dev/null and b/public/fonts/roboto-latin-400-normal.woff2 differ diff --git a/public/fonts/roboto-latin-500-normal.woff2 b/public/fonts/roboto-latin-500-normal.woff2 new file mode 100644 index 00000000..171a2f6a Binary files /dev/null and b/public/fonts/roboto-latin-500-normal.woff2 differ diff --git a/public/fonts/roboto-latin-700-normal.woff2 b/public/fonts/roboto-latin-700-normal.woff2 new file mode 100644 index 00000000..85b8ace2 Binary files /dev/null and b/public/fonts/roboto-latin-700-normal.woff2 differ diff --git a/public/fonts/roboto-latin-ext-300-normal.woff2 b/public/fonts/roboto-latin-ext-300-normal.woff2 new file mode 100644 index 00000000..4d917ed9 Binary files /dev/null and b/public/fonts/roboto-latin-ext-300-normal.woff2 differ diff --git a/public/fonts/roboto-latin-ext-400-normal.woff2 b/public/fonts/roboto-latin-ext-400-normal.woff2 new file mode 100644 index 00000000..100758e1 Binary files /dev/null and b/public/fonts/roboto-latin-ext-400-normal.woff2 differ diff --git a/public/fonts/roboto-latin-ext-500-normal.woff2 b/public/fonts/roboto-latin-ext-500-normal.woff2 new file mode 100644 index 00000000..a4e2d08d Binary files /dev/null and b/public/fonts/roboto-latin-ext-500-normal.woff2 differ diff --git a/public/fonts/roboto-latin-ext-700-normal.woff2 b/public/fonts/roboto-latin-ext-700-normal.woff2 new file mode 100644 index 00000000..2a80739f Binary files /dev/null and b/public/fonts/roboto-latin-ext-700-normal.woff2 differ diff --git a/public/images/earthmap-night4k.jpg b/public/images/earthmap-night4k.jpg new file mode 100644 index 00000000..e55dd06b Binary files /dev/null and b/public/images/earthmap-night4k.jpg differ diff --git a/public/images/earthmap4k.jpg b/public/images/earthmap4k.jpg new file mode 100644 index 00000000..83410c0e Binary files /dev/null and b/public/images/earthmap4k.jpg differ diff --git a/public/index.html b/public/index.html index 0d8a45c9..7f5e7855 100644 --- a/public/index.html +++ b/public/index.html @@ -5,9 +5,8 @@ SignalRange | Space Electronic Warfare Lab - - - +

diff --git a/retrospectives/quiz-self-check-framing-retro.md b/retrospectives/quiz-self-check-framing-retro.md deleted file mode 100644 index 2b8d7b8a..00000000 --- a/retrospectives/quiz-self-check-framing-retro.md +++ /dev/null @@ -1,39 +0,0 @@ -# Retrospective: Quiz Self-Check Framing for Solo Scenarios - -**Date:** 2026-01-25 -**Feature:** Added `Character.SYSTEM` to support quizzes without NPC attribution - -## Summary - -Implemented a way to present quiz questions in solo scenarios (like Scenario 8: Night Shift) where no NPC is present to "ask" the questions. Instead of showing "Charlie Brooks asks:", the quiz now shows "Knowledge Check" with a `?` icon and "Verify your understanding" subtext. - -## What Worked - -1. **Reusing the existing `character` parameter** - Rather than adding a new parameter like `framing` or `hideCharacter`, extending the Character enum with a `SYSTEM` value kept the API simple. Scenarios just set `character: Character.SYSTEM` like they would any other character. - -2. **Conditional header rendering** - Rebuilding the header HTML in `renderQuiz_()` based on character type was cleaner than trying to hide/show individual elements. The two rendering paths (NPC vs SYSTEM) are clearly separated. - -3. **Minimal CSS changes** - Only needed one new class (`.quiz-self-check-icon`) that reuses existing CSS variables for consistent styling. - -4. **Type safety** - TypeScript's exhaustive checking on Record types forced us to add entries for `SYSTEM` in all character lookup tables, preventing runtime errors. - -## What Didn't Work - -1. **Initial plan considered multiple approaches** - Spent exploration time evaluating `framing` parameter vs `Character.SYSTEM` vs `hideCharacter` boolean. In hindsight, the enum extension was clearly the simplest approach and could have been chosen faster. - -2. **Manual edits for 12 status-check conditions** - Adding `character: Character.SYSTEM` to each condition in scenario8.ts was repetitive. A find-and-replace or batch edit would have been faster than 12 individual Edit tool calls. - -## What to Change Next Time - -1. **For repetitive edits**, consider using a single Edit with `replace_all: true` if the pattern is consistent, or batch multiple related changes into fewer edits. - -2. **When extending enums for "special" values**, document clearly that this is a sentinel/marker value, not a real entity. The JSDoc comment helps but could be more prominent. - -3. **Consider creating a helper** - If more scenarios need self-check quizzes, could add a `createSelfCheckCondition()` helper that pre-fills `character: Character.SYSTEM` to reduce boilerplate. - -## Files Changed - -- `src/modal/character-enum.ts` - Added `Character.SYSTEM` enum value -- `src/modal/quiz-modal.ts` - Conditional rendering for SYSTEM mode -- `src/modal/quiz-modal.css` - Added `.quiz-self-check-icon` styles -- `src/campaigns/nats/scenario8.ts` - Added character param to 12 conditions diff --git a/rspack.config.mts b/rspack.config.mts new file mode 100644 index 00000000..1ad17b84 --- /dev/null +++ b/rspack.config.mts @@ -0,0 +1,240 @@ +import { execSync } from 'node:child_process'; +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import rspack from '@rspack/core'; +import dotenv from 'dotenv'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf8')) as { version: string }; + +// Private submodule (src/private, thkruz/signal-range-private). Absent in OSS +// clones. When its app entry exists the bundle may dynamic-import private +// modules through the @private alias; when it is absent the guarded imports +// are dead code and rspack never resolves the path (no stubs needed). +const PRIVATE_APP_DIR = path.resolve(__dirname, 'src/private/app'); +const IS_PRIVATE = fs.existsSync(path.join(PRIVATE_APP_DIR, 'index.ts')); +// Authoring tools need the dev-server file endpoints, so they only exist in +// development builds of the private edition. Never true in a deployed build. +const IS_AUTHORING = IS_PRIVATE && process.env.NODE_ENV === 'development'; + +// Get git commit SHA at build time +const getGitCommitSha = (): string => { + try { + return execSync('git rev-parse --short HEAD').toString().trim(); + } catch { + return 'unknown'; + } +}; + +// First loaded always wins, so load .env first +if (process.env.NODE_ENV === 'development') { + dotenv.config({ path: '.env' }); +} + +// For production builds, also load .env.production +dotenv.config({ path: '.env.production' }); + +const isProduction = process.env.NODE_ENV === 'production'; + +const config: rspack.Configuration = { + entry: { + main: './src/index.ts', + 'popup-callback': './src/user-account/popup-callback.ts', + }, + output: { + filename: '[name].[contenthash].js', + path: path.resolve(__dirname, 'dist'), + clean: true, + publicPath: '/', + }, + resolve: { + extensions: ['.ts', '.js'], + alias: { + '@app': path.resolve(__dirname, 'src'), + '@engine': path.resolve(__dirname, 'src/engine'), + '@private': PRIVATE_APP_DIR, + // External plugin clones (see scripts/plugin). The generated manifest + // only ever imports plugins present on disk, so this never dangles. + '@plugins-external': path.resolve(__dirname, 'src/plugins-external'), + }, + }, + // Production emits maps but keeps the sourceMappingURL out of the bundle, + // matching keeptrack; dev keeps the reference for debuggability. + devtool: isProduction ? 'hidden-source-map' : 'source-map', + module: { + rules: [ + { + // Rust/SWC transpile, no type-check: `pnpm run typecheck` (tsgo) owns + // types. Replaces ts-loader, which re-ran tsc on every build. + test: /\.ts$/u, + loader: 'builtin:swc-loader', + exclude: [/node_modules/u], + options: { + jsc: { + parser: { + syntax: 'typescript', + }, + target: 'es2022', + }, + }, + }, + { + test: /\.css$/u, + use: [ + 'style-loader', + { + loader: 'css-loader', + options: { + // Root-relative urls are server paths, not module requests. The + // self-hosted fonts in src/fonts.css point at /fonts/*.woff2, + // which CopyRspackPlugin puts in the output as-is; without this + // filter css-loader tries to resolve them against src/ and the + // build fails with "Can't resolve '/fonts/...'". + url: { filter: (url: string) => !url.startsWith('/') }, + }, + }, + ], + }, + { + test: /\.(png|jpg|jpeg|gif|svg|otf|ttf|woff|woff2)$/u, + type: 'asset/resource', + }, + { + test: /\.(mp3|wav)$/u, + type: 'asset/resource', + }, + ], + }, + plugins: [ + new rspack.HtmlRspackPlugin({ + template: './public/index.html', + chunks: ['main'], + filename: 'index.html', + }), + new rspack.HtmlRspackPlugin({ + template: './public/auth/callback.html', + chunks: ['popup-callback'], + filename: 'auth/callback.html', + }), + new rspack.DefinePlugin({ + 'process.env.PUBLIC_SUPABASE_URL': JSON.stringify(process.env.PUBLIC_SUPABASE_URL), + 'process.env.PUBLIC_SUPABASE_ANON_KEY': JSON.stringify(process.env.PUBLIC_SUPABASE_ANON_KEY), + 'process.env.PUBLIC_USER_API_URL': JSON.stringify(process.env.PUBLIC_USER_API_URL || 'https://user.keeptrack.space'), + 'process.env.PUBLIC_ASSETS_BASE_URL': JSON.stringify(process.env.PUBLIC_ASSETS_BASE_URL || ''), + 'process.env.PUBLIC_DEV_USER_IDS': JSON.stringify(process.env.PUBLIC_DEV_USER_IDS || ''), + 'process.env.PUBLIC_LOG_LEVEL': JSON.stringify(process.env.PUBLIC_LOG_LEVEL || 'LOG'), + __APP_VERSION__: JSON.stringify(pkg.version), + __GIT_COMMIT_SHA__: JSON.stringify(getGitCommitSha()), + __IS_PRIVATE__: JSON.stringify(IS_PRIVATE), + __AUTHORING__: JSON.stringify(IS_AUTHORING), + }), + // No case-sensitive-paths equivalent in rspack; the Linux CI build is what + // catches casing mistakes now. + new rspack.CopyRspackPlugin({ + patterns: [ + // noErrorOnMissing on the next two patterns preserves webpack's + // behaviour: copy-webpack-plugin tolerated them, CopyRspackPlugin + // errors. Both currently match nothing -- public/assets/logo.png does + // not exist, and public/assets/characters holds only .png files, which + // this pattern's own ignore list excludes. Neither has ever emitted + // anything into dist/. Kept (rather than deleted) so the intent + // survives if non-png character art is added. + { from: 'public/assets/logo.png', to: 'logo.png', noErrorOnMissing: true }, + { + from: 'public/assets/characters/', + to: 'assets/characters/', + noErrorOnMissing: true, + globOptions: { + ignore: ['**/*.png', '**/wip/**'], + }, + }, + { + from: 'public/images/', + to: 'images/', + globOptions: { + ignore: ['**/wip/**'], + }, + }, + // Self-hosted web fonts. Copied verbatim (not run through the + // asset/resource rule) so the URLs in src/fonts.css stay stable. + { + from: 'public/fonts/', + to: 'fonts/', + }, + ], + }), + ], + optimization: isProduction + ? { + minimizer: [ + new rspack.SwcJsMinimizerRspackPlugin({ + minimizerOptions: { + compress: { keep_classnames: true }, + mangle: { keep_classnames: true }, + }, + }), + new rspack.LightningCssMinimizerRspackPlugin({}), + ], + } + : undefined, + // Persistent filesystem cache makes local rebuilds near-instant. Skipped in + // CI, where a cold runner would write a cache it never reads. + cache: process.env.CI + ? false + : { + type: 'persistent', + // Namespace by mode so dev and prod never read each other's + // (differently minified) cached modules. + version: isProduction ? 'production' : 'development', + buildDependencies: [path.resolve(__dirname, 'rspack.config.mts'), path.resolve(__dirname, 'package.json')], + storage: { + type: 'filesystem', + directory: 'node_modules/.cache/rspack', + }, + }, + devServer: { + static: { + directory: path.join(__dirname, 'public'), + }, + compress: false, + port: 3000, + hot: true, + historyApiFallback: { + rewrites: [ + // Don't redirect auth callback - serve the actual HTML file + { from: /^\/auth\/callback/u, to: '/auth/callback.html' }, + // All other routes go to index.html + { from: /./u, to: '/index.html' }, + ], + }, + liveReload: false, + // Private dev endpoints (scenario authoring file access). The module is + // part of the private submodule; without it the dev server is unchanged. + // + // Under webpack-dev-server this registered straight onto `devServer.app`, + // which was an Express app. @rspack/dev-server does not expose one (its + // `app` has no .get/.post), so we build an Express app here and mount it as + // a middleware instead -- an Express app is itself a (req, res, next) + // handler. This keeps dev-middleware.cjs's Express contract intact without + // changing the private submodule. It is unshifted so the /__author routes + // resolve before historyApiFallback rewrites them to index.html. + setupMiddlewares: (middlewares) => { + const privateMiddleware = path.join(PRIVATE_APP_DIR, 'dev-middleware.cjs'); + + if (IS_AUTHORING && fs.existsSync(privateMiddleware)) { + const express = require('express'); + const authoringApp = express(); + + require(privateMiddleware).register(authoringApp, { repoRoot: __dirname }); + middlewares.unshift({ name: 'private-authoring', middleware: authoringApp }); + } + + return middlewares; + }, + }, +}; + +export default config; diff --git a/scripts/author-passes.mjs b/scripts/author-passes.mjs new file mode 100644 index 00000000..560f998b --- /dev/null +++ b/scripts/author-passes.mjs @@ -0,0 +1,672 @@ +/** + * Batch LEO pass-window authoring tool for campaign scenarios (nats-eu Campaign 2+). + * + * Extends scripts/author-tle.mjs from "one bird, one AOS target" to batch + * authoring: give it a scenario epoch, one or more ground stations, and a list + * of requested passes ("AOS at T+X min over station Y peaking at Z deg") and it + * grid-searches TLE parameters (inclination x mean motion x RAAN x mean + * anomaly) until each request is met, validating with the same ootk SGP4 + * propagation the app uses (see src/services/pass-planner-service.ts). The + * output is self-verifying: every emitted TLE is re-propagated and printed as + * a pass table (AOS / max-el / LOS plus follow-up passes in the next 2 h). + * + * Run from the repo root (node_modules resolution): + * node scripts/author-passes.mjs # built-in DEMO_CONFIG below + * node scripts/author-passes.mjs my-config.json # external JSON config + * + * Config shape (JSON file mirrors DEMO_CONFIG): + * { + * "epoch": "2027-03-15T14:00:00Z", // scenario start (UTC) = TLE epoch + * "cosparId": "27015A", // optional intl designator (default 001A) + * "stations": { + * "galway": { "lat": 53.27, "lon": -9.05, "alt": 0.02 }, + * "atlantic": { "lat": 53.00, "lon": -33.30, "alt": 0.0 } + * }, + * "passes": [ + * { + * "name": "MERIDIAN-SAR-3", // display name + * "noradId": 61703, + * "station": "galway", // key into stations (optional if only one) + * "aosOffsetMin": 2.0, // AOS at epoch + N min (hard tol +/-0.5 min) + * "maxElDeg": 88, // peak elevation (hard tol +/-5 deg) + * "durationMin": 12.5, // approximate (soft); seeds the mean-motion grid + * "direction": "northbound", // optional: northbound|southbound at max el + * "inclinations": [97.6], // optional override of the inclination grid + * "meanMotions": [14.9], // optional override of the mean-motion grid + * "tle1": "...", "tle2": "..." // optional: verify-only (no search), e.g. a + * } // checked-in TLE to print its pass table + * ] + * } + * + * Multi-station scheduling: when two (or more) requests share a noradId (the + * same bird must pass Galway at T+2 and a second station at T+98), they are + * solved JOINTLY - one TLE is searched that satisfies every request in the + * group, and the run reports per-request residuals if that is infeasible. + * Joint requests are only geometrically feasible when the stations sit near a + * common ground track at the requested time separation: successive ground + * tracks shift ~24.1 deg of longitude west per ~96 min orbit, so "pass B one + * orbit after pass A" needs station B roughly that far west of station A (or + * along the same track for separations << 1 orbit). + * + * The DEMO_CONFIG reproduces (approximately) the checked-in MERIDIAN-SAR-1 + * pass from src/campaigns/nats-eu/satellites.ts (AOS T+2.0, max el ~88, + * LOS T+14.5 over Galway, epoch 2027-03-15 14:00 UTC) and also carries the + * checked-in TLE as a verify-only entry, so the solver's pass table can be + * compared line-by-line against known-good numbers in a single run. + * + * Limitations: + * - AOS/LOS use a 0 deg elevation horizon (same default as PassPlannerService). + * - Eccentricity (0.001) and arg of perigee (90) are fixed, matching + * scripts/author-tle.mjs; only near-circular LEO birds are authored here + * (mean motion clamped to 11.25-16.4 rev/day). GEO work stays in + * scripts/author-tle-signal-hunter.mjs. + * - durationMin is a soft target: altitude (mean motion) and max-el dominate + * duration, so it converges to the physically consistent value. + */ +import { readFileSync } from 'node:fs'; +import { GroundObject, Satellite } from 'ootk'; + +// ── Tolerances and search constants ───────────────────────────────────────── + +const AOS_TOL_MIN = 0.5; // hard: achieved AOS within +/-0.5 min of request +const EL_TOL_DEG = 5; // hard: achieved max el within +/-5 deg of request +const DUR_SOFT_TOL_MIN = 2; // soft: duration error only nudges the score + +const COARSE_STEP_DEG = 2; // RAAN x MA coarse grid step (as author-tle.mjs) +const COARSE_AOS_GATE_MIN = 3; // relaxed gates while coarse-scanning... +const COARSE_EL_GATE_DEG = 20; // ...so near-misses stay reportable +const REFINE_SPAN_DEG = 2.5; // local refine box around the coarse winner +const REFINE_STEP_DEG = 0.25; // 0.25 deg MA ~= 4 s of AOS timing at mm 14.9 + +const DEFAULT_INCLINATIONS = [97.2, 97.6, 98.0, 98.4]; // sun-sync band (track reaches ~82 deg lat) +const DEFAULT_MEAN_MOTIONS = [14.2, 14.6, 15.0]; // only used when no durationMin given +const MM_SPREAD = 0.25; // mm grid = duration-derived estimate +/- spread +const MM_MIN = 11.25; +const MM_MAX = 16.4; +const FOLLOWUP_HORIZON_MIN = 120; // pass table extends 2 h past the request + +const MU_KM3_S2 = 398600.4418; +const RE_KM = 6378.137; +const EARTH_ROT_DEG_MIN = 0.250684; // sidereal rotation rate + +// ── Demo config ────────────────────────────────────────────────────────────── + +const DEMO_CONFIG = { + epoch: '2027-03-15T14:00:00Z', + cosparId: '27015A', + stations: { + galway: { lat: 53.27, lon: -9.05, alt: 0.02 }, + }, + passes: [ + { + // Fresh solve that should land (approximately) on the checked-in + // MERIDIAN-SAR-1 pass geometry: AOS T+2, max el ~88, LOS ~T+14.5. + name: 'MERIDIAN-DEMO', + noradId: 61799, + station: 'galway', + aosOffsetMin: 2.0, + maxElDeg: 88, + durationMin: 12.5, + }, + { + // Verify-only: the checked-in MERIDIAN-SAR-1 TLE from + // src/campaigns/nats-eu/satellites.ts. Its pass table prints next to + // the freshly solved bird above for a direct known-good comparison. + name: 'MERIDIAN-SAR-1 (checked-in, verify only)', + noradId: 61701, + station: 'galway', + aosOffsetMin: 2.0, + maxElDeg: 88, + durationMin: 12.5, + tle1: '1 61701U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9996', + tle2: '2 61701 97.6000 26.0000 0010000 90.0000 294.0000 14.90000000123451', + }, + ], +}; + +// ── Config loading ─────────────────────────────────────────────────────────── + +const configPath = process.argv[2]; +const config = configPath ? JSON.parse(readFileSync(configPath, 'utf8')) : DEMO_CONFIG; + +const START_MS = Date.parse(config.epoch); +if (!Number.isFinite(START_MS)) { + console.error(`Invalid config.epoch "${config.epoch}" - use an ISO UTC string like 2027-03-15T14:00:00Z`); + process.exit(1); +} +const EPOCH_FIELD = toTleEpochField(START_MS); +const COSPAR = (config.cosparId ?? `${String(new Date(START_MS).getUTCFullYear() % 100).padStart(2, '0')}001A`).padEnd(8); +const COARSE_STEP = config.coarseStepDeg ?? COARSE_STEP_DEG; + +const stations = {}; +for (const [key, s] of Object.entries(config.stations ?? {})) { + stations[key] = { go: new GroundObject({ lat: s.lat, lon: s.lon, alt: s.alt ?? 0 }), ...s }; +} +const stationKeys = Object.keys(stations); +if (stationKeys.length === 0) { + console.error('config.stations must define at least one station'); + process.exit(1); +} + +/** TLE epoch field YYDDD.DDDDDDDD for a Unix ms timestamp (UTC). */ +function toTleEpochField(ms) { + const d = new Date(ms); + const yy = String(d.getUTCFullYear() % 100).padStart(2, '0'); + const doyFloat = (ms - Date.UTC(d.getUTCFullYear(), 0, 1)) / 86_400_000 + 1; + const doy = Math.floor(doyFloat); + return `${yy}${String(doy).padStart(3, '0')}.${(doyFloat - doy).toFixed(8).slice(2)}`; +} + +// ── TLE construction (same conventions as scripts/author-tle.mjs) ─────────── + +function tleChecksum(line) { + let sum = 0; + for (const ch of line) { + if (ch >= '0' && ch <= '9') sum += Number(ch); + else if (ch === '-') sum += 1; + } + return sum % 10; +} + +function fmt(value, width, decimals) { + return value.toFixed(decimals).padStart(width); +} + +function buildTle(noradId, inclination, raan, meanAnomaly, meanMotion) { + const id = String(noradId).padStart(5, '0'); + const l1Body = `1 ${id}U ${COSPAR} ${EPOCH_FIELD} .00001000 00000-0 10000-3 0 999`; + const l2Body = `2 ${id} ${fmt(inclination, 8, 4)} ${fmt(raan, 8, 4)} 0010000 ${fmt(90, 8, 4)} ${fmt(meanAnomaly, 8, 4)} ${fmt(meanMotion, 11, 8)}12345`; + return { + tle1: l1Body + tleChecksum(l1Body), + tle2: l2Body + tleChecksum(l2Body), + }; +} + +function tryBuildSatellite(noradId, inclination, raan, meanAnomaly, meanMotion) { + const { tle1, tle2 } = buildTle(noradId, inclination, raan, meanAnomaly, meanMotion); + try { + return { sat: new Satellite({ tle1, tle2 }), tle1, tle2 }; + } catch { + return null; + } +} + +// ── Propagation helpers ────────────────────────────────────────────────────── + +function raeAt(sat, go, ms) { + try { + return sat.rae(go, new Date(ms)); + } catch { + return null; + } +} + +function elevationAt(sat, go, ms) { + const rae = raeAt(sat, go, ms); + return rae ? rae.el : -90; +} + +/** Bisect a horizon crossing between two sample times to ~0.5 s. */ +function refineCrossing(sat, go, loMs, hiMs, rising) { + let lo = loMs; + let hi = hiMs; + while (hi - lo > 500) { + const mid = (lo + hi) / 2; + const up = elevationAt(sat, go, mid) >= 0; + if (up === rising) hi = mid; + else lo = mid; + } + return rising ? hi : lo; +} + +/** Scan [aos, los] for max elevation, edge azimuths, and pass direction. */ +function summarizePass(sat, go, aosMs, losMs, scanMs) { + let maxEl = -90; + let maxElMs = aosMs; + let maxElRngKm = 0; + for (let t = aosMs; t <= losMs; t += scanMs) { + const rae = raeAt(sat, go, t); + if (rae && rae.el > maxEl) { + maxEl = rae.el; + maxElMs = t; + maxElRngKm = rae.rng; + } + } + const aosRae = raeAt(sat, go, aosMs + 500); + const losRae = raeAt(sat, go, losMs - 500); + let direction = null; + try { + const latBefore = sat.lla(new Date(maxElMs - 30_000)).lat; + const latAfter = sat.lla(new Date(maxElMs + 30_000)).lat; + direction = latAfter > latBefore ? 'northbound' : 'southbound'; + } catch { + // leave direction null if propagation fails at the sample points + } + return { + aosMs, + losMs, + maxEl, + maxElMs, + maxElRngKm, + aosAz: aosRae ? aosRae.az : null, + losAz: losRae ? losRae.az : null, + direction, + durationMin: (losMs - aosMs) / 60_000, + }; +} + +/** The single pass nearest the target AOS (20 s coarse walk, bisected edges). */ +function findPassNear(sat, go, targetAosMs, durEstMin, scanMs) { + const stepMs = 20_000; + let startMs = targetAosMs - 4 * 60_000; + // If already mid-pass at the window start, walk back below the horizon. + let guard = 0; + while (elevationAt(sat, go, startMs) >= 0 && guard < 90) { + startMs -= stepMs; + guard++; + } + const endMs = targetAosMs + (durEstMin + 8) * 60_000; + let prevEl = elevationAt(sat, go, startMs); + let aosMs = null; + for (let ms = startMs + stepMs; ms <= endMs; ms += stepMs) { + const el = elevationAt(sat, go, ms); + if (prevEl < 0 && el >= 0) { + aosMs = refineCrossing(sat, go, ms - stepMs, ms, true); + } + if (aosMs !== null && prevEl >= 0 && el < 0) { + const losMs = refineCrossing(sat, go, ms - stepMs, ms, false); + return summarizePass(sat, go, aosMs, losMs, scanMs); + } + prevEl = el; + } + return null; +} + +/** Every pass between startMs and endMs (for the verified pass table). */ +function listPasses(sat, go, startMs, endMs) { + const stepMs = 30_000; + const passes = []; + let cursor = startMs; + // Report a pass already in progress at startMs with its true (negative-T) AOS. + let guard = 0; + while (elevationAt(sat, go, cursor) >= 0 && guard < 60) { + cursor -= stepMs; + guard++; + } + let prevEl = elevationAt(sat, go, cursor); + let aosMs = null; + for (let ms = cursor + stepMs; ms <= endMs; ms += stepMs) { + const el = elevationAt(sat, go, ms); + if (prevEl < 0 && el >= 0) { + aosMs = refineCrossing(sat, go, ms - stepMs, ms, true); + } + if (aosMs !== null && prevEl >= 0 && el < 0) { + const losMs = refineCrossing(sat, go, ms - stepMs, ms, false); + passes.push(summarizePass(sat, go, aosMs, losMs, 2_000)); + aosMs = null; + } + prevEl = el; + } + return passes; +} + +// ── Duration -> mean-motion seeding (analytic circular-orbit model) ───────── + +function mmToSemiMajorKm(mm) { + const radPerS = (mm * 2 * Math.PI) / 86_400; + return Math.cbrt(MU_KM3_S2 / (radPerS * radPerS)); +} + +/** Predicted horizon-to-horizon pass duration (min) for a given peak elevation. */ +function predictDurationMin(mm, maxElDeg, inclDeg) { + const a = mmToSemiMajorKm(mm); + const k = RE_KM / a; + const lamMax = Math.acos(k); // Earth-central half-angle of the 0-deg-el circle + const elRad = (Math.min(maxElDeg, 89.9) * Math.PI) / 180; + // Cross-track central angle beta at which peak elevation equals the target: + // tan(el) = (cos b - k) / sin b, monotonically decreasing in b. + let lo = 1e-6; + let hi = lamMax; + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2; + const el = Math.atan2(Math.cos(mid) - k, Math.sin(mid)); + if (el > elRad) lo = mid; + else hi = mid; + } + const beta = (lo + hi) / 2; + const halfArcRad = Math.acos(Math.min(1, Math.cos(lamMax) / Math.cos(beta))); + const relRateDegMin = (mm * 360) / 1440 - EARTH_ROT_DEG_MIN * Math.cos((inclDeg * Math.PI) / 180); + return (2 * (halfArcRad * 180) / Math.PI) / relRateDegMin; +} + +/** Invert predictDurationMin for mm (duration decreases with mean motion). */ +function durationToMeanMotion(durMin, maxElDeg, inclDeg) { + let lo = MM_MIN; + let hi = MM_MAX; + if (predictDurationMin(lo, maxElDeg, inclDeg) < durMin) return lo; + if (predictDurationMin(hi, maxElDeg, inclDeg) > durMin) return hi; + for (let i = 0; i < 60; i++) { + const mid = (lo + hi) / 2; + if (predictDurationMin(mid, maxElDeg, inclDeg) > durMin) lo = mid; + else hi = mid; + } + return (lo + hi) / 2; +} + +// ── Request normalization and grouping ─────────────────────────────────────── + +function normalizeRequest(raw, index) { + const stationKey = raw.station ?? (stationKeys.length === 1 ? stationKeys[0] : null); + if (!stationKey || !stations[stationKey]) { + throw new Error(`passes[${index}] "${raw.name}": unknown station "${raw.station}" (have: ${stationKeys.join(', ')})`); + } + if (typeof raw.noradId !== 'number') throw new Error(`passes[${index}] "${raw.name}": noradId is required`); + if (typeof raw.aosOffsetMin !== 'number') throw new Error(`passes[${index}] "${raw.name}": aosOffsetMin is required`); + if (typeof raw.maxElDeg !== 'number') throw new Error(`passes[${index}] "${raw.name}": maxElDeg is required`); + const durEstMin = typeof raw.durationMin === 'number' ? raw.durationMin : 15; + return { + ...raw, + stationKey, + stationGO: stations[stationKey].go, + aosMs: START_MS + raw.aosOffsetMin * 60_000, + midMs: START_MS + (raw.aosOffsetMin + durEstMin / 2) * 60_000, + durEstMin, + direction: raw.direction ? (/^n/i.test(raw.direction) ? 'northbound' : 'southbound') : null, + }; +} + +function collectOverride(requests, key) { + const values = []; + for (const req of requests) { + if (Array.isArray(req[key])) { + for (const v of req[key]) { + if (!values.includes(v)) values.push(v); + } + } + } + if (values.length) return values; + if (Array.isArray(config[key])) return [...config[key]]; + return null; +} + +function deriveMeanMotions(requests, inclList) { + const inclRef = inclList[Math.floor(inclList.length / 2)]; + const grid = []; + for (const req of requests) { + if (typeof req.durationMin !== 'number') continue; + const est = durationToMeanMotion(req.durationMin, req.maxElDeg, inclRef); + for (const delta of [-MM_SPREAD, 0, MM_SPREAD]) { + const mm = Math.round(Math.min(MM_MAX, Math.max(MM_MIN, est + delta)) * 1e4) / 1e4; + if (!grid.some((v) => Math.abs(v - mm) < 0.02)) grid.push(mm); + } + } + return grid.length ? grid.sort((a, b) => a - b) : [...DEFAULT_MEAN_MOTIONS]; +} + +// ── Candidate screening and evaluation ─────────────────────────────────────── + +/** + * Cheap screen: 1 propagation per request near the expected max-el time kills + * ~99% of grid points before the (expensive) full pass evaluation; survivors + * get a 3-sample check that tolerates the peak drifting ~1 min off estimate. + */ +function passesScreen(sat, requests, diag) { + for (const req of requests) { + const elMid = elevationAt(sat, req.stationGO, req.midMs); + if (diag && elMid > (diag.bestMidEl.get(req) ?? -90)) diag.bestMidEl.set(req, elMid); + if (elMid < req.maxElDeg - 55) return false; + } + for (const req of requests) { + const el3 = Math.max( + elevationAt(sat, req.stationGO, req.midMs - 60_000), + elevationAt(sat, req.stationGO, req.midMs), + elevationAt(sat, req.stationGO, req.midMs + 60_000), + ); + if (el3 < req.maxElDeg - 30) return false; + } + return true; +} + +/** Score a candidate TLE against every request in the group (lower = better). */ +function evaluateCandidate(sat, requests, scanMs) { + let score = 0; + const results = []; + for (const req of requests) { + const pass = findPassNear(sat, req.stationGO, req.aosMs, req.durEstMin, scanMs); + if (!pass) return null; + const aosErrMin = Math.abs(pass.aosMs - req.aosMs) / 60_000; + if (aosErrMin > COARSE_AOS_GATE_MIN) return null; + const elErr = Math.abs(pass.maxEl - req.maxElDeg); + if (elErr > COARSE_EL_GATE_DEG) return null; + if (req.direction && pass.direction && pass.direction !== req.direction) return null; + const durErr = typeof req.durationMin === 'number' ? Math.abs(pass.durationMin - req.durationMin) : 0; + score += aosErrMin / AOS_TOL_MIN + elErr / EL_TOL_DEG + (0.3 * durErr) / DUR_SOFT_TOL_MIN; + results.push({ req, pass, aosErrMin, elErr, durErr }); + } + return { score, results }; +} + +// ── Solver: coarse grid + local refine per noradId group ──────────────────── + +function solveGroup(noradId, requests) { + const t0 = Date.now(); + const inclList = collectOverride(requests, 'inclinations') ?? [...DEFAULT_INCLINATIONS]; + const mmList = collectOverride(requests, 'meanMotions') ?? deriveMeanMotions(requests, inclList); + const stats = { combos: 0, screened: 0, evaluated: 0 }; + const diag = { bestMidEl: new Map() }; + + // Reachability sanity check: the ground track must get near each station. + for (const req of requests) { + const latAbs = Math.abs(stations[req.stationKey].lat); + const reachable = inclList.some((incl) => (incl <= 90 ? incl : 180 - incl) >= latAbs - 20); + if (!reachable) { + console.warn(` WARNING: no inclination candidate reaches lat ${latAbs} deg for "${req.name}" - override "inclinations".`); + } + } + + let best = null; + for (const incl of inclList) { + for (const mm of mmList) { + for (let raan = 0; raan < 360; raan += COARSE_STEP) { + for (let ma = 0; ma < 360; ma += COARSE_STEP) { + stats.combos++; + const built = tryBuildSatellite(noradId, incl, raan, ma, mm); + if (!built) continue; + if (!passesScreen(built.sat, requests, diag)) continue; + stats.screened++; + const evaluated = evaluateCandidate(built.sat, requests, 10_000); + if (!evaluated) continue; + stats.evaluated++; + if (!best || evaluated.score < best.score) { + best = { ...evaluated, incl, mm, raan, ma }; + } + } + } + } + } + + // Local refine around the coarse winner (RAAN/MA only; incl+mm stay fixed). + if (best) { + let refined = null; + for (let dr = -REFINE_SPAN_DEG; dr <= REFINE_SPAN_DEG; dr += REFINE_STEP_DEG) { + for (let dm = -REFINE_SPAN_DEG; dm <= REFINE_SPAN_DEG; dm += REFINE_STEP_DEG) { + const raan = (best.raan + dr + 360) % 360; + const ma = (best.ma + dm + 360) % 360; + const built = tryBuildSatellite(noradId, best.incl, raan, ma, best.mm); + if (!built) continue; + const evaluated = evaluateCandidate(built.sat, requests, 5_000); + if (!evaluated) continue; + if (!refined || evaluated.score < refined.score) { + refined = { ...evaluated, incl: best.incl, mm: best.mm, raan, ma }; + } + } + } + best = refined ?? best; + } + + // Final high-resolution verification of the winning TLE. + let final = null; + if (best) { + const built = tryBuildSatellite(noradId, best.incl, best.raan, best.ma, best.mm); + const evaluated = built ? evaluateCandidate(built.sat, requests, 2_000) : null; + if (built && evaluated) { + final = { ...evaluated, incl: best.incl, mm: best.mm, raan: best.raan, ma: best.ma, ...built }; + } + } + + const ok = final !== null && final.results.every((r) => r.aosErrMin <= AOS_TOL_MIN + 1e-9 && r.elErr <= EL_TOL_DEG + 1e-9); + return { noradId, requests, ok, final, stats, diag, elapsedS: (Date.now() - t0) / 1000 }; +} + +// ── Output ─────────────────────────────────────────────────────────────────── + +function tPlus(ms) { + return `T+${((ms - START_MS) / 60_000).toFixed(2)}`; +} + +function utc(ms) { + return `${new Date(ms).toISOString().slice(11, 19)}Z`; +} + +function az(deg) { + return deg === null ? 'az ?' : `az ${deg.toFixed(0).padStart(3)}`; +} + +function passRow(pass, index) { + return ( + ` #${index + 1} AOS ${tPlus(pass.aosMs).padStart(8)} (${utc(pass.aosMs)}, ${az(pass.aosAz)})` + + ` max el ${pass.maxEl.toFixed(1).padStart(4)} deg ${tPlus(pass.maxElMs)} (rng ${pass.maxElRngKm.toFixed(0)} km)` + + ` LOS ${tPlus(pass.losMs).padStart(8)} (${az(pass.losAz)}) dur ${pass.durationMin.toFixed(1)} min ${pass.direction ?? '?'}` + ); +} + +function printRequestLine(result) { + const { req, pass, aosErrMin, elErr } = result; + const okAos = aosErrMin <= AOS_TOL_MIN + 1e-9; + const okEl = elErr <= EL_TOL_DEG + 1e-9; + const verdict = okAos && okEl ? 'OK' : `MISS (${okAos ? '' : `AOS err ${aosErrMin.toFixed(2)} min > ${AOS_TOL_MIN}`}${!okAos && !okEl ? '; ' : ''}${okEl ? '' : `max-el err ${elErr.toFixed(1)} deg > ${EL_TOL_DEG}`})`; + console.log( + ` [${req.stationKey}] requested AOS T+${req.aosOffsetMin.toFixed(1)}, max el ${req.maxElDeg} deg` + + `${typeof req.durationMin === 'number' ? `, dur ~${req.durationMin} min` : ''}${req.direction ? `, ${req.direction}` : ''}` + + `\n achieved AOS ${tPlus(pass.aosMs)}, max el ${pass.maxEl.toFixed(1)} deg ${tPlus(pass.maxElMs)}, LOS ${tPlus(pass.losMs)},` + + ` dur ${pass.durationMin.toFixed(1)} min, ${pass.direction ?? '?'} -> ${verdict}`, + ); +} + +function printPassTables(sat, requests) { + const byStation = new Map(); + for (const req of requests) { + const horizonMs = req.aosMs + (req.durEstMin + FOLLOWUP_HORIZON_MIN) * 60_000; + byStation.set(req.stationKey, Math.max(byStation.get(req.stationKey) ?? 0, horizonMs)); + } + for (const [stationKey, endMs] of byStation) { + console.log(`\n Verified pass table - ${stationKey} (epoch ${new Date(START_MS).toISOString()}, horizon ${tPlus(endMs)}):`); + const passes = listPasses(sat, stations[stationKey].go, START_MS, endMs); + if (!passes.length) console.log(' (no passes in horizon)'); + passes.forEach((pass, i) => console.log(passRow(pass, i))); + } +} + +function printSnippet(name, tle1, tle2) { + console.log(`\n Ready to paste (src/campaigns/nats-eu/satellites.ts style) - ${name}:`); + console.log(` tle1: '${tle1}' as TleLine1,`); + console.log(` tle2: '${tle2}' as TleLine2,`); +} + +function printSolveReport(report) { + const names = [...new Set(report.requests.map((r) => r.name))].join(' / '); + console.log(`\n=== ${names} (${report.noradId}) - ${report.requests.length} requested pass(es) ===`); + console.log( + ` search: ${report.elapsedS.toFixed(1)} s | ${report.stats.combos.toLocaleString()} grid points | ` + + `${report.stats.screened.toLocaleString()} screened in | ${report.stats.evaluated.toLocaleString()} fully evaluated`, + ); + if (!report.final) { + console.log(' NO CANDIDATE passed even the relaxed coarse gates.'); + for (const req of report.requests) { + const bestEl = report.diag.bestMidEl.get(req); + console.log( + ` [${req.stationKey}] "${req.name}": best elevation seen near the requested peak time: ` + + `${bestEl === undefined ? 'n/a' : `${bestEl.toFixed(1)} deg`} (needs >= ${(req.maxElDeg - 30).toFixed(0)} deg to screen in)`, + ); + } + if (report.requests.length > 1) { + console.log( + ' Joint multi-station request is likely geometrically infeasible: check that the AOS\n' + + ' separation matches the ground-track drift (~24.1 deg lon west per ~96 min orbit),\n' + + ' or relax maxElDeg / adjust aosOffsetMin / widen "inclinations"/"meanMotions".', + ); + } + return; + } + console.log(` orbit: incl ${report.final.incl.toFixed(4)} raan ${report.final.raan.toFixed(4)} ma ${report.final.ma.toFixed(4)} mm ${report.final.mm.toFixed(8)} (ecc 0.001, argp 90)`); + for (const result of report.final.results) printRequestLine(result); + if (!report.ok) { + console.log(' RESULT: best candidate MISSES tolerance - see residuals above. Widen the grids or adjust the request.'); + } + printPassTables(report.final.sat, report.requests); + printSnippet(`${names} (${report.noradId})`, report.final.tle1, report.final.tle2); +} + +function printVerifyReport(req) { + console.log(`\n=== ${req.name} (${req.noradId}) - VERIFY ONLY (TLE supplied, no search) ===`); + let sat; + try { + sat = new Satellite({ tle1: req.tle1, tle2: req.tle2 }); + } catch (err) { + console.log(` Could not initialize satellite from supplied TLE: ${err.message}`); + return false; + } + const evaluated = evaluateCandidate(sat, [req], 2_000); + if (!evaluated) { + console.log(` No pass found near AOS T+${req.aosOffsetMin} over ${req.stationKey} (within relaxed gates).`); + printPassTables(sat, [req]); + return false; + } + for (const result of evaluated.results) printRequestLine(result); + printPassTables(sat, [req]); + return evaluated.results.every((r) => r.aosErrMin <= AOS_TOL_MIN + 1e-9 && r.elErr <= EL_TOL_DEG + 1e-9); +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +console.log(`Scenario epoch: ${new Date(START_MS).toISOString()} (TLE epoch field ${EPOCH_FIELD.trim()})`); +console.log(`Stations: ${stationKeys.map((k) => `${k} (lat ${stations[k].lat}, lon ${stations[k].lon}, alt ${stations[k].alt ?? 0} km)`).join(' | ')}`); +console.log(`Tolerances: AOS +/-${AOS_TOL_MIN} min (hard), max el +/-${EL_TOL_DEG} deg (hard), duration (soft)`); + +// Preserve config order; requests sharing a noradId are solved jointly. +const tasks = []; +const groupsByNorad = new Map(); +(config.passes ?? []).forEach((raw, index) => { + const req = normalizeRequest(raw, index); + if (raw.tle1 && raw.tle2) { + tasks.push({ kind: 'verify', req }); + return; + } + if (!groupsByNorad.has(req.noradId)) { + const group = { kind: 'solve', noradId: req.noradId, requests: [] }; + groupsByNorad.set(req.noradId, group); + tasks.push(group); + } + groupsByNorad.get(req.noradId).requests.push(req); +}); +if (!tasks.length) { + console.error('config.passes is empty - nothing to do'); + process.exit(1); +} + +let anyFailed = false; +for (const task of tasks) { + if (task.kind === 'verify') { + if (!printVerifyReport(task.req)) anyFailed = true; + } else { + const report = solveGroup(task.noradId, task.requests); + printSolveReport(report); + if (!report.ok) anyFailed = true; + } +} +if (anyFailed) { + process.exitCode = 1; + console.log('\nOne or more requests did not converge - see reports above.'); +} else { + console.log('\nAll requests converged within tolerance.'); +} diff --git a/scripts/author-tle-ccs.mjs b/scripts/author-tle-ccs.mjs new file mode 100644 index 00000000..3f905c4c --- /dev/null +++ b/scripts/author-tle-ccs.mjs @@ -0,0 +1,160 @@ +/** + * TLE authoring tool for Campaign 4 (Counter Communications) COBALT-4. + * + * COBALT-4 was a fixed-geometry `Satellite` with authored az 175 / el 30 from + * SANDSTORM. Those two angles cannot both be true for a geostationary bird: at + * 34 deg N, an azimuth of 175 puts the satellite 2.8 deg east of the site's + * meridian, which is an elevation of ~50 deg, not 30. Giving the target a real + * ephemeris (so it appears on the world map and ground track like Campaign 5's + * SENTRY birds) means picking one of the two and letting the other follow. + * Azimuth is kept; elevation moves to whatever the orbit actually gives. + * + * Grid-searches RAAN x mean anomaly for a near-zero-inclination GEO slot that + * parks the bird at the target longitude at the scenario epoch and holds it + * there for the length of the scenario - a stationary bird keeps the authored + * pointing objectives satisfiable at a fixed az/el, which a drifting or + * inclined one would not. + * + * Run from the repo root (node_modules resolution): + * node scripts/author-tle-ccs.mjs + */ +import { GroundObject, Satellite } from 'ootk'; + +// ── Scenario parameters ───────────────────────────────────────────────────── + +/** SANDSTORM transportable EA site (Campaign 4, southern California desert) */ +const STATION = { lat: 34.0, lon: -118.0, alt: 0.4 }; + +/** Scenario start: 2027-11-05 02:00:00 UTC -> day 309.0833 of 2027 */ +const START_MS = Date.UTC(2027, 10, 5, 2, 0, 0); +const EPOCH_FIELD = '27309.08333333'; + +/** Length of the scenario window the bird has to stay on station for */ +const WINDOW_HOURS = 3; + +/** Near-zero-drift geosynchronous mean motion (sidereal day) */ +const GEO_MEAN_MOTION = 1.00273791; + +/** + * Slot chosen to preserve the authored azimuth of 175 deg: 2.8 deg east of the + * site meridian. Elevation follows from the geometry (~50.4 deg). + */ +const BIRD = { + name: 'COBALT-4', + noradId: 90042, + // Not exactly zero - a real GEO bird has some inclination - but small enough + // that the daily N-S excursion stays far inside the objective's 3 deg + // pointing tolerance. + inclination: 0.05, + targetLonDeg: -115.2, +}; + +// ── TLE construction (same conventions as scripts/author-tle.mjs) ─────────── + +function tleChecksum(line) { + let sum = 0; + for (const ch of line) { + if (ch >= '0' && ch <= '9') sum += Number(ch); + else if (ch === '-') sum += 1; + } + + return sum % 10; +} + +function fmt(value, width, decimals) { + return value.toFixed(decimals).padStart(width); +} + +function buildTle(noradId, inclination, raan, meanAnomaly) { + const l1Body = `1 ${noradId}U 27300A ${EPOCH_FIELD} .00000010 00000-0 00000-0 0 999`; + const l2Body = `2 ${noradId} ${fmt(inclination, 8, 4)} ${fmt(raan, 8, 4)} 0001000 ${fmt(90, 8, 4)} ${fmt(meanAnomaly, 8, 4)} ${fmt(GEO_MEAN_MOTION, 11, 8)}12345`; + + return { + tle1: l1Body + tleChecksum(l1Body), + tle2: l2Body + tleChecksum(l2Body), + }; +} + +// ── Grid search ───────────────────────────────────────────────────────────── + +const stationGO = new GroundObject({ lat: STATION.lat, lon: STATION.lon, alt: STATION.alt }); + +function lonAt(sat, ms) { + try { + return sat.lla(new Date(ms)).lon; + } catch { + return null; + } +} + +let best = null; + +for (let raan = 0; raan < 360; raan += 1) { + for (let ma = 0; ma < 360; ma += 1) { + const { tle1, tle2 } = buildTle(BIRD.noradId, BIRD.inclination, raan, ma); + let sat; + + try { + sat = new Satellite({ tle1, tle2 }); + } catch { + continue; + } + + const lonStart = lonAt(sat, START_MS); + + if (lonStart === null) { + continue; + } + + let lonErr = Math.abs(lonStart - BIRD.targetLonDeg); + + if (lonErr > 180) { + lonErr = 360 - lonErr; + } + if (lonErr > 1.0) { + continue; + } + + // Must hold the slot for the whole scenario, or the authored pointing + // drifts out of tolerance partway through. + const lonLater = lonAt(sat, START_MS + WINDOW_HOURS * 3600 * 1000); + + if (lonLater === null || Math.abs(lonLater - lonStart) > 0.2) { + continue; + } + + if (!best || lonErr < best.score) { + best = { score: lonErr, raan, ma, tle1, tle2, sat }; + } + } +} + +if (!best) { + console.log('NO SOLUTION FOUND - widen the search or relax constraints'); + process.exit(1); +} + +const lla = best.sat.lla(new Date(START_MS)); +const rae = best.sat.rae(stationGO, new Date(START_MS)); + +console.log(`=== ${BIRD.name} (${BIRD.noradId}) target ${BIRD.targetLonDeg} deg lon ===`); +console.log(`raan ${best.raan} ma ${best.ma}`); +console.log(`subpoint T+0: lat ${lla.lat.toFixed(2)} lon ${lla.lon.toFixed(2)} alt ${lla.alt.toFixed(0)} km`); +console.log(`from SANDSTORM: az ${rae.az.toFixed(2)} el ${rae.el.toFixed(2)} rng ${rae.rng.toFixed(0)} km`); +console.log(best.tle1); +console.log(best.tle2); + +// ── Pointing stability across the scenario window ─────────────────────────── + +console.log('\n=== pointing over the scenario window ==='); +console.log('t(min) az el subLat subLon'); +for (let min = 0; min <= WINDOW_HOURS * 60; min += 15) { + const at = new Date(START_MS + min * 60 * 1000); + const r = best.sat.rae(stationGO, at); + const p = best.sat.lla(at); + + console.log( + `${String(min).padStart(5)} ${r.az.toFixed(2).padStart(6)} ${r.el.toFixed(2).padStart(6)}` + + ` ${p.lat.toFixed(2).padStart(6)} ${p.lon.toFixed(2).padStart(7)}`, + ); +} diff --git a/scripts/author-tle-signal-hunter.mjs b/scripts/author-tle-signal-hunter.mjs new file mode 100644 index 00000000..10f1885c --- /dev/null +++ b/scripts/author-tle-signal-hunter.mjs @@ -0,0 +1,190 @@ +/** + * TLE authoring tool for Campaign 5 (Signal Hunter) GEO satellites. + * + * Grid-searches RAAN x mean-anomaly so each SENTRY bird sits at its target + * GEO longitude slot at the scenario start epoch, validating with the same + * ootk SGP4/SDP4 propagation the app uses (see scripts/author-tle.mjs and + * src/private/retrospectives/phase-1-campaign2-orbital-foundations-retro.md, private submodule). + * + * Also prototypes the two-satellite TDOA/FDOA forward model against a + * candidate emitter site so the geolocation geometry is validated BEFORE + * the GeolocationService is written: prints TDOA/FDOA magnitudes and their + * variation across the scenario window (FDOA variation is what makes + * successive captures rotate the line of position). + * + * Run from the repo root (node_modules resolution): + * node scripts/author-tle-signal-hunter.mjs + */ +import { GroundObject, Satellite } from 'ootk'; + +// ── Scenario parameters ───────────────────────────────────────────────────── + +// Peterson Annex ground station, Colorado plains (Campaign 5 signal-hunter) +const STATION = { lat: 38.82, lon: -104.70, alt: 1.9 }; + +// Candidate hostile emitter site, West Texas (sandbox ground truth) +const EMITTER = { lat: 31.30, lon: -103.50, alt: 0.8 }; + +// Scenario start: 2027-09-01 06:00:00 UTC → epoch day 244.25 of 2027 +const START_MS = Date.UTC(2027, 8, 1, 6, 0, 0); +const EPOCH_FIELD = '27244.25000000'; + +/** Near-zero-drift geosynchronous mean motion (sidereal day) */ +const GEO_MEAN_MOTION = 1.00273791; + +const BIRDS = [ + { + name: 'SENTRY-7', // victim satellite (jammed transponder) + noradId: 71001, + // 3-4.5 deg inclination gives the birds enough N-S velocity that the FDOA + // gradient rotates off the TDOA gradient - making the emitter LATITUDE + // observable from time-clustered captures (0.7 deg left it near-ambiguous). + inclination: 3.0, + targetLonDeg: -101.0, + }, + { + name: 'SENTRY-9', // adjacent satellite (sidelobe collector) + noradId: 71002, + inclination: 4.5, + targetLonDeg: -98.0, + }, +]; + +// ── TLE construction (same conventions as scripts/author-tle.mjs) ─────────── + +function tleChecksum(line) { + let sum = 0; + for (const ch of line) { + if (ch >= '0' && ch <= '9') sum += Number(ch); + else if (ch === '-') sum += 1; + } + return sum % 10; +} + +function fmt(value, width, decimals) { + return value.toFixed(decimals).padStart(width); +} + +function buildTle(noradId, inclination, raan, meanAnomaly) { + const l1Body = `1 ${noradId}U 27200A ${EPOCH_FIELD} .00000010 00000-0 00000-0 0 999`; + const l2Body = `2 ${noradId} ${fmt(inclination, 8, 4)} ${fmt(raan, 8, 4)} 0001000 ${fmt(90, 8, 4)} ${fmt(meanAnomaly, 8, 4)} ${fmt(GEO_MEAN_MOTION, 11, 8)}12345`; + return { + tle1: l1Body + tleChecksum(l1Body), + tle2: l2Body + tleChecksum(l2Body), + }; +} + +// ── Geometry helpers ──────────────────────────────────────────────────────── + +const stationGO = new GroundObject({ lat: STATION.lat, lon: STATION.lon, alt: STATION.alt }); +const emitterGO = new GroundObject({ lat: EMITTER.lat, lon: EMITTER.lon, alt: EMITTER.alt }); + +function lonAt(sat, ms) { + try { + return sat.lla(new Date(ms)).lon; + } catch { + return null; + } +} + +function rng(groundObject, sat, ms) { + return sat.rae(groundObject, new Date(ms)).rng; +} + +/** Path length emitter → sat → station, km */ +function pathKm(sat, ms) { + return rng(emitterGO, sat, ms) + rng(stationGO, sat, ms); +} + +const C_KM_S = 299792.458; + +/** TDOA between the two sat paths, seconds */ +function tdoaS(satA, satB, ms) { + return (pathKm(satA, ms) - pathKm(satB, ms)) / C_KM_S; +} + +/** FDOA between the two sat paths at carrier freq, Hz (central difference) */ +function fdoaHz(satA, satB, ms, freqHz) { + const dtS = 30; + const rateKmS = (tdoaS(satA, satB, ms + dtS * 1000) - tdoaS(satA, satB, ms - dtS * 1000)) / (2 * dtS); + return -freqHz * rateKmS; // d(tdoa)/dt in s/s; f * rate = Hz +} + +// ── Grid search: park each bird at its longitude slot ─────────────────────── + +const solved = []; + +for (const bird of BIRDS) { + let best = null; + + for (let raan = 0; raan < 360; raan += 2) { + for (let ma = 0; ma < 360; ma += 2) { + const { tle1, tle2 } = buildTle(bird.noradId, bird.inclination, raan, ma); + let sat; + try { + sat = new Satellite({ tle1, tle2 }); + } catch { + continue; + } + + const lonStart = lonAt(sat, START_MS); + if (lonStart === null) continue; + + let lonErr = Math.abs(lonStart - bird.targetLonDeg); + if (lonErr > 180) lonErr = 360 - lonErr; + if (lonErr > 1.5) continue; + + // Stay on station across a 3-hour scenario window + const lonLater = lonAt(sat, START_MS + 3 * 3600 * 1000); + if (lonLater === null || Math.abs(lonLater - lonStart) > 0.5) continue; + + const rae = sat.rae(stationGO, new Date(START_MS)); + if (rae.el < 35) continue; + + if (!best || lonErr < best.score) { + best = { score: lonErr, raan, ma, tle1, tle2, sat, rae, lonStart }; + } + } + } + + console.log(`\n=== ${bird.name} (${bird.noradId}) target ${bird.targetLonDeg} deg lon ===`); + if (!best) { + console.log('NO SOLUTION FOUND — widen the search or relax constraints'); + } else { + const lla = best.sat.lla(new Date(START_MS)); + console.log(`subpoint T+0: lat ${lla.lat.toFixed(2)} lon ${lla.lon.toFixed(2)} alt ${lla.alt.toFixed(0)} km`); + console.log(`from station: az ${best.rae.az.toFixed(1)} el ${best.rae.el.toFixed(1)} rng ${best.rae.rng.toFixed(0)} km`); + const llaLater = best.sat.lla(new Date(START_MS + 3 * 3600 * 1000)); + console.log(`subpoint T+3h: lat ${llaLater.lat.toFixed(2)} lon ${llaLater.lon.toFixed(2)} (drift check)`); + console.log(best.tle1); + console.log(best.tle2); + solved.push({ ...bird, sat: best.sat }); + } +} + +// ── TDOA/FDOA observability check ─────────────────────────────────────────── + +if (solved.length === 2) { + const [s7, s9] = solved.map((b) => b.sat); + const F_UPLINK_HZ = 6013e6; // jammer uplink carrier + + console.log('\n=== TDOA/FDOA forward model at the emitter site ==='); + console.log('t(min) TDOA(us) FDOA(Hz)'); + for (let min = 0; min <= 120; min += 15) { + const ms = START_MS + min * 60 * 1000; + const t = tdoaS(s7, s9, ms) * 1e6; + const f = fdoaHz(s7, s9, ms, F_UPLINK_HZ); + console.log(`${String(min).padStart(5)} ${t.toFixed(2).padStart(9)} ${f.toFixed(2).padStart(9)}`); + } + + // Position sensitivity: how much does TDOA/FDOA move 50 km from the truth? + const north = new GroundObject({ lat: EMITTER.lat + 0.45, lon: EMITTER.lon, alt: EMITTER.alt }); + const east = new GroundObject({ lat: EMITTER.lat, lon: EMITTER.lon + 0.53, alt: EMITTER.alt }); + const tdoaAt = (go, ms) => + (rng(go, s7, ms) + rng(stationGO, s7, ms) - rng(go, s9, ms) - rng(stationGO, s9, ms)) / C_KM_S; + const ms0 = START_MS + 30 * 60 * 1000; + const dT_north = (tdoaAt(north, ms0) - tdoaAt(emitterGO, ms0)) * 1e6; + const dT_east = (tdoaAt(east, ms0) - tdoaAt(emitterGO, ms0)) * 1e6; + console.log(`\nTDOA sensitivity ~50 km: north ${dT_north.toFixed(2)} us, east ${dT_east.toFixed(2)} us`); + console.log('(compare against a 1-2 us measurement sigma for expected fix error)'); +} diff --git a/scripts/author-tle.mjs b/scripts/author-tle.mjs new file mode 100644 index 00000000..3d79bff1 --- /dev/null +++ b/scripts/author-tle.mjs @@ -0,0 +1,172 @@ +/** + * TLE authoring tool for scenario satellites. + * + * Grid-searches RAAN x mean-anomaly so a pass lands at the desired time after + * the scenario start epoch, validating with the same ootk SGP4 propagation the + * app uses (see src/private/retrospectives/phase-1-campaign2-orbital-foundations-retro.md, private submodule). + * + * Run from the repo root (node_modules resolution): + * node scripts/author-tle.mjs + * + * Edit OBSERVER / EPOCH / BIRDS below for the scenario being authored, then + * paste the printed TLEs into the campaign's satellites.ts and the printed + * pass numbers into its unit test. + */ +import { GroundObject, Satellite } from 'ootk'; + +// ── Scenario parameters ───────────────────────────────────────────────────── + +// Riley's backyard, Burlington VT (Campaign 3 ham-sdr sandbox) +const OBSERVER = new GroundObject({ lat: 44.48, lon: -73.21, alt: 0.05 }); + +// Scenario start: 2027-06-19 16:00:00 UTC → epoch day 170.66666667 of 2027 +const START_MS = Date.UTC(2027, 5, 19, 16, 0, 0); +const EPOCH_FIELD = '27170.66666667'; + +const BIRDS = [ + { + name: 'WXSAT-19', + noradId: 63001, + inclination: 98.7, // sun-synchronous weather sat (~850 km) + meanMotion: 14.19, + targetAosMin: 3.0, // first AOS ~T+3 min + minMaxEl: 55, // want a good overhead pass for the QFH + }, + { + name: 'CUBEHOP-1', + noradId: 63002, + inclination: 97.5, // cubesat rideshare LEO (~600 km) + meanMotion: 14.9, + targetAosMin: 18.0, // second act: yagi pass ~T+18 min + minMaxEl: 45, + }, + { + name: 'NAVSTAR-77', + noradId: 63003, + inclination: 55.0, // GPS Block III, MEO half-sync orbit + meanMotion: 2.00565, + targetAosMin: null, // no AOS target: want it already high at T+0 + minElAtStart: 45, + }, +]; + +// ── TLE construction ──────────────────────────────────────────────────────── + +function tleChecksum(line) { + let sum = 0; + for (const ch of line) { + if (ch >= '0' && ch <= '9') sum += Number(ch); + else if (ch === '-') sum += 1; + } + return sum % 10; +} + +function fmt(value, width, decimals) { + return value.toFixed(decimals).padStart(width); +} + +function buildTle(noradId, inclination, raan, meanAnomaly, meanMotion) { + const l1Body = `1 ${noradId}U 27042A ${EPOCH_FIELD} .00001000 00000-0 10000-3 0 999`; + const l2Body = `2 ${noradId} ${fmt(inclination, 8, 4)} ${fmt(raan, 8, 4)} 0010000 ${fmt(90, 8, 4)} ${fmt(meanAnomaly, 8, 4)} ${fmt(meanMotion, 11, 8)}12345`; + return { + tle1: l1Body + tleChecksum(l1Body), + tle2: l2Body + tleChecksum(l2Body), + }; +} + +// ── Pass evaluation ───────────────────────────────────────────────────────── + +function elevationAt(sat, ms) { + try { + return sat.rae(OBSERVER, new Date(ms)).el; + } catch { + return -90; + } +} + +/** First pass within horizonMin minutes: AOS/LOS/maxEl (30 s coarse, 1 s refine) */ +function firstPass(sat, horizonMin) { + const stepMs = 30_000; + let prevEl = elevationAt(sat, START_MS); + let aosMs = null; + + for (let ms = START_MS + stepMs; ms <= START_MS + horizonMin * 60_000; ms += stepMs) { + const el = elevationAt(sat, ms); + if (prevEl < 0 && el >= 0) { + // refine AOS to 1 s + let lo = ms - stepMs, hi = ms; + while (hi - lo > 1000) { + const mid = (lo + hi) / 2; + if (elevationAt(sat, mid) >= 0) hi = mid; else lo = mid; + } + aosMs = hi; + } + if (aosMs !== null && prevEl >= 0 && el < 0) { + // refine LOS, then find max el over the pass + let lo = ms - stepMs, hi = ms; + while (hi - lo > 1000) { + const mid = (lo + hi) / 2; + if (elevationAt(sat, mid) >= 0) lo = mid; else hi = mid; + } + const losMs = lo; + let maxEl = -90, maxElMs = aosMs; + for (let t = aosMs; t <= losMs; t += 5000) { + const e = elevationAt(sat, t); + if (e > maxEl) { maxEl = e; maxElMs = t; } + } + return { aosMs, losMs, maxEl, maxElMs }; + } + prevEl = el; + } + return null; +} + +// ── Grid search ───────────────────────────────────────────────────────────── + +for (const bird of BIRDS) { + let best = null; + + for (let raan = 0; raan < 360; raan += 2) { + for (let ma = 0; ma < 360; ma += 2) { + const { tle1, tle2 } = buildTle(bird.noradId, bird.inclination, raan, ma, bird.meanMotion); + let sat; + try { + sat = new Satellite({ tle1, tle2 }); + } catch { + continue; + } + + if (bird.targetAosMin === null) { + // MEO: want high elevation at scenario start, still visible an hour in + const elStart = elevationAt(sat, START_MS); + const elLater = elevationAt(sat, START_MS + 60 * 60_000); + if (elStart < bird.minElAtStart || elLater < 10) continue; + const score = -elStart; // maximize start elevation + if (!best || score < best.score) { + best = { score, raan, ma, tle1, tle2, note: `el ${elStart.toFixed(1)} deg at T+0, ${elLater.toFixed(1)} deg at T+60m` }; + } + } else { + const pass = firstPass(sat, 40); + if (!pass || pass.maxEl < bird.minMaxEl) continue; + const aosMin = (pass.aosMs - START_MS) / 60_000; + const score = Math.abs(aosMin - bird.targetAosMin); + if (score > 1.5) continue; + if (!best || score < best.score) { + best = { + score, raan, ma, tle1, tle2, + note: `AOS T+${aosMin.toFixed(1)}m, max el ${pass.maxEl.toFixed(1)} deg at T+${((pass.maxElMs - START_MS) / 60_000).toFixed(1)}m, LOS T+${((pass.losMs - START_MS) / 60_000).toFixed(1)}m`, + }; + } + } + } + } + + console.log(`\n=== ${bird.name} (${bird.noradId}) ===`); + if (!best) { + console.log('NO SOLUTION FOUND — widen the search or relax constraints'); + } else { + console.log(best.note); + console.log(best.tle1); + console.log(best.tle2); + } +} diff --git a/scripts/fix-mock-constructors.js b/scripts/fix-mock-constructors.js deleted file mode 100644 index 7ff6bbae..00000000 --- a/scripts/fix-mock-constructors.js +++ /dev/null @@ -1,94 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const testDir = path.join(__dirname, '..', 'test'); - -function getAllTestFiles(dir) { - const files = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...getAllTestFiles(fullPath)); - } else if (entry.name.endsWith('.test.ts') || entry.name.endsWith('.test.tsx')) { - files.push(fullPath); - } - } - - return files; -} - -function fixFile(filePath) { - let content = fs.readFileSync(filePath, 'utf-8'); - const originalContent = content; - - // Pattern 1: vi.fn().mockImplementation(() => functionCall()) - // Change to: vi.fn(function() { return functionCall(); }) - content = content.replace( - /vi\.fn\(\)\.mockImplementation\(\(\) => ([a-zA-Z_][a-zA-Z0-9_]*\(\))\)/g, - (match, funcCall) => { - return `vi.fn(function() { return ${funcCall}; })`; - } - ); - - // Pattern 2: vi.fn().mockImplementation(() => variableName) - // Change to: vi.fn(function() { return variableName; }) - content = content.replace( - /vi\.fn\(\)\.mockImplementation\(\(\) => ([a-zA-Z_][a-zA-Z0-9_]*)\)/g, - (match, varName) => { - return `vi.fn(function() { return ${varName}; })`; - } - ); - - // Pattern 3: vi.fn().mockImplementation((params) => ({...})) - with parameters - // Change to: vi.fn(function(params) { return {...}; }) - content = content.replace( - /vi\.fn\(\)\.mockImplementation\(\(([^)]*)\) => \((\{[\s\S]*?\})\)\)/g, - (match, params, objectLiteral) => { - return `vi.fn(function(${params}) { return ${objectLiteral}; })`; - } - ); - - // Pattern 4: vi.fn().mockImplementation((id: type) => expression) - // Change to: vi.fn(function(id: type) { return expression; }) - content = content.replace( - /vi\.fn\(\)\.mockImplementation\(\(([^)]+)\) => ([^{][^)]+)\)/g, - (match, params, expr) => { - // Only fix if expr doesn't start with { (that's a different pattern) - if (expr.trim().startsWith('{')) return match; - return `vi.fn(function(${params}) { return ${expr}; })`; - } - ); - - // Pattern 5: vi.fn().mockImplementation(() => ({...})) - simple inline object - // Change to: vi.fn(function() { return {...}; }) - content = content.replace( - /vi\.fn\(\)\.mockImplementation\(\(\) => \((\{[\s\S]*?\})\)\)/g, - (match, objectLiteral) => { - return `vi.fn(function() { return ${objectLiteral}; })`; - } - ); - - if (content !== originalContent) { - fs.writeFileSync(filePath, content); - return true; - } - - return false; -} - -const testFiles = getAllTestFiles(testDir); -let fixedCount = 0; - -console.log(`Found ${testFiles.length} test files`); - -for (const file of testFiles) { - const relativePath = path.relative(path.join(__dirname, '..'), file); - if (fixFile(file)) { - console.log(`Fixed: ${relativePath}`); - fixedCount++; - } -} - -console.log(`\nFix complete: ${fixedCount} files updated`); diff --git a/scripts/fix-mock-implementation.js b/scripts/fix-mock-implementation.js deleted file mode 100644 index b1e6f5b9..00000000 --- a/scripts/fix-mock-implementation.js +++ /dev/null @@ -1,109 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const testDir = path.join(__dirname, '..', 'test'); - -function getAllTestFiles(dir) { - const files = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...getAllTestFiles(fullPath)); - } else if (entry.name.endsWith('.test.ts') || entry.name.endsWith('.test.tsx')) { - files.push(fullPath); - } - } - - return files; -} - -function fixFile(filePath) { - let content = fs.readFileSync(filePath, 'utf-8'); - const originalContent = content; - - // Pattern: .mockImplementation(() => expression) where expression is not a block - // Change to: .mockImplementation(function() { return expression; }) - // This handles: mockImplementation(() => mockBox), mockImplementation(() => mockInstance) - content = content.replace( - /\.mockImplementation\(\(\) => ([^{][^)]*)\)/g, - (match, expr) => { - // Skip if it already uses function or if the expression looks complex - if (expr.includes('function') || expr.startsWith('{')) { - return match; - } - return `.mockImplementation(function() { return ${expr}; })`; - } - ); - - // Pattern: .mockImplementation(() => ({ ... })) - multi-line object literal - // Need to handle carefully to not break nested structures - // Match .mockImplementation(() => ({ with proper closing - const pattern = /\.mockImplementation\(\(\) => \(\{/g; - let newContent = content; - let match; - - while ((match = pattern.exec(content)) !== null) { - const startIdx = match.index; - const searchStart = startIdx + match[0].length; - - // Find the matching closing })) - let braceCount = 1; - let i = searchStart; - let inString = false; - let stringChar = ''; - - while (i < content.length && braceCount > 0) { - const char = content[i]; - - if (!inString) { - if (char === '"' || char === "'" || char === '`') { - inString = true; - stringChar = char; - } else if (char === '{') { - braceCount++; - } else if (char === '}') { - braceCount--; - } - } else { - if (char === stringChar && content[i - 1] !== '\\') { - inString = false; - } - } - i++; - } - - // Check if we found })) - if (braceCount === 0 && content.slice(i, i + 2) === '))') { - const objectContent = content.slice(searchStart - 1, i); // includes the { ... } - const fullMatch = content.slice(startIdx, i + 2); - const replacement = `.mockImplementation(function() { return ${objectContent}; })`; - newContent = newContent.replace(fullMatch, replacement); - } - } - - content = newContent; - - if (content !== originalContent) { - fs.writeFileSync(filePath, content); - return true; - } - - return false; -} - -const testFiles = getAllTestFiles(testDir); -let fixedCount = 0; - -console.log(`Found ${testFiles.length} test files`); - -for (const file of testFiles) { - const relativePath = path.relative(path.join(__dirname, '..'), file); - if (fixFile(file)) { - console.log(`Fixed: ${relativePath}`); - fixedCount++; - } -} - -console.log(`\nFix complete: ${fixedCount} files updated`); diff --git a/scripts/fix-require-in-tests.js b/scripts/fix-require-in-tests.js deleted file mode 100644 index c6c36dd6..00000000 --- a/scripts/fix-require-in-tests.js +++ /dev/null @@ -1,123 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const testDir = path.join(__dirname, '..', 'test'); - -function getAllTestFiles(dir) { - const files = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...getAllTestFiles(fullPath)); - } else if (entry.name.endsWith('.test.ts') || entry.name.endsWith('.test.tsx')) { - files.push(fullPath); - } - } - - return files; -} - -function fixFile(filePath) { - let content = fs.readFileSync(filePath, 'utf-8'); - const originalContent = content; - - // Find all require patterns in test bodies - const requirePattern = /const \{ ([^}]+) \} = require\(['"]([^'"]+)['"]\);/g; - const matches = [...content.matchAll(requirePattern)]; - - if (matches.length === 0) { - return false; - } - - const importsToAdd = new Map(); // path -> Set of names - - for (const match of matches) { - const [fullMatch, names, importPath] = match; - const nameList = names.split(',').map(n => n.trim()); - - if (!importsToAdd.has(importPath)) { - importsToAdd.set(importPath, new Set()); - } - for (const name of nameList) { - importsToAdd.get(importPath).add(name); - } - - // Remove the require line from the test body - content = content.replace(fullMatch + '\n', ''); - content = content.replace(fullMatch, ''); // In case no newline - } - - // Check what's already imported and add missing imports - for (const [importPath, names] of importsToAdd) { - for (const name of names) { - // Check if already imported - const importRegex = new RegExp(`import\\s+\\{[^}]*\\b${name}\\b[^}]*\\}\\s+from\\s+['"]${importPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]`); - if (!importRegex.test(content)) { - // Add the import after the last vi.mock or after imports section - const lastMockIndex = content.lastIndexOf('vi.mock('); - if (lastMockIndex !== -1) { - // Find the end of the vi.mock block - let braceCount = 0; - let inString = false; - let stringChar = ''; - let endIndex = lastMockIndex; - - for (let i = lastMockIndex; i < content.length; i++) { - const char = content[i]; - - if (!inString) { - if (char === '"' || char === "'" || char === '`') { - inString = true; - stringChar = char; - } else if (char === '(') { - braceCount++; - } else if (char === ')') { - braceCount--; - if (braceCount === 0) { - endIndex = i; - break; - } - } - } else { - if (char === stringChar && content[i - 1] !== '\\') { - inString = false; - } - } - } - - // Find the end of the line containing the closing - while (endIndex < content.length && content[endIndex] !== '\n') { - endIndex++; - } - - const importStatement = `\nimport { ${name} } from '${importPath}';`; - content = content.slice(0, endIndex + 1) + importStatement + content.slice(endIndex + 1); - } - } - } - } - - if (content !== originalContent) { - fs.writeFileSync(filePath, content); - return true; - } - - return false; -} - -const testFiles = getAllTestFiles(testDir); -let fixedCount = 0; - -console.log(`Found ${testFiles.length} test files`); - -for (const file of testFiles) { - const relativePath = path.relative(path.join(__dirname, '..'), file); - if (fixFile(file)) { - console.log(`Fixed: ${relativePath}`); - fixedCount++; - } -} - -console.log(`\nFix complete: ${fixedCount} files updated`); diff --git a/scripts/gen-nice-catalog.mjs b/scripts/gen-nice-catalog.mjs new file mode 100644 index 00000000..f54764d8 --- /dev/null +++ b/scripts/gen-nice-catalog.mjs @@ -0,0 +1,53 @@ +/** + * Generate scripts/nice-catalog.json — the checked-in list of valid NIST NICE + * codes this product may claim — from the nist-nice-reviewer skill reference + * files (`.claude/skills/nist-nice-reviewer/references/*.txt`). + * + * The reference .txt files are the authoritative source but live under the + * gitignored `.claude/` tree, so CI can't see them. This script snapshots the + * valid-code set into a repo-tracked JSON that scripts/nice-coverage.mjs and + * test/campaigns/nice-coverage.test.ts read. + * + * Run from the repo root after editing the reference files: + * node scripts/gen-nice-catalog.mjs + */ +import { readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptsDir, '..'); +const refDir = join(repoRoot, '.claude', 'skills', 'nist-nice-reviewer', 'references'); + +const codeLine = /^([KST]\d{4})\t/; +const codes = new Set(); +const byRole = {}; + +for (const file of readdirSync(refDir).filter((f) => f.endsWith('.txt'))) { + const role = file.replace(/\.txt$/, ''); + byRole[role] = []; + for (const line of readFileSync(join(refDir, file), 'utf8').split(/\r?\n/)) { + const match = codeLine.exec(line); + if (match) { + codes.add(match[1]); + byRole[role].push(match[1]); + } + } +} + +const sorted = [...codes].sort((a, b) => a.localeCompare(b)); +const payload = { + _comment: + 'Valid NIST NICE (2024 components) codes this product may claim. Generated ' + + 'from the gitignored nist-nice-reviewer skill reference files via ' + + 'scripts/gen-nice-catalog.mjs. Consumed by scripts/nice-coverage.mjs and ' + + 'test/campaigns/nice-coverage.test.ts. Do not edit by hand.', + count: sorted.length, + roles: Object.fromEntries( + Object.entries(byRole).map(([role, list]) => [role, list.length]), + ), + codes: sorted, +}; + +writeFileSync(join(scriptsDir, 'nice-catalog.json'), `${JSON.stringify(payload, null, 2)}\n`); +console.log(`Wrote scripts/nice-catalog.json: ${sorted.length} codes across ${Object.keys(byRole).length} role files.`); diff --git a/scripts/migrate-jest-to-vitest.js b/scripts/migrate-jest-to-vitest.js deleted file mode 100644 index 4a3d08f7..00000000 --- a/scripts/migrate-jest-to-vitest.js +++ /dev/null @@ -1,128 +0,0 @@ -const fs = require('fs'); -const path = require('path'); - -const testDir = path.join(__dirname, '..', 'test'); - -function getAllTestFiles(dir) { - const files = []; - const entries = fs.readdirSync(dir, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - files.push(...getAllTestFiles(fullPath)); - } else if (entry.name.endsWith('.test.ts') || entry.name.endsWith('.test.tsx')) { - files.push(fullPath); - } - } - - return files; -} - -function migrateFile(filePath) { - let content = fs.readFileSync(filePath, 'utf-8'); - const originalContent = content; - - // Check if file uses any jest APIs - const usesJest = /jest\./.test(content); - - if (!usesJest) { - return false; - } - - // Replace jest.* with vi.* - content = content.replace(/jest\.fn\(/g, 'vi.fn('); - content = content.replace(/jest\.spyOn\(/g, 'vi.spyOn('); - content = content.replace(/jest\.mock\(/g, 'vi.mock('); - content = content.replace(/jest\.unmock\(/g, 'vi.unmock('); - content = content.replace(/jest\.doMock\(/g, 'vi.doMock('); - content = content.replace(/jest\.resetModules\(/g, 'vi.resetModules('); - content = content.replace(/jest\.clearAllMocks\(/g, 'vi.clearAllMocks('); - content = content.replace(/jest\.restoreAllMocks\(/g, 'vi.restoreAllMocks('); - content = content.replace(/jest\.resetAllMocks\(/g, 'vi.resetAllMocks('); - content = content.replace(/jest\.useFakeTimers\(/g, 'vi.useFakeTimers('); - content = content.replace(/jest\.useRealTimers\(/g, 'vi.useRealTimers('); - content = content.replace(/jest\.advanceTimersByTime\(/g, 'vi.advanceTimersByTime('); - content = content.replace(/jest\.runAllTimers\(/g, 'vi.runAllTimers('); - content = content.replace(/jest\.clearAllTimers\(/g, 'vi.clearAllTimers('); - content = content.replace(/jest\.mocked\(/g, 'vi.mocked('); - content = content.replace(/jest\.requireActual\(/g, 'vi.importActual('); - content = content.replace(/jest\.isolateModules\(/g, 'vi.isolateModules('); - content = content.replace(/jest\.setSystemTime\(/g, 'vi.setSystemTime('); - content = content.replace(/jest\.getRealSystemTime\(/g, 'vi.getRealSystemTime('); - content = content.replace(/jest\.runOnlyPendingTimers\(/g, 'vi.runOnlyPendingTimers('); - content = content.replace(/jest\.advanceTimersToNextTimer\(/g, 'vi.advanceTimersToNextTimer('); - content = content.replace(/jest\.getTimerCount\(/g, 'vi.getTimerCount('); - content = content.replace(/jest\.setTimeout\(/g, 'vi.setConfig({ testTimeout:'); - content = content.replace(/jest\.retryTimes\(/g, '// vi.retryTimes('); - - // Replace jest.Mock type assertions with Mock from vitest - content = content.replace(/as jest\.Mock\b/g, 'as Mock'); - content = content.replace(/jest\.Mock\b/g, 'Mock'); - content = content.replace(/jest\.MockedFunction/g, 'MockedFunction'); - content = content.replace(/jest\.MockedClass/g, 'MockedClass'); - content = content.replace(/jest\.Mocked 0 && !content.includes("from 'vitest'") && !content.includes('from "vitest"')) { - const importStatement = `import { ${imports.join(', ')} } from 'vitest';\n`; - // Find the first import statement - const importMatch = content.match(/^(import\s+.*?['"];?\s*\n)/m); - if (importMatch) { - const firstImport = importMatch[0]; - const importIndex = content.indexOf(firstImport); - content = content.slice(0, importIndex) + importStatement + content.slice(importIndex); - } else { - // No imports found, add at the beginning - content = importStatement + content; - } - } else if (imports.length > 0 && content.includes("from 'vitest'")) { - // Update existing vitest import to include Mock if needed - if (needsMock && !content.includes('Mock')) { - content = content.replace(/import \{ ([^}]+) \} from 'vitest'/, (match, existingImports) => { - const allImports = [...new Set([...existingImports.split(/,\s*/), ...imports])]; - return `import { ${allImports.join(', ')} } from 'vitest'`; - }); - } - } - - if (content !== originalContent) { - fs.writeFileSync(filePath, content); - return true; - } - - return false; -} - -const testFiles = getAllTestFiles(testDir); -let migratedCount = 0; - -console.log(`Found ${testFiles.length} test files`); - -for (const file of testFiles) { - const relativePath = path.relative(path.join(__dirname, '..'), file); - if (migrateFile(file)) { - console.log(`Migrated: ${relativePath}`); - migratedCount++; - } -} - -console.log(`\nMigration complete: ${migratedCount} files updated`); diff --git a/scripts/nice-catalog.json b/scripts/nice-catalog.json new file mode 100644 index 00000000..d0f0bc9b --- /dev/null +++ b/scripts/nice-catalog.json @@ -0,0 +1,557 @@ +{ + "_comment": "Valid NIST NICE (2024 components) codes this product may claim. Generated from the gitignored nist-nice-reviewer skill reference files via scripts/gen-nice-catalog.mjs. Consumed by scripts/nice-coverage.mjs and test/campaigns/nice-coverage.test.ts. Do not edit by hand.", + "count": 542, + "roles": { + "Data Analysis": 128, + "Defensive Cybersecurity": 205, + "Incident Response": 106, + "Network Operations": 122, + "System Administration": 137, + "System Testing and Evaluation": 103, + "Technical Support": 95 + }, + "codes": [ + "K0064", + "K0068", + "K0159", + "K0645", + "K0647", + "K0674", + "K0675", + "K0676", + "K0677", + "K0678", + "K0679", + "K0680", + "K0681", + "K0682", + "K0683", + "K0684", + "K0685", + "K0686", + "K0689", + "K0691", + "K0692", + "K0693", + "K0694", + "K0695", + "K0698", + "K0699", + "K0700", + "K0701", + "K0702", + "K0703", + "K0704", + "K0705", + "K0706", + "K0707", + "K0708", + "K0709", + "K0710", + "K0711", + "K0712", + "K0713", + "K0716", + "K0717", + "K0718", + "K0720", + "K0721", + "K0723", + "K0724", + "K0725", + "K0726", + "K0728", + "K0729", + "K0730", + "K0731", + "K0732", + "K0736", + "K0737", + "K0738", + "K0739", + "K0740", + "K0741", + "K0742", + "K0743", + "K0744", + "K0746", + "K0747", + "K0749", + "K0750", + "K0751", + "K0752", + "K0753", + "K0756", + "K0757", + "K0758", + "K0759", + "K0760", + "K0761", + "K0762", + "K0765", + "K0766", + "K0769", + "K0770", + "K0772", + "K0773", + "K0775", + "K0778", + "K0779", + "K0780", + "K0781", + "K0783", + "K0784", + "K0785", + "K0786", + "K0787", + "K0788", + "K0789", + "K0790", + "K0791", + "K0792", + "K0793", + "K0794", + "K0803", + "K0805", + "K0806", + "K0811", + "K0812", + "K0813", + "K0814", + "K0815", + "K0816", + "K0820", + "K0828", + "K0829", + "K0830", + "K0831", + "K0832", + "K0833", + "K0837", + "K0838", + "K0839", + "K0840", + "K0842", + "K0844", + "K0845", + "K0848", + "K0849", + "K0851", + "K0857", + "K0858", + "K0859", + "K0860", + "K0861", + "K0862", + "K0863", + "K0864", + "K0865", + "K0866", + "K0867", + "K0870", + "K0871", + "K0872", + "K0873", + "K0874", + "K0875", + "K0876", + "K0877", + "K0878", + "K0879", + "K0880", + "K0881", + "K0891", + "K0892", + "K0897", + "K0898", + "K0903", + "K0904", + "K0911", + "K0914", + "K0915", + "K0916", + "K0917", + "K0918", + "K0919", + "K0923", + "K0924", + "K0925", + "K0926", + "K0928", + "K0934", + "K0935", + "K0936", + "K0937", + "K0938", + "K0939", + "K0940", + "K0942", + "K0946", + "K0947", + "K0948", + "K0950", + "K0951", + "K0952", + "K0953", + "K0955", + "K0956", + "K0958", + "K0962", + "K0963", + "K0969", + "K0983", + "K0991", + "K0992", + "K1014", + "K1032", + "K1049", + "K1069", + "K1072", + "K1077", + "K1079", + "K1084", + "K1089", + "K1090", + "K1094", + "K1096", + "K1097", + "K1099", + "K1100", + "K1101", + "K1108", + "K1112", + "K1127", + "K1131", + "K1132", + "K1144", + "K1146", + "K1157", + "K1168", + "K1176", + "K1181", + "K1188", + "K1191", + "K1193", + "K1219", + "K1226", + "K1233", + "K1238", + "S0015", + "S0028", + "S0029", + "S0035", + "S0045", + "S0048", + "S0077", + "S0080", + "S0109", + "S0111", + "S0114", + "S0118", + "S0156", + "S0172", + "S0399", + "S0400", + "S0401", + "S0402", + "S0403", + "S0407", + "S0408", + "S0409", + "S0421", + "S0424", + "S0425", + "S0430", + "S0438", + "S0448", + "S0449", + "S0451", + "S0472", + "S0473", + "S0478", + "S0483", + "S0487", + "S0488", + "S0490", + "S0509", + "S0543", + "S0544", + "S0547", + "S0548", + "S0549", + "S0550", + "S0558", + "S0559", + "S0561", + "S0562", + "S0563", + "S0566", + "S0567", + "S0568", + "S0572", + "S0573", + "S0574", + "S0575", + "S0576", + "S0577", + "S0578", + "S0579", + "S0582", + "S0583", + "S0584", + "S0585", + "S0586", + "S0587", + "S0589", + "S0593", + "S0594", + "S0595", + "S0597", + "S0598", + "S0600", + "S0606", + "S0607", + "S0608", + "S0609", + "S0610", + "S0613", + "S0614", + "S0615", + "S0618", + "S0621", + "S0622", + "S0626", + "S0627", + "S0630", + "S0631", + "S0632", + "S0634", + "S0635", + "S0636", + "S0639", + "S0640", + "S0644", + "S0646", + "S0648", + "S0649", + "S0650", + "S0651", + "S0661", + "S0662", + "S0663", + "S0667", + "S0671", + "S0672", + "S0674", + "S0675", + "S0677", + "S0678", + "S0679", + "S0680", + "S0681", + "S0682", + "S0685", + "S0687", + "S0688", + "S0700", + "S0701", + "S0709", + "S0710", + "S0711", + "S0712", + "S0722", + "S0728", + "S0729", + "S0735", + "S0741", + "S0755", + "S0762", + "S0791", + "S0805", + "S0806", + "S0807", + "S0808", + "S0809", + "S0815", + "S0819", + "S0820", + "S0821", + "S0828", + "S0835", + "S0838", + "S0839", + "S0840", + "S0842", + "S0844", + "S0846", + "S0852", + "S0854", + "S0857", + "S0859", + "S0863", + "S0866", + "S0867", + "S0869", + "S0872", + "S0874", + "S0875", + "S0877", + "S0885", + "S0889", + "S0892", + "S0897", + "T0020", + "T0068", + "T0080", + "T0081", + "T0126", + "T0129", + "T0153", + "T0164", + "T0237", + "T0262", + "T0274", + "T0292", + "T0299", + "T0349", + "T0383", + "T0431", + "T0460", + "T0510", + "T0512", + "T0513", + "T0531", + "T1020", + "T1021", + "T1024", + "T1049", + "T1050", + "T1063", + "T1064", + "T1065", + "T1066", + "T1084", + "T1085", + "T1092", + "T1100", + "T1101", + "T1109", + "T1110", + "T1112", + "T1118", + "T1119", + "T1130", + "T1136", + "T1138", + "T1140", + "T1141", + "T1143", + "T1144", + "T1176", + "T1177", + "T1208", + "T1209", + "T1210", + "T1214", + "T1215", + "T1228", + "T1241", + "T1242", + "T1248", + "T1250", + "T1251", + "T1252", + "T1254", + "T1255", + "T1256", + "T1257", + "T1260", + "T1266", + "T1275", + "T1276", + "T1277", + "T1278", + "T1290", + "T1297", + "T1299", + "T1313", + "T1314", + "T1315", + "T1316", + "T1332", + "T1333", + "T1334", + "T1346", + "T1347", + "T1348", + "T1349", + "T1350", + "T1351", + "T1370", + "T1371", + "T1372", + "T1384", + "T1386", + "T1387", + "T1388", + "T1389", + "T1390", + "T1391", + "T1400", + "T1405", + "T1406", + "T1407", + "T1411", + "T1412", + "T1427", + "T1428", + "T1429", + "T1440", + "T1445", + "T1458", + "T1484", + "T1485", + "T1489", + "T1491", + "T1500", + "T1501", + "T1502", + "T1506", + "T1512", + "T1527", + "T1530", + "T1531", + "T1538", + "T1539", + "T1540", + "T1541", + "T1548", + "T1554", + "T1566", + "T1567", + "T1569", + "T1570", + "T1571", + "T1572", + "T1578", + "T1579", + "T1580", + "T1581", + "T1582", + "T1583", + "T1587", + "T1588", + "T1589", + "T1603", + "T1606", + "T1610", + "T1611", + "T1612", + "T1615", + "T1616", + "T1617", + "T1618", + "T1626", + "T1829" + ] +} diff --git a/scripts/nice-coverage.mjs b/scripts/nice-coverage.mjs new file mode 100644 index 00000000..174aebcf --- /dev/null +++ b/scripts/nice-coverage.mjs @@ -0,0 +1,108 @@ +/** + * NIST NICE coverage report + off-catalog guard. + * + * Scans every `nice: [...]` array across src/campaigns/, diffs the claimed + * codes against scripts/nice-catalog.json (the checked-in valid-code set, + * regenerated by scripts/gen-nice-catalog.mjs from the reviewer skill), and: + * - prints a per-campaign coverage table + product totals, + * - lists any off-catalog codes with their file:line, + * - exits 1 if any off-catalog code is claimed (this would have caught the + * K0108 code that shipped in the signal-hunter sandbox). + * + * Run from the repo root: + * node scripts/nice-coverage.mjs + * + * The same off-catalog assertion runs in CI via + * test/campaigns/nice-coverage.test.ts. + */ +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join, relative } from 'node:path'; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptsDir, '..'); +const campaignsDir = join(repoRoot, 'src', 'campaigns'); + +const catalog = JSON.parse(readFileSync(join(scriptsDir, 'nice-catalog.json'), 'utf8')); +const validCodes = new Set(catalog.codes); + +/** Recursively list every .ts file under a directory. */ +function walkTs(dir) { + const out = []; + for (const entry of readdirSync(dir)) { + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walkTs(full)); + } else if (entry.endsWith('.ts')) { + out.push(full); + } + } + return out; +} + +const NICE_ARRAY = /nice:\s*\[([^\]]*)\]/g; +const CODE = /['"]([A-Z]\d{4})['"]/g; + +/** + * Extract every claimed NICE code occurrence from campaign source. + * @returns {{ code: string, campaign: string, file: string, line: number }[]} + */ +function extractClaims() { + const claims = []; + for (const file of walkTs(campaignsDir)) { + const rel = relative(repoRoot, file).replace(/\\/g, '/'); + const campaign = rel.split('/')[2] ?? '(root)'; + const text = readFileSync(file, 'utf8'); + let arrMatch; + while ((arrMatch = NICE_ARRAY.exec(text)) !== null) { + const line = text.slice(0, arrMatch.index).split('\n').length; + let codeMatch; + CODE.lastIndex = 0; + while ((codeMatch = CODE.exec(arrMatch[1])) !== null) { + claims.push({ code: codeMatch[1], campaign, file: rel, line }); + } + } + } + return claims; +} + +const claims = extractClaims(); + +// Per-campaign coverage +const byCampaign = new Map(); +for (const { code, campaign } of claims) { + if (!byCampaign.has(campaign)) byCampaign.set(campaign, new Set()); + byCampaign.get(campaign).add(code); +} + +const distinctProductCodes = new Set(claims.map((c) => c.code)); +const offCatalog = claims.filter((c) => !validCodes.has(c.code)); + +console.log('NIST NICE coverage report'); +console.log('='.repeat(60)); +console.log(`Catalog: ${catalog.count} valid codes (scripts/nice-catalog.json)\n`); + +const campaignRows = [...byCampaign.entries()].sort((a, b) => a[0].localeCompare(b[0])); +console.log('Campaign'.padEnd(20), 'Distinct codes'); +console.log('-'.repeat(40)); +for (const [campaign, codes] of campaignRows) { + console.log(campaign.padEnd(20), codes.size); +} +console.log('-'.repeat(40)); + +const validClaimed = [...distinctProductCodes].filter((c) => validCodes.has(c)); +const pct = ((validClaimed.length / catalog.count) * 100).toFixed(1); +console.log(`Product distinct in-catalog codes: ${validClaimed.length} / ${catalog.count} (${pct}%)`); +console.log(`Total code occurrences claimed: ${claims.length}\n`); + +if (offCatalog.length > 0) { + console.error(`OFF-CATALOG CODES (${offCatalog.length}) — not in scripts/nice-catalog.json:`); + for (const { code, file, line } of offCatalog) { + console.error(` ${code} ${file}:${line}`); + } + console.error('\nFix: use an in-catalog code, or add the code to the reviewer'); + console.error('reference files and rerun scripts/gen-nice-catalog.mjs.'); + process.exit(1); +} + +console.log('All claimed codes are in-catalog. ✓'); diff --git a/scripts/plugin/commands/add.ts b/scripts/plugin/commands/add.ts new file mode 100644 index 00000000..4bb6f79b --- /dev/null +++ b/scripts/plugin/commands/add.ts @@ -0,0 +1,125 @@ +import { existsSync, rmSync } from 'node:fs'; +import { basename, resolve } from 'node:path'; +import { writeGeneratedManifest } from '../lib/codegen'; +import { confirm } from '../lib/confirm'; +import { checkout, clone, headCommit } from '../lib/git'; +import { assertNameAvailable, deriveName, enforceEngineCompat, isGitUrl, isInsideExternalDir, validatePluginStrict } from '../lib/install-helpers'; +import { readLockfile, writeLockfile } from '../lib/lockfile'; +import { CliError, log } from '../lib/log'; +import { EXTERNAL_DIR, pluginDir } from '../lib/paths'; +import { readManifest } from '../lib/plugin-meta'; +import type { Flags, LockEntry } from '../lib/types'; + +export function addCommand(positionals: string[], flags: Flags): Promise { + const source = positionals[0]; + + if (!source) { + throw new CliError('Usage: pnpm run plugin -- add [--ref ] [--name ] [--force] [--yes]'); + } + + const force = Boolean(flags.force); + const assumeYes = Boolean(flags.yes); + + if (isGitUrl(source)) { + return addFromGit(source, flags, force, assumeYes); + } + + return Promise.resolve(addFromLocal(source, flags, force)); +} + +async function addFromGit(url: string, flags: Flags, force: boolean, assumeYes: boolean): Promise { + const name = (typeof flags.name === 'string' && flags.name) || deriveName(url); + const ref = typeof flags.ref === 'string' ? flags.ref : ''; + + assertNameAvailable(name); + + log.warn(`Installing "${name}" runs third-party code in your build and bundle. Only install plugins you trust.`); + if (!(await confirm(`Install ${name} from ${url}?`, assumeYes))) { + log.info('Aborted.'); + + return 1; + } + + const dir = pluginDir(name); + + try { + log.step(`Cloning ${url}`); + clone(url, dir); + if (ref) { + checkout(dir, ref); + } + const commit = headCommit(dir); + + validatePluginStrict(dir, name); + enforceEngineCompat(readManifest(dir).engine, force); + + recordAndFinalize(name, { url, ref: ref || '(default)', commit, installedAt: new Date().toISOString() }); + } catch (e) { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); + } + throw e instanceof CliError ? e : new CliError((e as Error).message); + } + + return 0; +} + +function addFromLocal(source: string, flags: Flags, force: boolean): number { + const abs = resolve(source); + + if (!existsSync(abs)) { + throw new CliError(`Local path not found: ${source}`); + } + if (!isInsideExternalDir(abs)) { + throw new CliError( + `Local installs must already live under src/plugins-external/. Move the folder there first, or use "create".\n Got: ${abs}\n Expected under: ${EXTERNAL_DIR}` + ); + } + + const name = (typeof flags.name === 'string' && flags.name) || basename(abs); + const existing = readLockfile().plugins[name]; + + // Local re-add is idempotent (used by CI + after `create`): refresh, don't + // reject. The folder has to exist for a local install, so only the lockfile + // can say the name is taken, and only by a git install. + if (existing && !existing.local) { + throw new CliError(`"${name}" is installed from ${existing.url}. Remove it first, or use --name.`); + } + + validatePluginStrict(abs, name); + enforceEngineCompat(readManifest(abs).engine, force); + + let commit = 'local'; + + // Only a plugin that is its own git repo has a commit of its own; without + // the check rev-parse would report the host engine's HEAD instead. + if (existsSync(resolve(abs, '.git'))) { + try { + commit = headCommit(abs); + } catch { + // Initialised but no commits yet; that's fine for a local entry. + } + } + + recordAndFinalize(name, { url: abs, ref: 'local', commit, installedAt: new Date().toISOString(), local: true }); + + return 0; +} + +/** Write the lockfile entry, regenerate the manifest, and print a summary. */ +function recordAndFinalize(name: string, entry: LockEntry): void { + const lock = readLockfile(); + + lock.plugins[name] = entry; + writeLockfile(lock); + + const manifest = readManifest(pluginDir(name)); + const report = writeGeneratedManifest(); + const ids = manifest.plugins.map((p) => p.id).join(', '); + + log.success(`Installed ${name}@${manifest.version} (${ids}).`); + if (report.incompatible.some((i) => manifest.plugins.some((p) => p.id === i.id))) { + log.warn('This plugin may be incompatible with the current host version; see "pnpm run plugin -- list".'); + } + log.info('Run "pnpm run build" (or restart "pnpm run dev") to include it.'); +} diff --git a/scripts/plugin/commands/create.ts b/scripts/plugin/commands/create.ts new file mode 100644 index 00000000..bd2982eb --- /dev/null +++ b/scripts/plugin/commands/create.ts @@ -0,0 +1,115 @@ +import { writeGeneratedManifest } from '../lib/codegen'; +import { init as gitInit } from '../lib/git'; +import { hostVersion } from '../lib/host-version'; +import { assertNameAvailable, validatePluginStrict } from '../lib/install-helpers'; +import { readLockfile, writeLockfile } from '../lib/lockfile'; +import { CliError, log } from '../lib/log'; +import { pluginDir } from '../lib/paths'; +import { promptText } from '../lib/prompt'; +import { buildVars, kebabCase, renderPlugin, titleCase } from '../lib/render-template'; +import type { Flags } from '../lib/types'; + +/** The only scaffold kind SignalRange ships; accepted for forward compatibility. */ +const KIND = 'antenna'; + +interface CreateOptions { + readonly base: string; + readonly description: string; + readonly author: string; +} + +async function gatherOptions(flags: Flags): Promise { + const skip = Boolean(flags.yes); + const base = kebabCase(await resolveName(flags, skip)); + + if (!base) { + throw new CliError('A plugin name is required. Usage: pnpm run plugin -- create [--description ] [--author ] [--yes]'); + } + if (!/^[a-z]/u.test(base)) { + throw new CliError(`Plugin name "${base}" must start with a letter (it becomes the class name, plugin id, and antenna id).`); + } + if (flags.kind !== undefined && flags.kind !== KIND) { + throw new CliError(`Unknown kind "${String(flags.kind)}" (the only kind is "${KIND}").`); + } + + const defaultDesc = `A SignalRange plugin (${titleCase(base)}).`; + const description = await resolveOption(flags.description, skip, defaultDesc, () => promptText('Short description', defaultDesc)); + const author = await resolveOption(flags.author, skip, '', () => promptText('Author', '')); + + return { base, description, author }; +} + +/** Return a flag value, its skip-default, or the interactive prompt result. */ +function resolveOption(flag: string | boolean | undefined, skip: boolean, def: string, promptFn: () => Promise): Promise { + if (typeof flag === 'string') { + return Promise.resolve(flag); + } + + return skip ? Promise.resolve(def) : promptFn(); +} + +function resolveName(flags: Flags, skip: boolean): Promise { + if (typeof flags.name === 'string') { + return Promise.resolve(flags.name); + } + + return skip ? Promise.resolve('') : promptText('Plugin name (kebab-case, e.g. big-dish)'); +} + +export async function createCommand(positionals: string[], flags: Flags): Promise { + const merged: Flags = { ...flags }; + + if (positionals[0] && typeof merged.name !== 'string') { + merged.name = positionals[0]; + } + + const { base, description, author } = await gatherOptions(merged); + const vars = buildVars(base, hostVersion(), { description, author }); + const dir = pluginDir(vars.PLUGIN_PKG); + + assertNameAvailable(vars.PLUGIN_PKG); + + renderPlugin(dir, vars); + validatePluginStrict(dir, vars.PLUGIN_PKG); + + try { + gitInit(dir); + } catch { + log.warn('git init failed; the plugin folder was created but is not a git repo yet.'); + } + + recordLocalEntry(vars.PLUGIN_PKG); + writeGeneratedManifest(); + + printNextSteps(vars.PLUGIN_PKG, vars.PLUGIN_ID); + + return 0; +} + +function printNextSteps(pkgName: string, pluginId: string): void { + console.log(''); + log.success(`Created an ${KIND} plugin: src/plugins-external/${pkgName} (${pluginId})`); + console.log(''); + console.log(log.bold(' Next steps')); + console.log(` 1. Preview live: ${log.bold(`pnpm run plugin -- dev ${pluginId}`)}`); + console.log(` 2. Edit the code: src/plugins-external/${pkgName}/src/plugin.ts`); + console.log(` 3. Validate: pnpm run plugin -- doctor ${pluginId}`); + console.log(` 4. Run tests: pnpm run plugin -- test ${pluginId}`); + console.log(''); + console.log(log.dim(` Publish: cd src/plugins-external/${pkgName} && git remote add origin && git push -u origin main`)); + console.log(log.dim(' Guide: docs/plugin-development-guide.md')); + console.log(''); +} + +function recordLocalEntry(pkgName: string): void { + const lock = readLockfile(); + + lock.plugins[pkgName] = { + url: pluginDir(pkgName), + ref: 'local', + commit: 'local', + installedAt: new Date().toISOString(), + local: true, + }; + writeLockfile(lock); +} diff --git a/scripts/plugin/commands/dev.ts b/scripts/plugin/commands/dev.ts new file mode 100644 index 00000000..597205a5 --- /dev/null +++ b/scripts/plugin/commands/dev.ts @@ -0,0 +1,190 @@ +import type { ChildProcess } from 'node:child_process'; +import type { Browser, BrowserType, Page } from 'playwright'; +import { buildDevUrl, DEFAULT_SCENARIO, DEV_SERVER_URL, isBenignConsole, isServerUp, killTree, waitForReady, waitForServer, waitForSigint } from '../lib/boot'; +import { CliError, log } from '../lib/log'; +import { REPO_ROOT } from '../lib/paths'; +import { resolvePlugin } from '../lib/resolve-plugin'; +import { runShim } from '../lib/run'; +import type { Flags } from '../lib/types'; + +const USAGE = 'Usage: pnpm run plugin -- dev [--scenario /] [--antenna ] [--headless] [--full] [--url] [--no-open]'; + +interface DevFlags { + readonly headless: boolean; + readonly full: boolean; + readonly noOpen: boolean; + readonly urlOnly: boolean; +} + +export async function devCommand(positionals: string[], flags: Flags): Promise { + const name = positionals[0]; + + if (!name) { + throw new CliError(USAGE); + } + + const resolved = resolvePlugin(name); + const pluginId = resolved.id; + const dependencies = resolved.kind === 'external' ? resolved.dependencies : []; + const antennaFlag = typeof flags.antenna === 'string' && flags.antenna ? flags.antenna : undefined; + const antenna = antennaFlag ?? (resolved.kind === 'external' ? resolved.manifest.provides?.antennas?.[0] : undefined); + const scenario = typeof flags.scenario === 'string' && flags.scenario ? flags.scenario : DEFAULT_SCENARIO; + const devFlags: DevFlags = { + headless: Boolean(flags.headless), + full: Boolean(flags.full), + noOpen: Boolean(flags['no-open']), + urlOnly: Boolean(flags.url), + }; + + let url: string; + + try { + url = buildDevUrl({ pluginId, dependencies, full: devFlags.full, scenario, antenna }); + } catch (e) { + throw new CliError(`${(e as Error).message}\n${USAGE}`); + } + + if (devFlags.urlOnly) { + console.log(url); + + return 0; + } + + const server = await ensureServer(); + + if (devFlags.noOpen) { + log.success(`Dev server ready for ${pluginId}. Open:\n ${url}`); + if (server) { + log.step('Press Ctrl+C to stop the dev server.'); + await waitForSigint(); + killTree(server); + } + + return 0; + } + + return runDevLoop(pluginId, url, devFlags, server); +} + +/** + * Playwright is installed as a dependency of @playwright/test, so the bare + * specifier normally resolves; fall back to the test package's re-export when + * the package manager did not hoist it. + */ +async function loadChromium(): Promise { + try { + return (await import('playwright')).chromium; + } catch { + return (await import('@playwright/test')).chromium; + } +} + +function forwardConsole(page: Page): void { + page.on('console', (msg) => { + const type = msg.type(); + + if ((type === 'warning' || type === 'error') && !isBenignConsole(msg.text())) { + console.log(`${log.dim('[browser]')} ${type}: ${msg.text()}`); + } + }); + page.on('pageerror', (err) => { + if (!isBenignConsole(err.message)) { + console.log(`${log.red('[browser] pageerror:')} ${err.message}`); + } + }); +} + +async function runDevLoop(pluginId: string, url: string, flags: DevFlags, server: ChildProcess | null): Promise { + // Lazy import so `add`/`list`/etc. don't pay Playwright's load cost. + const chromium = await loadChromium(); + const browser = await chromium.launch({ headless: flags.headless }); + const context = await browser.newContext({ viewport: { width: 1600, height: 900 } }); + const page = await context.newPage(); + + forwardConsole(page); + + log.info(`Booting ${pluginId} at ${url}`); + try { + await page.goto(url); + await waitForReady(page); + } catch (e) { + await browser.close().catch(() => undefined); + if (server) { + killTree(server); + } + throw new CliError(`The app did not become ready: ${(e as Error).message}`); + } + + let reloads = 0; + + page.on('load', () => { + waitForReady(page) + .then(() => { + reloads += 1; + log.step(`Reloaded (#${reloads}); ${pluginId} ready.`); + }) + .catch(() => { + // A reload mid-rebuild can race; the next load event will settle. + }); + }); + + log.success(`Dev session live for ${pluginId}. Edit your plugin; the app rebuilds and reloads automatically.`); + log.step(`Press Ctrl+C to stop.${flags.headless ? ' (headless)' : ''}`); + + await waitForExit(browser, server); + + return 0; +} + +/** Ensure a warm dev server; spawn one if absent. Returns the spawned child (to kill on exit) or null. */ +async function ensureServer(): Promise { + if (await isServerUp(DEV_SERVER_URL)) { + log.step(`Reusing dev server at ${DEV_SERVER_URL}.`); + + return null; + } + + log.info(`No dev server at ${DEV_SERVER_URL}; starting "pnpm run dev" (the first compile can take a minute).`); + + const child = runShim('pnpm', ['run', 'dev'], { cwd: REPO_ROOT, stdio: ['inherit', 'pipe', 'pipe'] }); + let exited = false; + + child.stdout?.on('data', (buf: Buffer) => process.stdout.write(buf)); + child.stderr?.on('data', (buf: Buffer) => process.stderr.write(buf)); + child.on('exit', () => { + exited = true; + }); + + // rspack serve accepts connections immediately but holds every request until + // the first compile finishes, so polling the URL is the readiness signal. + if (!(await waitForServer(DEV_SERVER_URL, 300_000, () => exited))) { + killTree(child); + throw new CliError(exited ? '"pnpm run dev" exited before the server answered.' : 'Dev server did not answer within 300 s.'); + } + + return child; +} + +function waitForExit(browser: Browser, server: ChildProcess | null): Promise { + return new Promise((resolve) => { + let done = false; + const shutdown = (): void => { + if (done) { + return; + } + done = true; + if (server) { + killTree(server); + } + resolve(); + }; + + browser.on('disconnected', shutdown); + process.on('SIGINT', () => { + browser + .close() + .catch(() => undefined) + .finally(shutdown); + }); + }); +} diff --git a/scripts/plugin/commands/doctor.ts b/scripts/plugin/commands/doctor.ts new file mode 100644 index 00000000..9daaa94e --- /dev/null +++ b/scripts/plugin/commands/doctor.ts @@ -0,0 +1,137 @@ +import { findUndeclaredAntennas } from '../lib/antenna-check'; +import { existingIds } from '../lib/existing-ids'; +import { classifyCompat, hostVersion } from '../lib/host-version'; +import { checkLocales, normalizeLocalesDir } from '../lib/locale-check'; +import { CliError, log } from '../lib/log'; +import { validateManifest } from '../lib/plugin-meta'; +import { type ResolvedExternal, resolvePlugin } from '../lib/resolve-plugin'; + +type Status = 'PASS' | 'WARN' | 'FAIL'; + +const TAG: Record = { + PASS: log.dim('PASS'), + WARN: log.yellow('WARN'), + FAIL: log.red('FAIL'), +}; + +function line(status: Status, label: string, detail = ''): void { + console.log(` [${TAG[status]}] ${label}${detail ? log.dim(` (${detail})`) : ''}`); +} + +export function doctorCommand(positionals: string[]): number { + const name = positionals[0]; + + if (!name) { + throw new CliError('Usage: pnpm run plugin -- doctor '); + } + + const resolved = resolvePlugin(name); + + if (resolved.kind !== 'external') { + throw new CliError(`"${name}" is a built-in plugin; doctor only checks external plugins.`); + } + + console.log(log.bold(`\nDoctor: ${resolved.name}@${resolved.manifest.version}\n`)); + + const failed = [checkManifest(resolved), checkEngine(resolved)].includes(true); + + checkLocaleBundle(resolved); + checkProvidedAntennas(resolved); + + console.log(''); + if (failed) { + log.error('Doctor found blocking issues.'); + + return 1; + } + log.success('Doctor passed.'); + + return 0; +} + +/** Manifest schema + disk consistency + id collisions. Returns true on FAIL. */ +function checkManifest({ dir, manifest, name }: ResolvedExternal): boolean { + const { errors, warnings } = validateManifest(manifest, { dir, expectedName: name, existingIds: existingIds(name) }); + + if (errors.length === 0) { + line('PASS', 'Manifest schema, entry files, and id collisions'); + } + for (const e of errors) { + line('FAIL', e); + } + for (const w of warnings) { + line('WARN', w); + } + + return errors.length > 0; +} + +/** Engine compatibility. Returns true on FAIL. */ +function checkEngine({ manifest }: ResolvedExternal): boolean { + const compat = classifyCompat(manifest.engine); + + if (compat === 'ok') { + line('PASS', `Engine ${manifest.engine} satisfies host ${hostVersion()}`); + + return false; + } + if (compat === 'minor-mismatch') { + line('WARN', `Engine ${manifest.engine} vs host ${hostVersion()} (same major)`); + + return false; + } + line('FAIL', compat === 'invalid-range' ? `Engine "${manifest.engine}" is not a valid semver range` : `Engine ${manifest.engine} incompatible with host ${hostVersion()}`); + + return true; +} + +/** English bundle present and namespaced under plugins.. Advisory only. */ +function checkLocaleBundle({ dir, manifest }: ResolvedExternal): void { + const ids = manifest.plugins.map((p) => p.id); + const enPath = `${normalizeLocalesDir(manifest.localesDir)}/en.json`; + const loc = checkLocales(dir, ids, manifest.localesDir); + + if (!loc.hasEn) { + line('WARN', `No ${enPath} found; user-facing strings will fall back to their keys`); + + return; + } + if (loc.parseError) { + line('WARN', `${enPath} could not be read`, loc.parseError); + + return; + } + + let clean = true; + + if (loc.extraRoots.length > 0) { + clean = false; + line('WARN', `${enPath} root keys should be only "plugins"`, `found: ${loc.extraRoots.join(', ')}`); + } + if (loc.missingIds.length > 0) { + clean = false; + line('WARN', `${enPath} has no plugins. entry for: ${loc.missingIds.join(', ')}`); + } + if (clean) { + line('PASS', `Locales: ${enPath} namespaced under plugins.${ids.join(' / plugins.')}`); + } +} + +/** Every provides.antennas id appears as a string literal under src/. Advisory only. */ +function checkProvidedAntennas({ dir, manifest }: ResolvedExternal): void { + const antennas = manifest.provides?.antennas ?? []; + + if (antennas.length === 0) { + line('PASS', 'provides.antennas: none declared'); + + return; + } + + const missing = findUndeclaredAntennas(dir, antennas); + + if (missing.length === 0) { + line('PASS', `provides.antennas found in src/: ${antennas.join(', ')}`); + } else { + line('WARN', `provides.antennas not found as a string literal under src/: ${missing.join(', ')}`); + } +} diff --git a/scripts/plugin/commands/list.ts b/scripts/plugin/commands/list.ts new file mode 100644 index 00000000..f7db2735 --- /dev/null +++ b/scripts/plugin/commands/list.ts @@ -0,0 +1,104 @@ +import { existsSync } from 'node:fs'; +import { type CompatLevel, classifyCompat } from '../lib/host-version'; +import { hasEnLocale } from '../lib/locale-check'; +import { readLockfile } from '../lib/lockfile'; +import { log } from '../lib/log'; +import { pluginDir } from '../lib/paths'; +import { readManifest } from '../lib/plugin-meta'; +import type { Flags } from '../lib/types'; + +interface ListRow { + readonly name: string; + readonly version: string; + readonly ids: readonly string[]; + readonly ref: string; + readonly commit: string; + readonly engine: string; + readonly compat: CompatLevel | 'not-installed' | 'error'; + /** True when /en.json is present. */ + readonly locales: boolean; + readonly antennas: readonly string[]; + readonly onDisk: boolean; +} + +function buildRows(): ListRow[] { + const lock = readLockfile(); + + return Object.entries(lock.plugins).map(([name, entry]): ListRow => { + const dir = pluginDir(name); + const onDisk = existsSync(dir); + const base: ListRow = { + name, + version: '?', + ids: [], + ref: entry.ref, + commit: entry.commit.slice(0, 7), + engine: '?', + compat: onDisk ? 'error' : 'not-installed', + locales: false, + antennas: [], + onDisk, + }; + + if (!onDisk) { + return base; + } + + try { + const m = readManifest(dir); + + return { + ...base, + version: m.version, + ids: m.plugins.map((p) => p.id), + engine: m.engine, + compat: classifyCompat(m.engine), + locales: hasEnLocale(dir, m.localesDir), + antennas: m.provides?.antennas ?? [], + }; + } catch { + return base; + } + }); +} + +const COMPAT_LABEL: Record = { + ok: 'OK', + 'minor-mismatch': 'WARN', + incompatible: 'INCOMPATIBLE', + 'invalid-range': 'BAD-RANGE', + 'not-installed': 'NOT-INSTALLED', + error: 'ERROR', +}; + +export function listCommand(flags: Flags): number { + const rows = buildRows(); + + if (flags.json) { + console.log(JSON.stringify(rows, null, 2)); + + return 0; + } + + if (rows.length === 0) { + log.info('No external plugins installed. Add one with: pnpm run plugin -- add '); + + return 0; + } + + log.plain(log.bold('\nInstalled external plugins:\n')); + for (const r of rows) { + const ids = r.ids.length > 0 ? r.ids.join(', ') : '—'; + const antennas = r.antennas.length > 0 ? r.antennas.join(', ') : '—'; + + log.plain(` ${log.bold(r.name)} ${log.dim(`v${r.version}`)}${r.onDisk ? '' : log.dim(' (not on disk; run restore)')}`); + log.plain(` plugins: ${ids}`); + log.plain(` ref: ${r.ref} @ ${r.commit}`); + log.plain(` engine: ${r.engine} [${COMPAT_LABEL[r.compat]}]`); + log.plain(` locales: ${r.locales ? 'yes' : 'no'}`); + log.plain(` antennas: ${antennas}`); + log.plain(''); + } + + return 0; +} diff --git a/scripts/plugin/commands/remove.ts b/scripts/plugin/commands/remove.ts new file mode 100644 index 00000000..7c2093d3 --- /dev/null +++ b/scripts/plugin/commands/remove.ts @@ -0,0 +1,34 @@ +import { existsSync, rmSync } from 'node:fs'; +import { writeGeneratedManifest } from '../lib/codegen'; +import { readLockfile, writeLockfile } from '../lib/lockfile'; +import { CliError, log } from '../lib/log'; +import { pluginDir } from '../lib/paths'; +import type { Flags } from '../lib/types'; + +export function removeCommand(positionals: string[], flags: Flags): number { + const name = positionals[0]; + + if (!name) { + throw new CliError('Usage: pnpm run plugin -- remove [--keep-files]'); + } + + const lock = readLockfile(); + const dir = pluginDir(name); + + if (!lock.plugins[name] && !existsSync(dir)) { + throw new CliError(`"${name}" is not installed.`); + } + + if (!flags['keep-files'] && existsSync(dir)) { + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); + log.step(`Removed src/plugins-external/${name}`); + } + + delete lock.plugins[name]; + writeLockfile(lock); + writeGeneratedManifest(); + + log.success(`Removed ${name}. Rebuild to apply.`); + + return 0; +} diff --git a/scripts/plugin/commands/restore.ts b/scripts/plugin/commands/restore.ts new file mode 100644 index 00000000..388395f7 --- /dev/null +++ b/scripts/plugin/commands/restore.ts @@ -0,0 +1,70 @@ +import { existsSync, rmSync } from 'node:fs'; +import { checkout, clone } from '../lib/git'; +import { readLockfile } from '../lib/lockfile'; +import { log } from '../lib/log'; +import { pluginDir } from '../lib/paths'; + +export interface RestoreResult { + readonly restored: string[]; + readonly localSkipped: string[]; + readonly failed: string[]; +} + +/** + * Clone any lockfile entry missing from disk and check out its pinned commit. + * Network/clone failures warn and continue: a missing plugin is later dropped + * from the generated manifest by sync, so the build still succeeds without it. + * Local entries are skipped with a warning. + */ +export function restoreMissing(): RestoreResult { + const lock = readLockfile(); + const result: RestoreResult = { restored: [], localSkipped: [], failed: [] }; + + for (const [name, entry] of Object.entries(lock.plugins)) { + const dir = pluginDir(name); + + if (existsSync(dir)) { + continue; + } + if (entry.local) { + log.warn(`${name} is a local plugin and its folder is missing; nothing to restore.`); + result.localSkipped.push(name); + continue; + } + + try { + log.step(`Restoring ${name} @ ${entry.commit.slice(0, 7)}`); + clone(entry.url, dir); + checkout(dir, entry.commit); + result.restored.push(name); + } catch (e) { + log.warn(`Failed to restore ${name}: ${(e as Error).message}`); + result.failed.push(name); + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true, maxRetries: 5 }); + } + } + } + + return result; +} + +export function restoreCommand(): number { + const { restored, localSkipped, failed } = restoreMissing(); + + if (restored.length === 0 && failed.length === 0 && localSkipped.length === 0) { + log.success('All external plugins already present.'); + } else { + if (restored.length > 0) { + log.success(`Restored ${restored.length} plugin(s): ${restored.join(', ')}`); + } + if (failed.length > 0) { + log.warn(`Failed to restore: ${failed.join(', ')}`); + } + } + + // `restore` alone does not regenerate the manifest; run `sync` for that. + log.step('Run "pnpm run plugin -- sync" to regenerate the manifest (or just build).'); + + return 0; +} diff --git a/scripts/plugin/commands/sync.ts b/scripts/plugin/commands/sync.ts new file mode 100644 index 00000000..2f83ddf1 --- /dev/null +++ b/scripts/plugin/commands/sync.ts @@ -0,0 +1,58 @@ +import { isGeneratedManifestInSync, writeGeneratedManifest } from '../lib/codegen'; +import { log } from '../lib/log'; +import type { Flags } from '../lib/types'; +import { restoreMissing } from './restore'; + +export interface SyncOptions { + /** Compare only; exit 1 on drift, write nothing. */ + readonly check?: boolean; + /** Skip cloning missing plugins (used when the caller already restored). */ + readonly skipRestore?: boolean; +} + +/** + * The single reconciliation entry point: restore missing clones and regenerate + * the committed manifest from disk. Idempotent and deterministic; a clean tree + * regenerates a byte-identical file. Called by prebuild/predev and after every + * add/remove/update. + */ +export function runSync(opts: SyncOptions = {}): number { + if (opts.check) { + if (isGeneratedManifestInSync()) { + log.success('plugin-manifest.external.generated.ts is in sync.'); + + return 0; + } + log.error('plugin-manifest.external.generated.ts is out of sync; run "pnpm run plugin -- sync".'); + + return 1; + } + + if (!opts.skipRestore) { + restoreMissing(); + } + + const report = writeGeneratedManifest(); + + for (const { name, reason } of report.invalid) { + log.warn(`Skipped invalid plugin "${name}": ${reason}`); + } + for (const name of report.missing) { + log.warn(`Plugin "${name}" is in the lockfile but not on disk; excluded from the build. Run "pnpm run plugin -- restore".`); + } + for (const { id, engine } of report.incompatible) { + log.warn(`Plugin "${id}" declares engine ${engine}, which does not satisfy the host version; it may fail to build.`); + } + + if (report.included.length > 0) { + log.success(`Wired ${report.included.length} external plugin(s): ${report.included.join(', ')}`); + } else { + log.step('No external plugins installed.'); + } + + return 0; +} + +export function syncCommand(flags: Flags): number { + return runSync({ check: Boolean(flags.check) }); +} diff --git a/scripts/plugin/commands/test.ts b/scripts/plugin/commands/test.ts new file mode 100644 index 00000000..753b4ccb --- /dev/null +++ b/scripts/plugin/commands/test.ts @@ -0,0 +1,35 @@ +import { relative } from 'node:path'; +import { CliError, log } from '../lib/log'; +import { REPO_ROOT } from '../lib/paths'; +import { resolvePlugin } from '../lib/resolve-plugin'; +import { runShimSync } from '../lib/run'; +import type { Flags } from '../lib/types'; + +/** + * Run an external plugin's unit tests through the host vitest harness. The main + * vitest config excludes src/plugins-external/** from the normal suite (third-party + * tests belong in their own repos), so this uses vitest.external.config.mts, the + * same env/aliases scoped to external plugins, filtered to the target folder. + */ +export function testCommand(positionals: string[], flags: Flags): number { + const name = positionals[0]; + + if (!name) { + throw new CliError('Usage: pnpm run plugin -- test [--watch]'); + } + + const resolved = resolvePlugin(name); + + if (resolved.kind !== 'external') { + throw new CliError(`"${name}" is a built-in plugin. Run its tests with the host "pnpm test".`); + } + + const dirFilter = relative(REPO_ROOT, resolved.dir).replaceAll('\\', '/'); + const args = ['vitest', flags.watch ? 'watch' : 'run', '--config', 'vitest.external.config.mts', dirFilter]; + + log.step(`vitest ${args.slice(1).join(' ')}`); + + const res = runShimSync('npx', args, { cwd: REPO_ROOT, stdio: 'inherit' }); + + return res.status ?? 1; +} diff --git a/scripts/plugin/commands/update.ts b/scripts/plugin/commands/update.ts new file mode 100644 index 00000000..7de99e79 --- /dev/null +++ b/scripts/plugin/commands/update.ts @@ -0,0 +1,110 @@ +import { existsSync } from 'node:fs'; +import { writeGeneratedManifest } from '../lib/codegen'; +import { checkout, fastForward, fetch, headCommit, remoteDefaultBranch } from '../lib/git'; +import { enforceEngineCompat, validatePluginStrict } from '../lib/install-helpers'; +import { readLockfile, writeLockfile } from '../lib/lockfile'; +import { CliError, log } from '../lib/log'; +import { pluginDir } from '../lib/paths'; +import { readManifest } from '../lib/plugin-meta'; +import type { Flags, LockEntry } from '../lib/types'; + +export function updateCommand(positionals: string[], flags: Flags): number { + const lock = readLockfile(); + const target = positionals[0]; + const names = target ? [target] : Object.keys(lock.plugins); + + if (names.length === 0) { + log.info('No external plugins installed.'); + + return 0; + } + + let changed = false; + + for (const name of names) { + const entry = lock.plugins[name]; + + if (!entry) { + throw new CliError(`"${name}" is not installed.`); + } + if (entry.local) { + log.step(`Skipping local plugin ${name}.`); + continue; + } + + const updated = updateOne(name, entry, flags); + + if (updated) { + lock.plugins[name] = updated; + changed = true; + } + } + + if (changed) { + writeLockfile(lock); + writeGeneratedManifest(); + log.success('Update complete. Rebuild to apply.'); + } else { + log.info('Everything already up to date.'); + } + + return 0; +} + +/** Move one clone forward; returns the new lock entry, or null when nothing changed. */ +function updateOne(name: string, entry: LockEntry, flags: Flags): LockEntry | null { + const dir = pluginDir(name); + + if (!existsSync(dir)) { + throw new CliError(`"${name}" is in the lockfile but not on disk; run "pnpm run plugin -- restore" first.`); + } + + const newRef = typeof flags.ref === 'string' ? flags.ref : ''; + const oldCommit = headCommit(dir); + + fetch(dir); + + // A pinned tag/SHA does not move unless the user explicitly passes a new --ref. + if (!newRef && entry.ref !== '(default)' && !isBranchRef(entry.ref)) { + log.step(`${name} is pinned to ${entry.ref}; pass --ref to change it.`); + + return null; + } + + try { + moveTo(dir, newRef || entry.ref); + const newCommit = headCommit(dir); + + if (newCommit === oldCommit && !newRef) { + return null; + } + + validatePluginStrict(dir, name); + enforceEngineCompat(readManifest(dir).engine, Boolean(flags.force)); + + log.success(`Updated ${name} → ${newCommit.slice(0, 7)}`); + + return { ...entry, ref: newRef || entry.ref, commit: newCommit }; + } catch (e) { + log.error(`Update of ${name} failed: ${(e as Error).message}. Rolling back.`); + checkout(dir, oldCommit); + + return null; + } +} + +function isBranchRef(ref: string): boolean { + // Heuristic: 40-hex or vX.Y.Z tags are pinned; anything else is treated as a branch. + return !/^[0-9a-f]{7,40}$/u.test(ref) && !/^v?\d+\.\d+\.\d+/u.test(ref); +} + +function moveTo(dir: string, ref: string): void { + if (ref === '(default)' || isBranchRef(ref)) { + const branch = ref === '(default)' ? remoteDefaultBranch(dir) : ref; + + checkout(dir, branch); + fastForward(dir, branch); + } else { + checkout(dir, ref); + } +} diff --git a/scripts/plugin/export-template.ts b/scripts/plugin/export-template.ts new file mode 100644 index 00000000..4881639e --- /dev/null +++ b/scripts/plugin/export-template.ts @@ -0,0 +1,64 @@ +/** + * Render the plugin template to a standalone directory, using example + * placeholder values, to (re)generate the public `signal-range-plugin-example` + * repo used for GitHub's "Use this template" flow. Keeps that repo from + * drifting out of sync with scripts/plugin/templates. + * + * pnpm run plugin:export-template -- + */ +import { rmSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { hostVersion } from './lib/host-version'; +import { log } from './lib/log'; +import { REPO_ROOT } from './lib/paths'; +import { buildVars, renderPlugin } from './lib/render-template'; +import { resolveStrictlyWithin } from './lib/safe-path'; + +const WORKSPACE_ROOT = resolve(REPO_ROOT, '..'); + +function main(): number { + const outArg = process.argv.slice(2).find((a) => a !== '--'); + + if (!outArg || outArg.startsWith('--')) { + log.error('Usage: pnpm run plugin:export-template -- '); + + return 1; + } + + // outArg is CLI-controlled and feeds a recursive rmSync below. Resolve it + // (relative to cwd) then confine it to the workspace so it can never delete + // a path outside the repo's containing folder; also refuse the workspace + // root and the repo root themselves. + let outDir: string; + + try { + outDir = resolveStrictlyWithin(WORKSPACE_ROOT, resolve(outArg)); + } catch (e) { + log.error((e as Error).message); + + return 1; + } + + if (outDir === REPO_ROOT) { + log.error('Refusing to export onto the repo root itself.'); + + return 1; + } + + const vars = buildVars('example', hostVersion(), { + description: 'An example SignalRange plugin: adds a 4.5 m Ka-band antenna that shows up in every sandbox loadout. Use this repo as a template.', + author: 'Theodore Kruczek', + repository: 'https://github.com/thkruz/signal-range-plugin-example', + }); + + // Clean the target so a removed template file doesn't linger in the export. + rmSync(outDir, { recursive: true, force: true, maxRetries: 5 }); + renderPlugin(outDir, vars); + + log.success(`Rendered template to ${outDir}`); + log.step('Commit this directory as the public signal-range-plugin-example repo.'); + + return 0; +} + +process.exitCode = main(); diff --git a/scripts/plugin/index.ts b/scripts/plugin/index.ts new file mode 100644 index 00000000..c6c9ecd9 --- /dev/null +++ b/scripts/plugin/index.ts @@ -0,0 +1,133 @@ +/** + * SignalRange plugin CLI: install, scaffold, and develop external plugins. + * + * pnpm run plugin -- add [--ref ] [--name ] [--force] [--yes] + * pnpm run plugin -- remove [--keep-files] + * pnpm run plugin -- update [name] [--ref ] [--force] + * pnpm run plugin -- list [--json] + * pnpm run plugin -- restore + * pnpm run plugin -- sync [--check] + * pnpm run plugin -- create [--description ] [--author ] [--yes] + * pnpm run plugin -- dev [--scenario /] [--antenna ] [--headless] [--full] [--url] [--no-open] + * pnpm run plugin -- test [--watch] + * pnpm run plugin -- doctor + */ +import { addCommand } from './commands/add'; +import { createCommand } from './commands/create'; +import { devCommand } from './commands/dev'; +import { doctorCommand } from './commands/doctor'; +import { listCommand } from './commands/list'; +import { removeCommand } from './commands/remove'; +import { restoreCommand } from './commands/restore'; +import { syncCommand } from './commands/sync'; +import { testCommand } from './commands/test'; +import { updateCommand } from './commands/update'; +import { CliError, log } from './lib/log'; +import type { Flags } from './lib/types'; + +interface ParsedArgs { + readonly positionals: string[]; + readonly flags: Flags; +} + +/** Flags that never take a value, so `--headless smoke` keeps `smoke` positional. */ +const BOOLEAN_FLAGS = new Set(['force', 'yes', 'keep-files', 'check', 'json', 'watch', 'headless', 'full', 'no-open', 'url']); + +/** Parse `--flag`, `--flag value`, and `--flag=value`; everything else is a positional. */ +function parseArgs(argv: string[]): ParsedArgs { + const positionals: string[] = []; + const flags: Flags = {}; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + + if (!arg.startsWith('--')) { + positionals.push(arg); + continue; + } + + const body = arg.slice(2); + const eq = body.indexOf('='); + + if (eq !== -1) { + flags[body.slice(0, eq)] = body.slice(eq + 1); + } else if (!BOOLEAN_FLAGS.has(body) && i + 1 < argv.length && !argv[i + 1].startsWith('--')) { + flags[body] = argv[++i]; + } else { + flags[body] = true; + } + } + + return { positionals, flags }; +} + +const HELP = ` +SignalRange Plugin CLI + + add Install a plugin (clones into src/plugins-external/) + remove Uninstall a plugin + update [name] Update one or all plugins to their latest ref + list [--json] List installed plugins and compatibility + restore Clone plugins recorded in external-plugins.json + sync [--check] Regenerate the manifest from installed plugins + create Scaffold a new antenna plugin skeleton + dev Boot a sandbox with only this plugin, live-reload + test Run this plugin's unit tests + doctor Validate a plugin (schema, collisions, locales, antennas) +`; + +function main(): number | Promise { + // `pnpm run plugin -- sync` forwards the `--` separator itself; drop it wherever it lands. + const [command, ...rest] = process.argv.slice(2).filter((arg) => arg !== '--'); + const { positionals, flags } = parseArgs(rest); + + switch (command) { + case 'add': + return addCommand(positionals, flags); + case 'remove': + return removeCommand(positionals, flags); + case 'update': + return updateCommand(positionals, flags); + case 'list': + return listCommand(flags); + case 'restore': + return restoreCommand(); + case 'sync': + return syncCommand(flags); + case 'create': + return createCommand(positionals, flags); + case 'dev': + return devCommand(positionals, flags); + case 'test': + return testCommand(positionals, flags); + case 'doctor': + return doctorCommand(positionals); + case undefined: + case 'help': + case '--help': + console.log(HELP); + + return 0; + default: + log.error(`Unknown command: ${command}`); + console.log(HELP); + + return 1; + } +} + +// Promise.resolve().then(main) routes synchronous throws into the same catch. +Promise.resolve() + .then(main) + .then((code) => { + process.exitCode = code; + }) + .catch((e: unknown) => { + if (e instanceof CliError) { + log.error(e.message); + } else { + log.error(`Unexpected error: ${(e as Error).message}`); + console.error((e as Error).stack); + } + process.exitCode = 1; + }); diff --git a/scripts/plugin/lib/antenna-check.ts b/scripts/plugin/lib/antenna-check.ts new file mode 100644 index 00000000..b6065ae4 --- /dev/null +++ b/scripts/plugin/lib/antenna-check.ts @@ -0,0 +1,56 @@ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +const SOURCE_EXT = /\.(?:[cm]?[jt]sx?)$/u; + +/** Every source file under `root`, recursively (node_modules skipped). */ +function sourceFiles(root: string): string[] { + if (!existsSync(root)) { + return []; + } + + const out: string[] = []; + const stack = [root]; + + while (stack.length > 0) { + const dir = stack.pop() as string; + + for (const name of readdirSync(dir)) { + if (name === 'node_modules' || name.startsWith('.')) { + continue; + } + const abs = join(dir, name); + + if (statSync(abs).isDirectory()) { + stack.push(abs); + } else if (SOURCE_EXT.test(name)) { + out.push(abs); + } + } + } + + return out; +} + +function escapeRegExp(text: string): string { + return text.replaceAll(/[.*+?^${}()|[\]\\]/gu, '\\$&'); +} + +/** + * `provides.antennas` is informational, but an id that never appears as a + * string literal under `src/` is almost certainly a typo. Returns the ids that + * were not found. + */ +export function findUndeclaredAntennas(dir: string, antennas: readonly string[]): string[] { + if (antennas.length === 0) { + return []; + } + + const sources = sourceFiles(join(dir, 'src')).map((file) => readFileSync(file, 'utf8')); + + return antennas.filter((id) => { + const literal = new RegExp(`['"\`]${escapeRegExp(id)}['"\`]`, 'u'); + + return !sources.some((src) => literal.test(src)); + }); +} diff --git a/scripts/plugin/lib/boot.ts b/scripts/plugin/lib/boot.ts new file mode 100644 index 00000000..22dc12d3 --- /dev/null +++ b/scripts/plugin/lib/boot.ts @@ -0,0 +1,143 @@ +import { type ChildProcess, spawnSync } from 'node:child_process'; +import type { Page } from 'playwright'; + +/** Where `pnpm run dev` (rspack serve) listens. */ +export const DEV_SERVER_URL = process.env.BASE_URL ?? 'http://127.0.0.1:3000'; + +/** Sandbox the dev harness boots into unless --scenario says otherwise. */ +export const DEFAULT_SCENARIO = 'nats/nats-sandbox'; + +export interface DevOverrideOptions { + readonly pluginId: string; + readonly dependencies: readonly string[]; + /** false => isStrictPluginList (only the target + deps + alwaysEnabled boot). */ + readonly full: boolean; +} + +export interface DevUrlOptions extends DevOverrideOptions { + /** `/`. */ + readonly scenario: string; + /** Antenna registry id for the `?antenna=` loadout override; omitted when undefined. */ + readonly antenna?: string; +} + +/** + * settingsOverride for a focused plugin-dev boot: strict-plugin-list isolation + * so ONLY the target plugin (plus its declared dependencies and always-enabled + * infra) loads. + */ +export function buildDevOverride(opts: DevOverrideOptions): Record { + const plugins: Record = { [opts.pluginId]: { enabled: true } }; + + for (const dep of opts.dependencies) { + plugins[dep] = { enabled: true }; + } + + return { isStrictPluginList: !opts.full, plugins }; +} + +/** + * Mirror of SettingsManager.encodeOverride: JSON → UTF-8 bytes as chars → base64url. + * Node 24 has a global `btoa`, so no Buffer round-trip is needed. + */ +export function encodeOverride(override: Record): string { + const json = JSON.stringify(override); + const utf8 = encodeURIComponent(json).replace(/%([0-9A-F]{2})/gu, (_m, hex: string) => String.fromCharCode(Number.parseInt(hex, 16))); + + return btoa(utf8).replace(/\+/gu, '-').replace(/\//gu, '_').replace(/[=]+$/u, ''); +} + +/** Split `--scenario /` into its route segments. */ +export function parseScenario(spec: string): { campaignId: string; scenarioId: string } { + const parts = spec.split('/').filter(Boolean); + + if (parts.length !== 2) { + throw new Error(`Scenario must be "/" (got "${spec}").`); + } + + return { campaignId: parts[0], scenarioId: parts[1] }; +} + +/** The full URL the dev harness opens. */ +export function buildDevUrl(opts: DevUrlOptions): string { + const { campaignId, scenarioId } = parseScenario(opts.scenario); + const params = new URLSearchParams({ settingsOverride: encodeOverride(buildDevOverride(opts)) }); + + if (opts.antenna) { + params.set('antenna', opts.antenna); + } + + return `${DEV_SERVER_URL}/campaigns/${encodeURIComponent(campaignId)}/scenarios/${encodeURIComponent(scenarioId)}?${params.toString()}`; +} + +/** One GET; true when the server answered with anything below 500. */ +export function isServerUp(url: string, timeoutMs = 5_000): Promise { + return fetch(url, { signal: AbortSignal.timeout(timeoutMs) }) + .then((r) => r.status < 500) + .catch(() => false); +} + +/** + * Poll a URL until it responds, the timeout elapses, or `abandoned()` reports + * the server process is gone. rspack serve holds requests until the first + * compile finishes, so each probe has its own short timeout. + */ +export function waitForServer(url: string, timeoutMs: number, abandoned: () => boolean = () => false): Promise { + const deadline = Date.now() + timeoutMs; + + return new Promise((resolve) => { + const attempt = (): void => { + isServerUp(url, 10_000).then((ok) => { + if (ok) { + resolve(true); + } else if (abandoned() || Date.now() >= deadline) { + resolve(false); + } else { + setTimeout(attempt, 1_000); + } + }); + }; + + attempt(); + }); +} + +/** Browser console chatter that is never the plugin's fault. */ +const BENIGN_CONSOLE = [/\[HMR\]/u, /\[webpack-dev-server\]/u, /\[rspack-dev-server\]/u, /favicon\.ico/u, /Download the React DevTools/u]; + +export function isBenignConsole(text: string): boolean { + return BENIGN_CONSOLE.some((re) => re.test(text)); +} + +/** + * Wait for the app to finish booting: every plugin registered and the + * mission-control tab bar on screen. String-form expressions dodge the tsx + * `__name` helper that a serialized arrow function would drag along. + */ +export async function waitForReady(page: Page): Promise { + await page.waitForFunction('window.signalRange && window.signalRange.plugins && window.signalRange.plugins.isReady === true', undefined, { timeout: 60_000 }); + await page.waitForSelector('#tab-bar', { state: 'visible', timeout: 60_000 }); +} + +/** + * Kill a spawned dev server. With `shell: true` on Windows the child is a + * cmd.exe whose grandchildren (pnpm → rspack) survive a plain kill(), so use + * taskkill's tree mode there. + */ +export function killTree(child: ChildProcess): void { + if (child.exitCode !== null || child.pid === undefined) { + return; + } + if (process.platform === 'win32') { + spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' }); + } else { + child.kill(); + } +} + +/** Resolve once the user presses Ctrl+C. */ +export function waitForSigint(): Promise { + return new Promise((resolve) => { + process.once('SIGINT', () => resolve()); + }); +} diff --git a/scripts/plugin/lib/builtin-ids.ts b/scripts/plugin/lib/builtin-ids.ts new file mode 100644 index 00000000..31999547 --- /dev/null +++ b/scripts/plugin/lib/builtin-ids.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs'; +import { BUILTIN_MANIFEST_PATH } from './paths'; + +let cached: Set | null = null; + +/** + * Extract every built-in plugin `id` by regex-scanning plugin-manifest.ts. + * + * Deliberately does NOT import the manifest module: it pulls in browser-only + * engine code and the `__IS_PRIVATE__` DefinePlugin constant, neither of which + * resolves under a plain tsx/Node CLI. + */ +export function builtinIds(): Set { + if (cached) { + return cached; + } + + const src = readFileSync(BUILTIN_MANIFEST_PATH, 'utf8'); + const ids = new Set(); + const re = /id:\s*'([^']+)'/gu; + let m: RegExpExecArray | null = re.exec(src); + + while (m !== null) { + ids.add(m[1]); + m = re.exec(src); + } + + cached = ids; + + return ids; +} diff --git a/scripts/plugin/lib/codegen.ts b/scripts/plugin/lib/codegen.ts new file mode 100644 index 00000000..416cccfc --- /dev/null +++ b/scripts/plugin/lib/codegen.ts @@ -0,0 +1,247 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import type { ExternalPluginMetaEntry } from '../../../src/plugins/plugin-descriptor'; +import { classifyCompat, isCompatible } from './host-version'; +import { hasEnLocale, normalizeLocalesDir } from './locale-check'; +import { readLockfile } from './lockfile'; +import { GENERATED_MANIFEST_PATH, pluginDir } from './paths'; +import { readManifest, validateManifest } from './plugin-meta'; +import type { LockEntry, SignalRangePluginManifest } from './types'; + +/** + * Header of the generated file. Kept as a line array so the backticks in the + * prose need no escaping; must stay byte-identical to the committed empty form. + */ +const HEADER = [ + '/**', + ' * AUTO-GENERATED by `pnpm run plugin -- sync`. DO NOT EDIT BY HAND.', + ' *', + ' * Regenerated from external-plugins.json + the signal-range-plugin.json of each', + ' * clone under src/plugins-external/. Committed so a fresh checkout typechecks', + ' * and builds without running any tool; empty upstream when no external plugins', + ' * are installed.', + ' *', + ' * - `externalPluginManifest` is spread onto the end of `pluginManifest`, giving', + ' * each external plugin its own async chunk exactly like a built-in.', + ' * - `externalPluginMeta` is read by the dev menu so it can list installed', + ' * external plugins (version, repo, compat) without fetching anything at', + ' * runtime. `compatible` is precomputed at sync time against the host', + ' * package.json version.', + ' */', + "import type { ExternalPluginMetaEntry, PluginDescriptor } from './plugin-descriptor';", + '', +].join('\n'); + +const EMPTY_BODY = ['export const externalPluginManifest: PluginDescriptor[] = [];', '', 'export const externalPluginMeta: Record = {};', ''].join( + '\n' +); + +export interface SyncReport { + /** Plugin names present on disk and included in the generated manifest. */ + readonly included: string[]; + /** Plugin names in the lockfile whose clone is missing (dropped from the manifest). */ + readonly missing: string[]; + /** Plugin names present on disk but skipped because their manifest is invalid. */ + readonly invalid: { readonly name: string; readonly reason: string }[]; + /** Ids whose engine range does not satisfy the host version. */ + readonly incompatible: { readonly id: string; readonly engine: string }[]; +} + +// ── JS literal emitter ────────────────────────────────────────────────────── +// The generated file lives under src/ and is linted by Biome (single quotes, +// unquoted keys, trailing commas), so values are emitted in the exact form the +// formatter would produce rather than as raw JSON. + +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; + +// Values come from third-party plugin manifests. JSON.stringify leaves these +// unescaped; none belongs in an id or path, so escaping them changes nothing +// for a real plugin and keeps a hostile one from shaping the emitted source. +const UNSAFE_CHARS: Record = { + '<': '\\u003C', + '>': '\\u003E', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +/** Quote a string the way Biome's formatter would (single unless that needs more escapes). */ +function quote(value: string): string { + const json = JSON.stringify(value).replace(/[<>\u2028\u2029]/gu, (c) => UNSAFE_CHARS[c]); + const singles = value.split("'").length - 1; + const doubles = value.split('"').length - 1; + + if (singles > doubles) { + return json; + } + + return `'${json.slice(1, -1).replaceAll('\\"', '"').replaceAll("'", "\\'")}'`; +} + +function key(name: string): string { + return IDENTIFIER.test(name) ? name : quote(name); +} + +/** Inline (single-line) literal for a JSON-compatible value. */ +function inline(value: unknown): string { + if (typeof value === 'string') { + return quote(value); + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + if (value === null || value === undefined) { + return 'null'; + } + if (Array.isArray(value)) { + return `[${value.map(inline).join(', ')}]`; + } + + const entries = Object.entries(value as Record).map(([k, v]) => `${key(k)}: ${inline(v)}`); + + return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; +} + +/** Multi-line object literal, one property per line at `indent` spaces. */ +function expanded(value: Record, indent: number): string { + const pad = ' '.repeat(indent); + const lines = Object.entries(value).map(([k, v]) => `${pad} ${key(k)}: ${inline(v)},`); + + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${pad}}`; +} + +/** Strip a TS/JS extension from a repo-relative entry path and normalize to POSIX. */ +function toImportSpecifier(name: string, entry: string): string { + const posix = entry.replaceAll('\\', '/').replace(/^\.\//u, ''); + const noExt = posix.replace(/\.(?:[cm]?[jt]sx?)$/u, ''); + + return `@plugins-external/${name}/${noExt}`; +} + +/** + * Build the generated-manifest source text from the lockfile + on-disk clones. + * Only plugins actually present on disk are included, so the file never contains + * an import rspack cannot resolve. + */ +export function generateManifestSource(): { source: string; report: SyncReport } { + const lock = readLockfile(); + const names = Object.keys(lock.plugins).sort((a, b) => a.localeCompare(b)); + const report: SyncReport = { included: [], missing: [], invalid: [], incompatible: [] }; + const descriptorBlocks: string[] = []; + const metaBlocks: string[] = []; + + for (const name of names) { + const dir = pluginDir(name); + + if (!existsSync(dir)) { + report.missing.push(name); + continue; + } + + // Lenient here (prebuild path): a malformed third-party manifest is skipped + // with a report entry rather than bricking the build. `add`/`update` are the + // strict gates that block on validation errors. + try { + const manifest = readManifest(dir); + const { errors } = validateManifest(manifest, { dir, expectedName: name }); + + if (errors.length > 0) { + report.invalid.push({ name, reason: errors[0] }); + continue; + } + + emitPlugin(name, dir, manifest, lock.plugins[name], descriptorBlocks, metaBlocks, report); + } catch (e) { + report.invalid.push({ name, reason: (e as Error).message }); + } + } + + if (descriptorBlocks.length === 0) { + return { source: `${HEADER}\n${EMPTY_BODY}`, report }; + } + + const body = [ + 'export const externalPluginManifest: PluginDescriptor[] = [', + ...descriptorBlocks, + '];', + '', + 'export const externalPluginMeta: Record = {', + ...metaBlocks, + '};', + '', + ].join('\n'); + + return { source: `${HEADER}\n${body}`, report }; +} + +function emitPlugin( + name: string, + dir: string, + manifest: SignalRangePluginManifest, + lockEntry: LockEntry, + descriptorBlocks: string[], + metaBlocks: string[], + report: SyncReport +): void { + report.included.push(name); + + const localesDir = normalizeLocalesDir(manifest.localesDir); + const hasLocales = hasEnLocale(dir, manifest.localesDir); + const meta: ExternalPluginMetaEntry = { + packageName: manifest.name, + version: manifest.version, + repoUrl: manifest.repository ?? lockEntry.url, + description: manifest.description ?? '', + engine: manifest.engine, + commit: lockEntry.commit, + compatible: classifyCompat(manifest.engine) === 'ok', + }; + const metaRecord = meta as unknown as Record; + + for (const p of manifest.plugins) { + const lines = [ + ` // ${name}@${manifest.version} (${lockEntry.commit.slice(0, 7)})`, + ' {', + ` id: ${quote(p.id)},`, + ` import: () => import(${quote(toImportSpecifier(name, p.entry))}),`, + ` className: ${quote(p.className)},`, + ` defaultConfig: ${inline(p.defaultConfig)},`, + ]; + + if (p.dependencies && p.dependencies.length > 0) { + lines.push(` dependencies: ${inline(p.dependencies)},`); + } + if (p.alwaysEnabled) { + lines.push(' alwaysEnabled: true,'); + } + if (hasLocales) { + lines.push(` locales: { en: () => import(${quote(`@plugins-external/${name}/${localesDir}/en.json`)}) },`); + } + lines.push(" source: 'external',", ` meta: ${expanded(metaRecord, 4)},`, ' },'); + + descriptorBlocks.push(lines.join('\n')); + + if (!isCompatible(manifest.engine)) { + report.incompatible.push({ id: p.id, engine: manifest.engine }); + } + + metaBlocks.push(` ${key(p.id)}: ${expanded(metaRecord, 2)},`); + } +} + +/** Regenerate and write the committed generated manifest. Returns the sync report. */ +export function writeGeneratedManifest(): SyncReport { + const { source, report } = generateManifestSource(); + + // Skip the write when nothing changed so a watching dev server is not poked. + if (!isGeneratedManifestInSync(source)) { + writeFileSync(GENERATED_MANIFEST_PATH, source, 'utf8'); + } + + return report; +} + +/** True if the on-disk generated manifest already matches what sync would write. */ +export function isGeneratedManifestInSync(source = generateManifestSource().source): boolean { + const current = existsSync(GENERATED_MANIFEST_PATH) ? readFileSync(GENERATED_MANIFEST_PATH, 'utf8') : ''; + + return current === source; +} diff --git a/scripts/plugin/lib/confirm.ts b/scripts/plugin/lib/confirm.ts new file mode 100644 index 00000000..4ea0bf73 --- /dev/null +++ b/scripts/plugin/lib/confirm.ts @@ -0,0 +1,22 @@ +import { createInterface } from 'node:readline/promises'; + +/** + * Ask a yes/no question. Returns true immediately when `assumeYes` is set or + * stdin is not a TTY (CI), so automated flows never hang. Interactive users get + * a real prompt defaulting to No. + */ +export async function confirm(question: string, assumeYes: boolean): Promise { + if (assumeYes || !process.stdin.isTTY) { + return true; + } + + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + try { + const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase(); + + return answer === 'y' || answer === 'yes'; + } finally { + rl.close(); + } +} diff --git a/scripts/plugin/lib/existing-ids.ts b/scripts/plugin/lib/existing-ids.ts new file mode 100644 index 00000000..eb456afe --- /dev/null +++ b/scripts/plugin/lib/existing-ids.ts @@ -0,0 +1,31 @@ +import { existsSync } from 'node:fs'; +import { builtinIds } from './builtin-ids'; +import { readLockfile } from './lockfile'; +import { pluginDir } from './paths'; +import { readManifest } from './plugin-meta'; + +/** + * Every id already claimed by a built-in plugin or an installed external + * plugin, excluding `excludeName` (so re-validating a plugin doesn't collide with + * itself). Used by `add`/`doctor` to reject duplicate ids before install. + */ +export function existingIds(excludeName?: string): Set { + const ids = new Set(builtinIds()); + const lock = readLockfile(); + + for (const name of Object.keys(lock.plugins)) { + if (name === excludeName || !existsSync(pluginDir(name))) { + continue; + } + + try { + for (const p of readManifest(pluginDir(name)).plugins) { + ids.add(p.id); + } + } catch { + // A malformed installed manifest is surfaced elsewhere; don't block here. + } + } + + return ids; +} diff --git a/scripts/plugin/lib/git.ts b/scripts/plugin/lib/git.ts new file mode 100644 index 00000000..8a72ac73 --- /dev/null +++ b/scripts/plugin/lib/git.ts @@ -0,0 +1,78 @@ +import { spawnSync } from 'node:child_process'; +import { CliError } from './log'; + +interface GitResult { + readonly ok: boolean; + readonly stdout: string; + readonly stderr: string; +} + +interface GitOptions { + readonly cwd?: string; + /** Stream output to the terminal (clone/fetch progress) instead of capturing it. */ + readonly inherit?: boolean; + /** Return `ok: false` instead of throwing on a non-zero exit. */ + readonly allowFail?: boolean; +} + +/** + * Run a git command. `git.exe` is a real executable on every platform, so no + * shell is involved (which also keeps Node 24's DEP0190 warning quiet). + * Captures output by default; pass `inherit: true` to stream progress. + */ +export function git(args: string[], opts: GitOptions = {}): GitResult { + const res = spawnSync('git', args, { + cwd: opts.cwd, + encoding: 'utf8', + stdio: opts.inherit ? ['ignore', 'inherit', 'inherit'] : 'pipe', + }); + + const ok = res.status === 0; + + if (!ok && !opts.allowFail) { + const spawnFailure = res.error ? `: ${res.error.message}` : ''; + const detail = opts.inherit ? spawnFailure : `: ${(res.stderr || res.stdout || spawnFailure.slice(2) || '').trim()}`; + + throw new CliError(`git ${args.join(' ')} failed${detail}`); + } + + return { ok, stdout: (res.stdout ?? '').trim(), stderr: (res.stderr ?? '').trim() }; +} + +/** Clone a repo into `dest`, streaming progress. */ +export function clone(url: string, dest: string): void { + git(['clone', url, dest], { inherit: true }); +} + +/** Check out a specific ref (branch, tag, or SHA) in `cwd`. */ +export function checkout(cwd: string, ref: string): void { + git(['checkout', ref], { cwd, inherit: true }); +} + +/** Resolve the current HEAD commit SHA in `cwd`. */ +export function headCommit(cwd: string): string { + return git(['rev-parse', 'HEAD'], { cwd }).stdout; +} + +/** Fetch all refs + tags. */ +export function fetch(cwd: string): void { + git(['fetch', '--tags', '--prune', 'origin'], { cwd, inherit: true }); +} + +/** Fast-forward the current branch to origin/; throws if not fast-forwardable. */ +export function fastForward(cwd: string, ref: string): void { + git(['merge', '--ff-only', `origin/${ref}`], { cwd, inherit: true }); +} + +/** The remote's default branch name (e.g. "main"), falling back to "main". */ +export function remoteDefaultBranch(cwd: string): string { + const res = git(['rev-parse', '--abbrev-ref', 'origin/HEAD'], { cwd, allowFail: true }); + const match = /^origin\/(.+)$/u.exec(res.stdout); + + return match ? match[1] : 'main'; +} + +/** Initialize a new git repo in `cwd` (used by the scaffolder). */ +export function init(cwd: string): void { + git(['init'], { cwd }); +} diff --git a/scripts/plugin/lib/host-version.ts b/scripts/plugin/lib/host-version.ts new file mode 100644 index 00000000..465154b3 --- /dev/null +++ b/scripts/plugin/lib/host-version.ts @@ -0,0 +1,49 @@ +import { readFileSync } from 'node:fs'; +import * as semver from 'semver'; +import { HOST_PACKAGE_JSON_PATH } from './paths'; + +let cachedVersion: string | null = null; + +/** The host SignalRange version (the value injected as __APP_VERSION__ at build time). */ +export function hostVersion(): string { + if (cachedVersion === null) { + const pkg = JSON.parse(readFileSync(HOST_PACKAGE_JSON_PATH, 'utf8')) as { version: string }; + + cachedVersion = pkg.version; + } + + return cachedVersion; +} + +export type CompatLevel = 'ok' | 'minor-mismatch' | 'incompatible' | 'invalid-range'; + +/** + * Classify a plugin's declared `engine` range against the host version. + * - ok: host satisfies the range. + * - minor-mismatch: same major, but host is outside the range (warn, proceed). + * - incompatible: different major (error unless --force). + * - invalid-range: the range string is not valid semver. + */ +export function classifyCompat(engineRange: string): CompatLevel { + const host = hostVersion(); + + if (!semver.validRange(engineRange)) { + return 'invalid-range'; + } + if (semver.satisfies(host, engineRange, { includePrerelease: true })) { + return 'ok'; + } + + const min = semver.minVersion(engineRange); + + if (min && semver.major(min) === semver.major(host)) { + return 'minor-mismatch'; + } + + return 'incompatible'; +} + +/** Boolean the generated meta embeds so the browser needs no semver library. */ +export function isCompatible(engineRange: string): boolean { + return classifyCompat(engineRange) === 'ok'; +} diff --git a/scripts/plugin/lib/install-helpers.ts b/scripts/plugin/lib/install-helpers.ts new file mode 100644 index 00000000..3490d2be --- /dev/null +++ b/scripts/plugin/lib/install-helpers.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { basename, isAbsolute, relative } from 'node:path'; +import { existingIds } from './existing-ids'; +import { classifyCompat, hostVersion } from './host-version'; +import { readLockfile } from './lockfile'; +import { CliError, log } from './log'; +import { EXTERNAL_DIR, pluginDir } from './paths'; +import { readManifest, validateManifest } from './plugin-meta'; + +const GIT_URL = /^(?:https?:\/\/|git@|ssh:\/\/|git:\/\/)/u; + +/** Heuristic: is this a remote git URL (vs a local filesystem path)? */ +export function isGitUrl(source: string): boolean { + return GIT_URL.test(source) || source.endsWith('.git'); +} + +/** Derive a sanitized, kebab-case install name from a git URL's basename. */ +export function deriveName(url: string): string { + const raw = basename(url.replace(/\/+$/u, '')).replace(/\.git$/u, ''); + const name = raw + .toLowerCase() + .replaceAll(/[^a-z0-9]+/gu, '-') + .replace(/^-+|-+$/gu, ''); + + if (!name) { + throw new CliError(`Could not derive a plugin name from "${url}"; pass --name .`); + } + + return name; +} + +/** Reject a name that is already installed (on disk or in the lockfile). */ +export function assertNameAvailable(name: string): void { + if (existsSync(pluginDir(name))) { + throw new CliError(`src/plugins-external/${name} already exists. Remove it first, or use --name.`); + } + if (readLockfile().plugins[name]) { + throw new CliError(`"${name}" is already in external-plugins.json. Use "update" or "remove" first.`); + } +} + +/** + * Strict validation used by `add`/`update`/`create`: throws CliError on the + * first schema, disk-consistency, or id-collision error. Prints non-fatal + * warnings. + */ +export function validatePluginStrict(dir: string, name: string): void { + const manifest = readManifest(dir); + const { errors, warnings } = validateManifest(manifest, { + dir, + expectedName: name, + existingIds: existingIds(name), + }); + + for (const w of warnings) { + log.warn(w); + } + if (errors.length > 0) { + throw new CliError(`Plugin validation failed:\n - ${errors.join('\n - ')}`); + } +} + +/** + * Enforce the engine-compat policy: minor mismatch warns, different-major errors + * unless `force`, invalid range errors. + */ +export function enforceEngineCompat(engineRange: string, force: boolean): void { + const level = classifyCompat(engineRange); + + if (level === 'ok') { + return; + } + if (level === 'invalid-range') { + throw new CliError(`engine "${engineRange}" is not a valid semver range.`); + } + if (level === 'minor-mismatch') { + log.warn(`Plugin targets engine ${engineRange} but host is ${hostVersion()} (same major). Proceeding.`); + + return; + } + // incompatible (different major) + const msg = `Plugin targets engine ${engineRange}, incompatible with host ${hostVersion()}.`; + + if (!force) { + throw new CliError(`${msg} Re-run with --force to install anyway.`); + } + log.warn(`${msg} Installing anyway (--force).`); +} + +/** True when `absPath` is a strict descendant of src/plugins-external. */ +export function isInsideExternalDir(absPath: string): boolean { + const rel = relative(EXTERNAL_DIR, absPath); + + return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel); +} diff --git a/scripts/plugin/lib/locale-check.ts b/scripts/plugin/lib/locale-check.ts new file mode 100644 index 00000000..8285ff90 --- /dev/null +++ b/scripts/plugin/lib/locale-check.ts @@ -0,0 +1,67 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +export const DEFAULT_LOCALES_DIR = 'locales'; + +export interface LocaleReport { + /** True when `/en.json` exists. */ + readonly hasEn: boolean; + /** Set when en.json exists but is not valid JSON (or not an object). */ + readonly parseError?: string; + /** Root keys other than `plugins` (plugins must stay in their own namespace). */ + readonly extraRoots: string[]; + /** Plugin ids with no `plugins.` entry. */ + readonly missingIds: string[]; +} + +/** Normalize a manifest `localesDir` to a plain relative POSIX path. */ +export function normalizeLocalesDir(localesDir: string | undefined): string { + const raw = (localesDir ?? DEFAULT_LOCALES_DIR).replaceAll('\\', '/').replace(/^\.\//u, '').replace(/\/+$/u, ''); + + return raw || DEFAULT_LOCALES_DIR; +} + +/** Absolute path of a plugin's English bundle. */ +export function enLocalePath(dir: string, localesDir: string | undefined): string { + return join(dir, normalizeLocalesDir(localesDir), 'en.json'); +} + +/** True when the plugin ships an English bundle. */ +export function hasEnLocale(dir: string, localesDir: string | undefined): boolean { + return existsSync(enLocalePath(dir, localesDir)); +} + +/** + * Check the English bundle of an installed plugin: it must exist, its only + * root key must be `plugins`, and `plugins.` must exist for every id. + * Findings are advisory (doctor reports WARN, never FAIL). + */ +export function checkLocales(dir: string, ids: readonly string[], localesDir: string | undefined): LocaleReport { + const file = enLocalePath(dir, localesDir); + + if (!existsSync(file)) { + return { hasEn: false, extraRoots: [], missingIds: [...ids] }; + } + + let en: unknown; + + try { + en = JSON.parse(readFileSync(file, 'utf8')); + } catch (e) { + return { hasEn: true, parseError: (e as Error).message, extraRoots: [], missingIds: [...ids] }; + } + + if (en === null || typeof en !== 'object' || Array.isArray(en)) { + return { hasEn: true, parseError: 'root must be a JSON object', extraRoots: [], missingIds: [...ids] }; + } + + const root = en as Record; + const plugins = root.plugins; + const pluginsObj = plugins !== null && typeof plugins === 'object' && !Array.isArray(plugins) ? (plugins as Record) : {}; + + return { + hasEn: true, + extraRoots: Object.keys(root).filter((k) => k !== 'plugins'), + missingIds: ids.filter((id) => !(id in pluginsObj)), + }; +} diff --git a/scripts/plugin/lib/lockfile.ts b/scripts/plugin/lib/lockfile.ts new file mode 100644 index 00000000..c3c7f103 --- /dev/null +++ b/scripts/plugin/lib/lockfile.ts @@ -0,0 +1,31 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { LOCKFILE_PATH } from './paths'; +import type { LockEntry, Lockfile } from './types'; + +const FORMAT_VERSION = 1; + +/** Read external-plugins.json, returning an empty lockfile if absent. */ +export function readLockfile(): Lockfile { + if (!existsSync(LOCKFILE_PATH)) { + return { formatVersion: FORMAT_VERSION, plugins: {} }; + } + + const parsed = JSON.parse(readFileSync(LOCKFILE_PATH, 'utf8')) as { formatVersion?: number; plugins?: Record }; + + return { formatVersion: parsed.formatVersion ?? FORMAT_VERSION, plugins: parsed.plugins ?? {} }; +} + +/** + * Write external-plugins.json with stable key ordering and LF line endings so + * the committed file is deterministic (no spurious diffs across machines). + */ +export function writeLockfile(lock: Lockfile): void { + const sortedNames = Object.keys(lock.plugins).sort((a, b) => a.localeCompare(b)); + const ordered: Lockfile = { formatVersion: FORMAT_VERSION, plugins: {} }; + + for (const name of sortedNames) { + ordered.plugins[name] = lock.plugins[name]; + } + + writeFileSync(LOCKFILE_PATH, `${JSON.stringify(ordered, null, 2)}\n`, 'utf8'); +} diff --git a/scripts/plugin/lib/log.ts b/scripts/plugin/lib/log.ts new file mode 100644 index 00000000..2de2bd02 --- /dev/null +++ b/scripts/plugin/lib/log.ts @@ -0,0 +1,39 @@ +/** + * Minimal ANSI logger for the plugin CLI. Self-contained so the CLI never pulls + * in bundler-side reporters. + */ +const STYLE = { + gray: '\x1b[90m', + cyan: '\x1b[36m', + red: '\x1b[31m', + yellow: '\x1b[33m', + green: '\x1b[32m', + bold: '\x1b[1m', + reset: '\x1b[0m', +} as const; + +function paint(color: keyof typeof STYLE, msg: string): string { + return `${STYLE[color]}${msg}${STYLE.reset}`; +} + +export const log = { + info: (msg: string): void => console.log(paint('cyan', msg)), + warn: (msg: string): void => console.warn(paint('yellow', `⚠ ${msg}`)), + error: (msg: string): void => console.error(paint('red', `✖ ${msg}`)), + success: (msg: string): void => console.log(paint('green', `✓ ${msg}`)), + step: (msg: string): void => console.log(paint('gray', ` ${msg}`)), + plain: (msg: string): void => console.log(msg), + bold: (msg: string): string => paint('bold', msg), + dim: (msg: string): string => paint('gray', msg), + red: (msg: string): string => paint('red', msg), + yellow: (msg: string): string => paint('yellow', msg), +}; + +/** A fatal CLI error whose message is already user-facing (no stack dump needed). */ +export class CliError extends Error { + constructor(message: string) { + super(message); + this.name = 'CliError'; + Object.setPrototypeOf(this, CliError.prototype); + } +} diff --git a/scripts/plugin/lib/paths.ts b/scripts/plugin/lib/paths.ts new file mode 100644 index 00000000..43725656 --- /dev/null +++ b/scripts/plugin/lib/paths.ts @@ -0,0 +1,37 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { CliError } from './log'; +import { resolveStrictlyWithin } from './safe-path'; + +const here = dirname(fileURLToPath(import.meta.url)); + +/** Repo root (scripts/plugin/lib → ../../..). */ +export const REPO_ROOT = resolve(here, '..', '..', '..'); + +/** Directory holding installed external-plugin clones. */ +export const EXTERNAL_DIR = resolve(REPO_ROOT, 'src', 'plugins-external'); + +/** Committed lockfile of installed plugins. */ +export const LOCKFILE_PATH = resolve(REPO_ROOT, 'external-plugins.json'); + +/** Committed generated manifest wiring. */ +export const GENERATED_MANIFEST_PATH = resolve(REPO_ROOT, 'src', 'plugins', 'plugin-manifest.external.generated.ts'); + +/** Built-in plugin manifest (scanned for id collisions). */ +export const BUILTIN_MANIFEST_PATH = resolve(REPO_ROOT, 'src', 'plugins', 'plugin-manifest.ts'); + +/** Host package.json (source of __APP_VERSION__ / engine-compat truth). */ +export const HOST_PACKAGE_JSON_PATH = resolve(REPO_ROOT, 'package.json'); + +/** + * Absolute path to an installed plugin's clone dir. Names come from the CLI and + * the lockfile and feed `rmSync`, so the result is confined to a strict child + * of src/plugins-external. + */ +export function pluginDir(name: string): string { + try { + return resolveStrictlyWithin(EXTERNAL_DIR, name); + } catch (e) { + throw new CliError(`Invalid plugin name "${name}": ${(e as Error).message}`); + } +} diff --git a/scripts/plugin/lib/plugin-meta.ts b/scripts/plugin/lib/plugin-meta.ts new file mode 100644 index 00000000..e0238a1a --- /dev/null +++ b/scripts/plugin/lib/plugin-meta.ts @@ -0,0 +1,167 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { CliError } from './log'; +import type { PluginEntry, SignalRangePluginManifest } from './types'; + +export const MANIFEST_FILENAME = 'signal-range-plugin.json'; +export const SUPPORTED_FORMAT_VERSION = 1; + +/** Read and JSON-parse a plugin's signal-range-plugin.json. Throws CliError on failure. */ +export function readManifest(dir: string): SignalRangePluginManifest { + const file = join(dir, MANIFEST_FILENAME); + + if (!existsSync(file)) { + throw new CliError(`No ${MANIFEST_FILENAME} found in ${dir}`); + } + + let parsed: SignalRangePluginManifest; + + try { + parsed = JSON.parse(readFileSync(file, 'utf8')) as SignalRangePluginManifest; + } catch (e) { + throw new CliError(`${MANIFEST_FILENAME} is not valid JSON: ${(e as Error).message}`); + } + + return parsed; +} + +export interface ValidationResult { + readonly errors: string[]; + readonly warnings: string[]; +} + +export interface ValidateOptions { + /** Plugin folder; when given, entry files are checked on disk. */ + readonly dir?: string; + /** Install directory name the manifest `name` must equal. */ + readonly expectedName?: string; + /** Ids already taken by built-ins + other installed plugins. */ + readonly existingIds?: Set; +} + +const KEBAB = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; +const PLUGIN_ID = /^[A-Za-z][A-Za-z0-9]*$/u; +const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/u; +const CLASS_NAME = /^[A-Za-z_$][A-Za-z0-9_$]*$/u; +// Plugin-relative path: forward slashes, no segment starting with '.', so no +// '..' and no absolute path. codegen writes these into generated source. +const REL_PATH = /^(?:\.\/)?[A-Za-z0-9_-][A-Za-z0-9_.-]*(?:\/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*\/?$/u; + +/** + * Validate a manifest's shape and, when `dir` is given, its consistency with the + * files on disk (entry exists, class exported, id declared). + */ +export function validateManifest(manifest: SignalRangePluginManifest, opts: ValidateOptions = {}): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + if (manifest.formatVersion !== SUPPORTED_FORMAT_VERSION) { + errors.push(`formatVersion must be ${SUPPORTED_FORMAT_VERSION} (got ${JSON.stringify(manifest.formatVersion)})`); + } + if (typeof manifest.name !== 'string' || !KEBAB.test(manifest.name)) { + errors.push(`name must be kebab-case (got ${JSON.stringify(manifest.name)})`); + } + if (opts.expectedName && manifest.name !== opts.expectedName) { + errors.push(`name "${manifest.name}" must equal the install directory name "${opts.expectedName}"`); + } + if (typeof manifest.version !== 'string' || !SEMVER.test(manifest.version)) { + errors.push(`version must be a semver string like "1.2.3" (got ${JSON.stringify(manifest.version)})`); + } + if (typeof manifest.engine !== 'string' || manifest.engine.length === 0) { + errors.push('engine is required (semver range, e.g. ">=1.1.0 <2.0.0")'); + } + if (manifest.localesDir !== undefined && (typeof manifest.localesDir !== 'string' || !REL_PATH.test(manifest.localesDir))) { + errors.push('localesDir must be a relative path inside the plugin (e.g. "locales") when present'); + } + validateProvides(manifest, errors); + + if (!Array.isArray(manifest.plugins) || manifest.plugins.length === 0) { + errors.push('plugins must be a non-empty array'); + + return { errors, warnings }; + } + + const seenIds = new Set(); + + for (const p of manifest.plugins) { + validateEntry(p, opts, seenIds, errors, warnings); + } + + return { errors, warnings }; +} + +function validateProvides(manifest: SignalRangePluginManifest, errors: string[]): void { + const provides = manifest.provides; + + if (provides === undefined) { + return; + } + if (provides === null || typeof provides !== 'object' || Array.isArray(provides)) { + errors.push('provides must be an object when present'); + + return; + } + if (provides.antennas !== undefined && (!Array.isArray(provides.antennas) || provides.antennas.some((a) => typeof a !== 'string' || a.length === 0))) { + errors.push('provides.antennas must be an array of non-empty strings when present'); + } +} + +function validateEntry(p: PluginEntry, opts: ValidateOptions, seenIds: Set, errors: string[], warnings: string[]): void { + const label = p.id ?? '(missing id)'; + + if (typeof p.id !== 'string' || !PLUGIN_ID.test(p.id)) { + errors.push(`[${label}] id must be a PascalCase identifier`); + + return; + } + if (typeof p.className !== 'string' || !CLASS_NAME.test(p.className)) { + errors.push(`[${label}] className must be a JavaScript class name`); + } + if (typeof p.entry !== 'string' || !REL_PATH.test(p.entry)) { + errors.push(`[${label}] entry must be a relative path inside the plugin (e.g. "src/plugin.ts")`); + } + if (!p.defaultConfig || typeof p.defaultConfig.enabled !== 'boolean') { + errors.push(`[${label}] defaultConfig.enabled (boolean) is required`); + } + if (p.dependencies !== undefined && (!Array.isArray(p.dependencies) || p.dependencies.some((d) => typeof d !== 'string'))) { + errors.push(`[${label}] dependencies must be an array of plugin ids when present`); + } + if (seenIds.has(p.id)) { + errors.push(`[${label}] duplicate id within this manifest`); + } + seenIds.add(p.id); + + if (opts.existingIds?.has(p.id)) { + errors.push(`id "${p.id}" collides with a built-in or already-installed plugin; choose a unique id`); + } + + if (opts.dir && typeof p.entry === 'string' && typeof p.className === 'string') { + validateEntryFile(p, opts.dir, errors, warnings); + } +} + +function validateEntryFile(p: PluginEntry, dir: string, errors: string[], warnings: string[]): void { + const entryPath = resolve(dir, p.entry); + + if (!existsSync(entryPath)) { + errors.push(`[${p.id}] entry file not found: ${p.entry}`); + + return; + } + + const src = readFileSync(entryPath, 'utf8'); + + // Named export of the class (what PluginManager.instantiate_ resolves via mod[className]). + const exportRe = new RegExp(`export\\s+(?:abstract\\s+)?class\\s+${p.className}\\b`, 'u'); + + if (!exportRe.test(src)) { + errors.push(`[${p.id}] entry must contain "export class ${p.className}"`); + } + + // id invariant (SignalRangePlugin keys settings and locales by this.id). + const idRe = new RegExp(`id\\s*=\\s*['"]${p.id}['"]`, 'u'); + + if (!idRe.test(src)) { + warnings.push(`[${p.id}] could not confirm "readonly id = '${p.id}'" in ${p.entry}; the class id MUST equal its manifest id`); + } +} diff --git a/scripts/plugin/lib/prompt.ts b/scripts/plugin/lib/prompt.ts new file mode 100644 index 00000000..8cc42a5d --- /dev/null +++ b/scripts/plugin/lib/prompt.ts @@ -0,0 +1,22 @@ +import { createInterface } from 'node:readline/promises'; + +async function ask(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + + try { + return (await rl.question(question)).trim(); + } finally { + rl.close(); + } +} + +/** Free-text prompt. Returns `def` immediately when stdin is not a TTY (CI). */ +export async function promptText(label: string, def = ''): Promise { + if (!process.stdin.isTTY) { + return def; + } + + const answer = await ask(`${label}${def ? ` (${def})` : ''}: `); + + return answer || def; +} diff --git a/scripts/plugin/lib/render-template.ts b/scripts/plugin/lib/render-template.ts new file mode 100644 index 00000000..bba3ef10 --- /dev/null +++ b/scripts/plugin/lib/render-template.ts @@ -0,0 +1,144 @@ +import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { resolveWithin } from './safe-path'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +export const TEMPLATES_DIR = resolve(HERE, '..', 'templates'); +// The destination ultimately derives from a CLI argument (create/export), so +// every scaffold write is confined to the workspace that contains this repo: +// `create` targets src/plugins-external/ inside the repo, `export` a sibling repo. +const WORKSPACE_ROOT = resolve(HERE, '..', '..', '..', '..'); + +export interface TemplateVars { + readonly PLUGIN_PKG: string; + readonly CLASS_NAME: string; + readonly PLUGIN_ID: string; + readonly DISPLAY_NAME: string; + readonly ELEMENT_BASE: string; + readonly ANTENNA_ID: string; + readonly ENGINE_RANGE: string; + readonly ENGINE_REF: string; + readonly DESCRIPTION: string; + readonly YEAR: string; + readonly AUTHOR: string; + /** Install URL shown in the README; a placeholder for scaffolds, the real repo for the export. */ + readonly REPO_URL: string; + /** Manifest `repository` field; empty for scaffolds until the author publishes. */ + readonly REPOSITORY: string; +} + +const PKG_PREFIX = 'signal-range-plugin-'; + +function words(kebab: string): string[] { + return kebab.split('-').filter(Boolean); +} + +export function pascalCase(kebab: string): string { + return words(kebab) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(''); +} + +export function titleCase(kebab: string): string { + return words(kebab) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(' '); +} + +export function upperSnakeCase(kebab: string): string { + return words(kebab) + .map((s) => s.toUpperCase()) + .join('_'); +} + +/** Normalize user input to a kebab base name, dropping the package prefix if given. */ +export function kebabCase(input: string): string { + const lower = input.trim().toLowerCase(); + const stripped = lower.startsWith(PKG_PREFIX) ? lower.slice(PKG_PREFIX.length) : lower; + + return stripped.replaceAll(/[^a-z0-9]+/gu, '-').replace(/^-+|-+$/gu, ''); +} + +function substitute(content: string, vars: TemplateVars): string { + let out = content; + + for (const [key, value] of Object.entries(vars)) { + out = out.replaceAll(`__${key}__`, value); + } + + return out; +} + +/** Map a template-relative path to its rendered destination path. */ +function targetRelPath(templateRel: string): string { + const segments = templateRel.split(/[\\/]/u); + const mapped = segments.map((seg) => (seg === 'github' ? '.github' : seg)); + const last = mapped.length - 1; + const base = mapped[last]; + + mapped[last] = base === 'gitignore.tmpl' ? '.gitignore' : base.replace(/\.tmpl$/u, ''); + + return mapped.join('/'); +} + +function renderTree(srcDir: string, destDir: string, vars: TemplateVars, rel = ''): void { + // Dirent types come from the directory read itself, so no stat-then-read gap + for (const entry of readdirSync(join(srcDir, rel), { withFileTypes: true })) { + const childRel = rel ? `${rel}/${entry.name}` : entry.name; + const abs = join(srcDir, childRel); + + if (entry.isDirectory()) { + renderTree(srcDir, destDir, vars, childRel); + continue; + } + + // targetRelPath is derived from template file names; confine the write to + // destDir so a template path can never escape the scaffold directory. + const destAbs = resolveWithin(destDir, targetRelPath(childRel)); + + mkdirSync(dirname(destAbs), { recursive: true }); + + // Binary assets are copied verbatim; text templates are substituted. + if (childRel.endsWith('.tmpl')) { + writeFileSync(destAbs, substitute(readFileSync(abs, 'utf8'), vars), 'utf8'); + } else { + copyFileSync(abs, destAbs); + } + } +} + +/** Render the full plugin skeleton into `destDir`. */ +export function renderPlugin(destDir: string, vars: TemplateVars): void { + // destDir traces back to a CLI argument: validate it lands inside the + // workspace before creating anything, then thread the validated path through. + const safeDest = resolveWithin(WORKSPACE_ROOT, destDir); + + mkdirSync(safeDest, { recursive: true }); + renderTree(TEMPLATES_DIR, safeDest, vars); +} + +/** Build template vars for a plugin from its kebab base name + metadata. */ +export function buildVars(base: string, hostVersion: string, opts: { readonly description: string; readonly author: string; readonly repository?: string }): TemplateVars { + const [majorText, minorText] = hostVersion.split('.'); + const major = Number.parseInt(majorText, 10); + const minor = Number.parseInt(minorText ?? '0', 10) || 0; + const pascal = pascalCase(base); + + return { + PLUGIN_PKG: `${PKG_PREFIX}${base}`, + CLASS_NAME: `${pascal}Plugin`, + PLUGIN_ID: `${pascal}Plugin`, + DISPLAY_NAME: titleCase(base), + ELEMENT_BASE: base, + ANTENNA_ID: `${upperSnakeCase(base)}_ANTENNA`, + ENGINE_RANGE: `>=${major}.${minor}.0 <${major + 1}.0.0`, + ENGINE_REF: `v${hostVersion}`, + DESCRIPTION: opts.description, + YEAR: String(new Date().getFullYear()), + AUTHOR: opts.author, + REPO_URL: opts.repository ?? `https://github.com/YOU/${PKG_PREFIX}${base}`, + REPOSITORY: opts.repository ?? '', + }; +} diff --git a/scripts/plugin/lib/resolve-plugin.ts b/scripts/plugin/lib/resolve-plugin.ts new file mode 100644 index 00000000..89e1dd36 --- /dev/null +++ b/scripts/plugin/lib/resolve-plugin.ts @@ -0,0 +1,83 @@ +import { existsSync, readdirSync, statSync } from 'node:fs'; +import { join } from 'node:path'; +import { builtinIds } from './builtin-ids'; +import { readLockfile } from './lockfile'; +import { CliError } from './log'; +import { EXTERNAL_DIR, pluginDir } from './paths'; +import { readManifest } from './plugin-meta'; +import type { SignalRangePluginManifest } from './types'; + +export interface ResolvedExternal { + readonly kind: 'external'; + readonly name: string; + readonly dir: string; + readonly manifest: SignalRangePluginManifest; + /** The id the user asked for (or the first one in the manifest). */ + readonly id: string; + /** Dependencies of the matched plugin entry. */ + readonly dependencies: readonly string[]; +} + +export interface ResolvedBuiltin { + readonly kind: 'builtin'; + readonly id: string; +} + +export type ResolvedPlugin = ResolvedExternal | ResolvedBuiltin; + +/** All installed external-plugin directory names (lockfile ∪ disk). */ +export function installedNames(): string[] { + const fromLock = Object.keys(readLockfile().plugins); + const fromDisk = existsSync(EXTERNAL_DIR) ? readdirSync(EXTERNAL_DIR).filter((n) => !n.startsWith('.') && statSync(join(EXTERNAL_DIR, n)).isDirectory()) : []; + + return [...new Set([...fromLock, ...fromDisk])]; +} + +/** + * Resolve a user-supplied name (an external package dir name, any external + * plugin id, or a built-in id) to a concrete target for dev / doctor / test. + */ +export function resolvePlugin(name: string): ResolvedPlugin { + for (const pkg of installedNames()) { + const dir = pluginDir(pkg); + + if (!existsSync(dir)) { + continue; + } + + let manifest: SignalRangePluginManifest; + + try { + manifest = readManifest(dir); + } catch { + continue; + } + + const plugins = Array.isArray(manifest.plugins) ? manifest.plugins : []; + const entry = plugins.find((p) => p.id === name); + + if (pkg === name || entry) { + const matched = entry ?? plugins[0]; + + if (!matched) { + throw new CliError(`"${pkg}" declares no plugins in its manifest.`); + } + + return { kind: 'external', name: pkg, dir, manifest, id: matched.id, dependencies: matched.dependencies ?? [] }; + } + } + + if (builtinIds().has(name)) { + return { kind: 'builtin', id: name }; + } + + throw new CliError(`Unknown plugin "${name}".\n${suggest(name)}`); +} + +function suggest(name: string): string { + const candidates = [...builtinIds(), ...installedNames()]; + const lower = name.toLowerCase(); + const near = candidates.filter((c) => c.toLowerCase().includes(lower) || lower.includes(c.toLowerCase())).slice(0, 5); + + return near.length > 0 ? `Did you mean: ${near.join(', ')}?` : 'Run "pnpm run plugin -- list" to see installed plugins.'; +} diff --git a/scripts/plugin/lib/run.ts b/scripts/plugin/lib/run.ts new file mode 100644 index 00000000..c39dc728 --- /dev/null +++ b/scripts/plugin/lib/run.ts @@ -0,0 +1,22 @@ +import { type ChildProcess, type SpawnOptions, type SpawnSyncOptions, type SpawnSyncReturns, spawn, spawnSync } from 'node:child_process'; + +const IS_WIN = process.platform === 'win32'; + +function quoteArg(arg: string): string { + return /[\s"]/u.test(arg) ? `"${arg.replaceAll('"', '\\"')}"` : arg; +} + +/** + * Run a package-manager shim (`npx`, `pnpm`). On Windows those are .cmd files + * that only a shell can start; passing one pre-joined command string (instead + * of an args array with `shell: true`) keeps Node 24's DEP0190 warning quiet. + * Real executables (git, taskkill) do not need this and should use spawn directly. + */ +export function runShimSync(cmd: string, args: readonly string[], opts: SpawnSyncOptions): SpawnSyncReturns { + return IS_WIN ? spawnSync([cmd, ...args].map(quoteArg).join(' '), { ...opts, shell: true }) : spawnSync(cmd, [...args], opts); +} + +/** Async twin of {@link runShimSync} for long-running processes (the dev server). */ +export function runShim(cmd: string, args: readonly string[], opts: SpawnOptions): ChildProcess { + return IS_WIN ? spawn([cmd, ...args].map(quoteArg).join(' '), { ...opts, shell: true }) : spawn(cmd, [...args], opts); +} diff --git a/scripts/plugin/lib/safe-path.ts b/scripts/plugin/lib/safe-path.ts new file mode 100644 index 00000000..649cb44b --- /dev/null +++ b/scripts/plugin/lib/safe-path.ts @@ -0,0 +1,44 @@ +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +/** + * Resolve `segments` against `baseDir` and assert the result stays inside + * `baseDir`. + * + * These scripts are run from the CLI (and by agentic tooling), so any path + * derived from `process.argv` must be confined to a known-safe root before it + * reaches the filesystem; otherwise a faulty argument like `../../..` or an + * absolute system path could read, create, or delete files outside the + * intended directory. Returns the validated absolute path; throws on escape. + * + * @param baseDir Allowed root. The returned path is guaranteed to be `baseDir` + * itself or a descendant of it. + * @param segments Path segments (typically CLI-supplied) to join onto the base. + */ +export function resolveWithin(baseDir: string, ...segments: string[]): string { + const base = resolve(baseDir); + const target = resolve(base, ...segments); + const rel = relative(base, target); + + if (rel !== '' && (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel))) { + throw new Error(`Refusing path outside "${base}": "${target}".`); + } + + return target; +} + +/** + * Like {@link resolveWithin} but additionally forbids the base directory itself, + * so the result is always a strict descendant. Use this for destructive or + * create-then-populate roots (e.g. `rmSync`) where operating on the base itself + * would be catastrophic. + */ +export function resolveStrictlyWithin(baseDir: string, ...segments: string[]): string { + const base = resolve(baseDir); + const target = resolveWithin(base, ...segments); + + if (target === base) { + throw new Error(`Refusing to operate on the allowed root itself: "${base}".`); + } + + return target; +} diff --git a/scripts/plugin/lib/types.ts b/scripts/plugin/lib/types.ts new file mode 100644 index 00000000..7efd956d --- /dev/null +++ b/scripts/plugin/lib/types.ts @@ -0,0 +1,61 @@ +/** + * Shared types for the plugin CLI. The on-disk `signal-range-plugin.json` + * schema and the `external-plugins.json` lockfile schema live here. + */ + +/** Parsed `--flag` values from the command line. */ +export type Flags = Record; + +/** One plugin entry inside a repo's signal-range-plugin.json `plugins` array. */ +export interface PluginEntry { + /** Plugin id. MUST equal the plugin class's `readonly id`. */ + readonly id: string; + /** Named export in `entry` to instantiate. */ + readonly className: string; + /** Repo-relative path to the entry module (explicit extension, POSIX separators). */ + readonly entry: string; + /** Default configuration folded into SettingsManager.settings.plugins. */ + readonly defaultConfig: { readonly enabled: boolean; readonly order?: number }; + /** Ids of plugins this plugin needs (dev harness strict-list boot set). */ + readonly dependencies?: readonly string[]; + /** When true, always enabled regardless of user config. */ + readonly alwaysEnabled?: boolean; +} + +/** What a plugin contributes to the engine's registries (informational). */ +export interface PluginProvides { + /** Antenna registry ids the plugin registers. */ + readonly antennas?: readonly string[]; +} + +/** A repo's signal-range-plugin.json manifest. */ +export interface SignalRangePluginManifest { + readonly formatVersion: number; + readonly name: string; + readonly version: string; + readonly description?: string; + readonly author?: string; + readonly repository?: string; + /** Semver range against the host package.json version. */ + readonly engine: string; + readonly plugins: readonly PluginEntry[]; + /** Folder holding `en.json` (default "locales"). */ + readonly localesDir?: string; + readonly provides?: PluginProvides; +} + +/** One installed plugin recorded in external-plugins.json. */ +export interface LockEntry { + readonly url: string; + readonly ref: string; + readonly commit: string; + readonly installedAt: string; + /** True for `create`/local-path installs: restore warns instead of cloning. */ + readonly local?: boolean; +} + +/** The external-plugins.json lockfile. */ +export interface Lockfile { + readonly formatVersion: number; + readonly plugins: Record; +} diff --git a/scripts/plugin/templates/LICENSE.tmpl b/scripts/plugin/templates/LICENSE.tmpl new file mode 100644 index 00000000..a5da5855 --- /dev/null +++ b/scripts/plugin/templates/LICENSE.tmpl @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) __YEAR__ __AUTHOR__ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/scripts/plugin/templates/README.md.tmpl b/scripts/plugin/templates/README.md.tmpl new file mode 100644 index 00000000..ec9fe264 --- /dev/null +++ b/scripts/plugin/templates/README.md.tmpl @@ -0,0 +1,54 @@ +# __PLUGIN_PKG__ + +__DESCRIPTION__ + +A plugin for [SignalRange](https://github.com/thkruz/SignalRange). It registers +one antenna (`__ANTENNA_ID__`) that shows up in every sandbox loadout. + +## Develop + +The plugin needs the SignalRange engine to compile against. Two common setups: + +**A. Develop inside a SignalRange checkout (recommended)** + +```bash +git clone https://github.com/thkruz/SignalRange +cd SignalRange +pnpm install +pnpm run plugin -- create my-plugin # scaffolds src/plugins-external/signal-range-plugin-my-plugin +pnpm run plugin -- dev __PLUGIN_ID__ # boots a sandbox with only this plugin, live-reload +``` + +Edit the files under `src/plugins-external/__PLUGIN_PKG__/`, then push them to +their own git repo (`git remote add origin …` from inside the plugin folder). + +**B. Develop standalone** + +Clone this repo next to a `SignalRange` checkout (so the `../SignalRange` paths +in `tsconfig.json` resolve), or clone SignalRange into `.signal-range/`. Then +`pnpm install` here for editor type-checking. + +## Install (for users) + +```bash +# From a SignalRange checkout: +pnpm run plugin -- add __REPO_URL__ +pnpm run build # or restart `pnpm run dev` +``` + +## Try it + +Open any campaign sandbox, click **LOADOUT** in the command bar, and pick the +antenna. To skip the picker, append `?antenna=__ANTENNA_ID__` to the sandbox URL. + +## The two hard rules + +1. The entry (`src/plugin.ts`) must have a **named export** matching `className` + in `signal-range-plugin.json` (`__CLASS_NAME__`). +2. The class's `readonly id` must **equal** the manifest's plugin `id` + (`__PLUGIN_ID__`). + +## License + +MIT. Note: SignalRange itself is AGPL-3.0. A plugin compiled into a SignalRange +build becomes part of that combined work, so distribute accordingly. diff --git a/scripts/plugin/templates/__tests__/plugin.test.ts.tmpl b/scripts/plugin/templates/__tests__/plugin.test.ts.tmpl new file mode 100644 index 00000000..dcf32e47 --- /dev/null +++ b/scripts/plugin/templates/__tests__/plugin.test.ts.tmpl @@ -0,0 +1,83 @@ +import { createAntenna } from '@app/equipment/antenna/antenna-factory'; +import { AntennaRegistry } from '@app/equipment/antenna/antenna-registry'; +import { addLocaleBundle, resetLocales, t7e } from '@app/locales/i18n'; +import type { PluginApi } from '@app/plugins/signal-range-plugin'; +import { vi } from 'vitest'; +import en from '../locales/en.json'; +import { ANTENNA_ID } from '../src/antenna-config'; +import { __CLASS_NAME__ } from '../src/plugin'; + +// The antenna core subscribes to the simulation loop; stub it like the engine's own antenna tests do. +vi.mock('@app/simulation/simulation-manager', () => ({ + SimulationManager: { + getInstance: vi.fn(() => ({ + update: vi.fn(), + draw: vi.fn(), + sync: vi.fn(), + getSatByNoradId: vi.fn(), + getSatsByAzEl: () => [], + satellites: [], + })), + destroy: vi.fn(), + }, +})); + +function fakeApi(): PluginApi { + return { + engineVersion: '0.0.0-test', + isPrivate: false, + isAuthoring: false, + antennas: AntennaRegistry.getInstance(), + t7e, + router: null, + campaigns: null, + settings: null, + events: null, + } as unknown as PluginApi; +} + +describe('__CLASS_NAME__', () => { + beforeEach(() => { + AntennaRegistry.destroy(); + resetLocales(); + addLocaleBundle('en', en); + document.body.innerHTML = '
'; + }); + + afterEach(() => { + AntennaRegistry.destroy(); + resetLocales(); + vi.restoreAllMocks(); + }); + + it('has the id the manifest declares', () => { + expect(new __CLASS_NAME__().id).toBe('__PLUGIN_ID__'); + }); + + it('registers its antenna under the plugin source', () => { + new __CLASS_NAME__().register(fakeApi()); + + const entry = AntennaRegistry.getInstance().find(ANTENNA_ID); + + expect(entry?.source).toBe('plugin:__PLUGIN_ID__'); + expect(entry?.config.name).toBe(en.plugins.__PLUGIN_ID__.antennaName); + }); + + it('builds a working headless antenna from the registered config', () => { + new __CLASS_NAME__().register(fakeApi()); + + const antenna = createAntenna('test-parent', 'headless', ANTENNA_ID); + + expect(antenna.config.band).toBe('Ka'); + expect(antenna.config.diameter).toBe(4.5); + }); + + it('removes its antenna on dispose', () => { + const plugin = new __CLASS_NAME__(); + + plugin.register(fakeApi()); + plugin.dispose(); + + expect(AntennaRegistry.getInstance().has(ANTENNA_ID)).toBe(false); + }); +}); diff --git a/scripts/plugin/templates/github/workflows/ci.yml.tmpl b/scripts/plugin/templates/github/workflows/ci.yml.tmpl new file mode 100644 index 00000000..c8f2dc6e --- /dev/null +++ b/scripts/plugin/templates/github/workflows/ci.yml.tmpl @@ -0,0 +1,38 @@ +name: CI + +on: [push, pull_request] + +jobs: + check: + runs-on: ubuntu-latest + steps: + # Check out the SignalRange engine at this plugin's minimum supported version. + - name: Checkout SignalRange + uses: actions/checkout@v4 + with: + repository: thkruz/SignalRange + ref: __ENGINE_REF__ + + # Check out this plugin into the engine's external-plugins folder. + - name: Checkout plugin + uses: actions/checkout@v4 + with: + path: src/plugins-external/__PLUGIN_PKG__ + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile --ignore-scripts + + # Validate + wire the plugin into the manifest (local add: no clone). + - run: pnpm run plugin -- add ./src/plugins-external/__PLUGIN_PKG__ --yes + + - name: Typecheck (host + plugin) + run: pnpm run typecheck + + - name: Test plugin + run: pnpm run plugin -- test __PLUGIN_PKG__ diff --git a/scripts/plugin/templates/gitignore.tmpl b/scripts/plugin/templates/gitignore.tmpl new file mode 100644 index 00000000..8cbf10b0 --- /dev/null +++ b/scripts/plugin/templates/gitignore.tmpl @@ -0,0 +1,4 @@ +node_modules/ +.signal-range/ +*.log +.DS_Store diff --git a/scripts/plugin/templates/locales/en.json.tmpl b/scripts/plugin/templates/locales/en.json.tmpl new file mode 100644 index 00000000..ced0effd --- /dev/null +++ b/scripts/plugin/templates/locales/en.json.tmpl @@ -0,0 +1,7 @@ +{ + "plugins": { + "__PLUGIN_ID__": { + "antennaName": "__DISPLAY_NAME__ 4.5m Ka-Band" + } + } +} diff --git a/scripts/plugin/templates/package.json.tmpl b/scripts/plugin/templates/package.json.tmpl new file mode 100644 index 00000000..dff27b4b --- /dev/null +++ b/scripts/plugin/templates/package.json.tmpl @@ -0,0 +1,15 @@ +{ + "name": "__PLUGIN_PKG__", + "version": "0.1.0", + "description": "__DESCRIPTION__", + "private": true, + "type": "module", + "keywords": ["signal-range", "signal-range-plugin"], + "license": "MIT", + "scripts": { + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/scripts/plugin/templates/signal-range-plugin.json.tmpl b/scripts/plugin/templates/signal-range-plugin.json.tmpl new file mode 100644 index 00000000..4f6b227a --- /dev/null +++ b/scripts/plugin/templates/signal-range-plugin.json.tmpl @@ -0,0 +1,20 @@ +{ + "formatVersion": 1, + "name": "__PLUGIN_PKG__", + "version": "0.1.0", + "description": "__DESCRIPTION__", + "author": "__AUTHOR__", + "repository": "__REPOSITORY__", + "engine": "__ENGINE_RANGE__", + "plugins": [ + { + "id": "__PLUGIN_ID__", + "className": "__CLASS_NAME__", + "entry": "src/plugin.ts", + "defaultConfig": { "enabled": true, "order": 500 }, + "dependencies": [] + } + ], + "localesDir": "locales", + "provides": { "antennas": ["__ANTENNA_ID__"] } +} diff --git a/scripts/plugin/templates/src/antenna-config.ts.tmpl b/scripts/plugin/templates/src/antenna-config.ts.tmpl new file mode 100644 index 00000000..7cb30980 --- /dev/null +++ b/scripts/plugin/templates/src/antenna-config.ts.tmpl @@ -0,0 +1,40 @@ +import type { AntennaConfig } from '@app/equipment/antenna/antenna-configs'; +import type { Hertz } from '@app/types'; + +/** Registry id. Upper snake case, unique across built-ins and installed plugins. */ +export const ANTENNA_ID = '__ANTENNA_ID__'; + +/** + * A 4.5 m Ka-band dish. Distinct band and size from every built-in so it is + * obvious in the loadout picker that the plugin is the source. Every field is + * documented on AntennaConfig in the engine. + */ +export const antennaConfig: AntennaConfig = { + name: '__DISPLAY_NAME__ 4.5m Ka-Band', + diameter: 4.5, + efficiency: 0.65, + band: 'Ka', + minRxFrequency: 17.7e9 as Hertz, + maxRxFrequency: 21.2e9 as Hertz, + minTxFrequency: 27.5e9 as Hertz, + maxTxFrequency: 31.0e9 as Hertz, + feedLoss: 0.5, + surfaceRms_m: 0.0003, + blockageFraction: 0.07, + xpd_dB: 30, + polType: 'circular', + feedLossModel: { a: 0.2, b: 0.1, c: 0.012 }, + kBeamConst: 70, + patternModel: 'ITU465', + pointingSigma_deg: 0.02, + elRange_deg: [5, 90], + azContinuous: false, + azRange_deg: [-180, 540], + maxRate_deg_s: 3.0, + windDePointingCoef_deg_per_mps: 0.003, + lnaNF_dB: 1.2, + rxChainLoss_dB: 0.5, + rxPhysTemp_K: 290, + acuModel: '__DISPLAY_NAME__ ACU', + acuSerialNumber: 'ACU-__ELEMENT_BASE__-01', +}; diff --git a/scripts/plugin/templates/src/plugin.ts.tmpl b/scripts/plugin/templates/src/plugin.ts.tmpl new file mode 100644 index 00000000..038e72d1 --- /dev/null +++ b/scripts/plugin/templates/src/plugin.ts.tmpl @@ -0,0 +1,28 @@ +/** + * __DISPLAY_NAME__: a SignalRange plugin. + * + * This file is the plugin's entry point. It MUST export a class named + * `__CLASS_NAME__` whose `readonly id` equals `__PLUGIN_ID__`; both are declared + * in signal-range-plugin.json and the host wires the plugin in by those names. + * + * The stable surface you can rely on within a major version: the + * SignalRangePlugin base class, PluginApi, the antenna registry, the router + * and campaign manager it exposes, and t7e. See docs/plugin-development-guide.md. + */ +import { AntennaRegistry } from '@app/equipment/antenna/antenna-registry'; +import { type PluginApi, SignalRangePlugin } from '@app/plugins/signal-range-plugin'; +import { ANTENNA_ID, antennaConfig } from './antenna-config'; + +export class __CLASS_NAME__ extends SignalRangePlugin { + readonly id = '__PLUGIN_ID__'; + + register(api: PluginApi): void { + // Strings live in locales/en.json under plugins.__PLUGIN_ID__ and are + // merged before register() runs, so t7e already knows them here. + api.antennas.register(ANTENNA_ID, { ...antennaConfig, name: api.t7e('plugins.__PLUGIN_ID__.antennaName') }, { source: `plugin:${this.id}` }); + } + + dispose(): void { + AntennaRegistry.getInstance().unregister(ANTENNA_ID); + } +} diff --git a/scripts/plugin/templates/tsconfig.json.tmpl b/scripts/plugin/templates/tsconfig.json.tmpl new file mode 100644 index 00000000..9075d2fb --- /dev/null +++ b/scripts/plugin/templates/tsconfig.json.tmpl @@ -0,0 +1,22 @@ +{ + "//": "Editor IntelliSense against the SignalRange engine. Point the paths below at a", + "//2": "sibling checkout of SignalRange (or a gitignored .signal-range/ clone).", + "//3": "The host build ignores this file; it compiles the plugin with the host's own tsconfig.", + "compilerOptions": { + "target": "es2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": false, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "baseUrl": ".", + "paths": { + "@app/*": ["../SignalRange/src/*", "../signal-range/src/*", ".signal-range/src/*"], + "@engine/*": ["../SignalRange/src/engine/*", "../signal-range/src/engine/*", ".signal-range/src/engine/*"] + } + }, + "include": ["src/**/*.ts", "__tests__/**/*.ts"] +} diff --git a/scripts/vo-manifest.mjs b/scripts/vo-manifest.mjs new file mode 100644 index 00000000..c4b37588 --- /dev/null +++ b/scripts/vo-manifest.mjs @@ -0,0 +1,290 @@ +/** + * nats-eu voice-over manifest (phase 16, engine item E4). + * + * Dialog clips for Campaign 2 are authored as text with `audioUrl: ''` and the + * recordings follow later. This script keeps the two in step without anyone + * hand-editing scenario files: + * + * node scripts/vo-manifest.mjs -> writes nats-eu-vo-manifest.json + * node scripts/vo-manifest.mjs --apply -> also rewrites `audioUrl` for every + * clip whose mp3 exists on disk + * node scripts/vo-manifest.mjs --script -> also writes one recording script per + * character, -vo-script-.md + * node scripts/vo-manifest.mjs --campaign nats --out my.json + * + * The manifest lists every clip (scenario, key, character, emotion, plain text + * and HTML, target asset path, whether the file exists) so a recording session + * has a script and a file list. `--script` renders the same clips as one + * Markdown file per character, in scenario order, with the emotion and target + * file name above each line, which is what a voice actor or a TTS batch reads. + * Both outputs are gitignored (`*-vo-manifest.json`, `*-vo-script-*.md`); + * regenerate them from the scenario files. Target paths follow the Campaign 1 convention: + * `assets/campaigns///intro.mp3` and `.../obj-.mp3`. + * + * `--apply` edits only the `audioUrl` initializer of a clip whose target file is + * present under public/, replacing it with `getAssetUrl('/')`, and adds + * the `getAssetUrl` import when the file lacks it. Clips whose file is missing + * are left as they are, so a partially recorded scenario still renders text. + * Edits are made from TypeScript AST positions, so the surrounding formatting is + * untouched (the authoring editor round-trips these files byte for byte). + */ +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +const scriptsDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(scriptsDir, '..'); + +const args = process.argv.slice(2); +const flag = (name) => args.includes(name); +const opt = (name, fallback) => { + const i = args.indexOf(name); + return i >= 0 && args[i + 1] ? args[i + 1] : fallback; +}; + +const campaign = opt('--campaign', 'nats-eu'); +const apply = flag('--apply'); +const script = flag('--script'); +const outPath = opt('--out', join(repoRoot, `${campaign}-vo-manifest.json`)); + +const campaignDir = join(repoRoot, 'src', 'campaigns', campaign); +const publicDir = join(repoRoot, 'public'); + +/** Display names from character-enum.ts, e.g. CHARLIE_BROOKS -> "Charlie Brooks". */ +function loadCharacterNames() { + const text = readFileSync(join(repoRoot, 'src', 'modal', 'character-enum.ts'), 'utf8'); + const start = text.indexOf('export const CharacterNames'); + const block = text.slice(start, text.indexOf('};', start)); + const names = {}; + for (const m of block.matchAll(/\[Character\.([A-Z_]+)\]:\s*'([^']*)'/g)) { + names[m[1]] = m[2]; + } + return names; +} + +const characterNames = loadCharacterNames(); + +function scenarioFiles() { + return readdirSync(campaignDir) + .filter((f) => /^scenario\d+\.ts$/.test(f)) + .sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])) + .map((f) => join(campaignDir, f)); +} + +function stripHtml(html) { + let text = html.replace(//gi, '\n').replace(/<\/p>/gi, '\n'); + // One pass can reassemble a tag ('ipt>'); repeat until none remain, + // then drop any stray angle bracket + let prev; + do { + prev = text; + text = text.replace(/<[^>]*>/g, ''); + } while (text !== prev); + return text + .replace(/[<>]/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/^[ \t]+/gm, '') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +/** Property name of an object-literal member as a plain string. */ +function propName(prop) { + const n = prop.name; + if (!n) return undefined; + if (ts.isIdentifier(n) || ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return n.text; + return undefined; +} + +function findProp(obj, name) { + return obj.properties.find((p) => ts.isPropertyAssignment(p) && propName(p) === name); +} + +function literalText(node) { + if (!node) return undefined; + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; + if (ts.isTemplateExpression(node)) { + // Template with substitutions: keep the raw source between the backticks. + return node.getText().slice(1, -1); + } + return undefined; +} + +function enumMember(node) { + // Character.X / Emotion.Y + if (node && ts.isPropertyAccessExpression(node)) return node.name.text; + return node ? node.getText() : undefined; +} + +function audioUrlLiteral(node) { + if (!node) return ''; + if (ts.isStringLiteral(node)) return node.text; + if (ts.isCallExpression(node) && node.arguments[0] && ts.isStringLiteral(node.arguments[0])) { + return node.arguments[0].text; // getAssetUrl('/assets/...') + } + return node.getText(); +} + +/** + * Walk one scenario file and return its clips plus the AST handles needed for + * --apply. + */ +function readScenario(file) { + const source = readFileSync(file, 'utf8'); + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const rel = relative(repoRoot, file).replace(/\\/g, '/'); + const number = Number(file.match(/scenario(\d+)\.ts$/)[1]); + + let dialogClips; + let scenarioId; + let scenarioTitle; + const visit = (node) => { + if (ts.isPropertyAssignment(node)) { + const name = propName(node); + if (name === 'dialogClips' && ts.isObjectLiteralExpression(node.initializer)) dialogClips = node.initializer; + if (name === 'id' && !scenarioId && ts.isStringLiteral(node.initializer) && node.initializer.text.startsWith(campaign)) { + scenarioId = node.initializer.text; + } + if (name === 'title' && !scenarioTitle && ts.isStringLiteral(node.initializer)) scenarioTitle = node.initializer.text; + } + ts.forEachChild(node, visit); + }; + visit(sf); + + const clips = []; + const pushClip = (key, obj) => { + if (!obj || !ts.isObjectLiteralExpression(obj)) return; + const textNode = findProp(obj, 'text')?.initializer; + const audioProp = findProp(obj, 'audioUrl'); + const characterKey = enumMember(findProp(obj, 'character')?.initializer); + const html = literalText(textNode) ?? ''; + const fileName = key === 'intro' ? 'intro.mp3' : `obj-${key}.mp3`; + const target = `assets/campaigns/${campaign}/${number}/${fileName}`; + clips.push({ + scenario: scenarioId ?? rel, + number, + key, + character: characterKey, + characterName: characterNames[characterKey] ?? characterKey, + emotion: enumMember(findProp(obj, 'emotion')?.initializer) ?? 'NEUTRAL', + text: stripHtml(html), + html: html.trim(), + target, + exists: existsSync(join(publicDir, target)), + audioUrl: audioUrlLiteral(audioProp?.initializer), + _audioProp: audioProp, + }); + }; + + if (dialogClips) { + pushClip('intro', findProp(dialogClips, 'intro')?.initializer); + const objectives = findProp(dialogClips, 'objectives')?.initializer; + if (objectives && ts.isObjectLiteralExpression(objectives)) { + for (const p of objectives.properties) { + if (ts.isPropertyAssignment(p)) pushClip(propName(p), p.initializer); + } + } + } + + return { file, rel, number, title: scenarioTitle ?? `Scenario ${number}`, source, sf, clips }; +} + +/** + * One Markdown recording script per character: every line that character + * speaks, in scenario order, with the emotion and the file the recording must + * be saved as. Returns what was written. + */ +function writeScripts(scenarios, dir) { + const byCharacter = new Map(); + for (const s of scenarios) { + for (const clip of s.clips) { + if (!clip.character || clip.character === 'SYSTEM') continue; + if (!byCharacter.has(clip.character)) byCharacter.set(clip.character, []); + byCharacter.get(clip.character).push({ scenario: s, clip }); + } + } + const written = []; + for (const [character, lines] of byCharacter) { + const name = characterNames[character] ?? character; + const slug = character.toLowerCase().replace(/_/g, '-'); + const words = lines.reduce((n, l) => n + l.clip.text.split(/\s+/).filter(Boolean).length, 0); + const out = [`# ${name} - ${campaign} recording script`, '']; + out.push(`${lines.length} clips, ${words} words. Generated by scripts/vo-manifest.mjs --script; regenerate, do not edit.`); + out.push('Save each take as the file named under its heading, under public/, then run `pnpm run vo-manifest --apply`.', ''); + let current; + for (const { scenario, clip } of lines) { + if (scenario !== current) { + current = scenario; + out.push(`## S${scenario.number} ${scenario.title}`, ''); + } + const heading = clip.key === 'intro' ? 'intro' : `obj-${clip.key}`; + out.push(`### ${heading} (${clip.emotion.toLowerCase()})`, '', `File: \`${clip.target}\``, '', clip.text, ''); + } + const path = join(dir, `${campaign}-vo-script-${slug}.md`); + writeFileSync(path, `${out.join('\n')}\n`, 'utf8'); + written.push({ path, name, clips: lines.length, words }); + } + return written; +} + +function applyUrls(scenario) { + const edits = []; + for (const clip of scenario.clips) { + const desired = `/${clip.target}`; + if (!clip.exists || clip.audioUrl === desired || !clip._audioProp) continue; + const init = clip._audioProp.initializer; + edits.push({ start: init.getStart(scenario.sf), end: init.getEnd(), text: `getAssetUrl('${desired}')` }); + } + if (edits.length === 0) return 0; + + let out = scenario.source; + for (const e of edits.sort((a, b) => b.start - a.start)) { + out = out.slice(0, e.start) + e.text + out.slice(e.end); + } + if (!/import \{[^}]*\bgetAssetUrl\b[^}]*\} from '@app\/utils\/asset-url'/.test(out)) { + // Insert after the last import line to keep Biome's import order happy enough. + const lines = out.split('\n'); + let last = -1; + for (let i = 0; i < lines.length; i++) if (/^import /.test(lines[i])) last = i; + lines.splice(last + 1, 0, "import { getAssetUrl } from '@app/utils/asset-url';"); + out = lines.join('\n'); + } + writeFileSync(scenario.file, out, 'utf8'); + return edits.length; +} + +const scenarios = scenarioFiles().map(readScenario); +const manifest = { + campaign, + generatedAt: new Date().toISOString(), + clipCount: scenarios.reduce((n, s) => n + s.clips.length, 0), + scenarios: scenarios.map((s) => ({ + file: s.rel, + number: s.number, + clips: s.clips.map(({ _audioProp, ...clip }) => clip), + })), +}; + +writeFileSync(outPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); + +const recorded = manifest.scenarios.flatMap((s) => s.clips).filter((c) => c.exists).length; +console.log(`${campaign}: ${manifest.clipCount} clips across ${scenarios.length} scenarios, ${recorded} recorded -> ${relative(repoRoot, outPath)}`); +for (const s of manifest.scenarios) { + const words = s.clips.reduce((n, c) => n + c.text.split(/\s+/).filter(Boolean).length, 0); + console.log(` S${String(s.number).padStart(2)} ${String(s.clips.length).padStart(2)} clips ${String(words).padStart(5)} words ${s.clips.filter((c) => c.exists).length} recorded`); +} + +if (script) { + for (const w of writeScripts(scenarios, dirname(outPath))) { + console.log(` ${w.name.padEnd(16)} ${String(w.clips).padStart(3)} clips ${String(w.words).padStart(5)} words -> ${relative(repoRoot, w.path)}`); + } +} + +if (apply) { + let total = 0; + for (const s of scenarios) total += applyUrls(s); + console.log(total === 0 ? 'apply: nothing to change' : `apply: rewrote ${total} audioUrl value(s)`); +} diff --git a/src/ScenarioData.ts b/src/ScenarioData.ts index 2c0862fc..1c77e5a4 100644 --- a/src/ScenarioData.ts +++ b/src/ScenarioData.ts @@ -1,7 +1,6 @@ import type { Objective } from './objectives'; import type { DialogClip, SimulationSettings } from './scenario-manager'; - export interface ScenarioData { id: string; isDisabled?: boolean; @@ -24,4 +23,9 @@ export interface ScenarioData { }; /** Optional scenario-wide time limit in seconds */ timeLimitSeconds?: number; + /** + * Seed for every random stream in the run. Defaults to FNV-1a of `id`; + * `?seed=` in the URL overrides both (see simulation/rng.ts). + */ + seed?: number; } diff --git a/src/analytics.ts b/src/analytics.ts new file mode 100644 index 00000000..0f1ac830 --- /dev/null +++ b/src/analytics.ts @@ -0,0 +1,179 @@ +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; + +/** + * GA4 measurement ID for app.signalrange.space. + * + * THIS IS THE ONE-LINE SWAP POINT: replace the placeholder with the real + * measurement ID (e.g. 'G-XXXXXXXXXX') and analytics goes live. While the + * placeholder is in place, nothing is loaded and nothing is sent. + */ +export const GA4_MEASUREMENT_ID: string = 'G-QNLH2DZXEK'; + +/** Sentinel meaning "not configured yet" — must never equal a real ID. */ +const PLACEHOLDER_ID = 'G-PLACEHOLDER'; + +/** + * Consent cookie shared with signalrange.space and docs.signalrange.space + * (public/sr-consent.js in signal-range-home and signal-range-docs). Keep the + * name, values and domain in step with that script. + */ +export const CONSENT_COOKIE = 'sr_analytics_consent'; +const ONE_YEAR_S = 60 * 60 * 24 * 365; +const PRIVACY_URL = 'https://signalrange.space/privacy'; + +type ConsentChoice = 'granted' | 'denied'; + +declare global { + interface Window { + dataLayer?: unknown[]; + } +} + +/** + * Opt-in GA4 gtag.js tagging for the SPA. + * + * Nothing loads until the visitor presses "Allow" on the consent banner (or + * allowed it earlier on any signalrange.space site). The app is client-side + * routed (see Router), so the automatic first page_view is suppressed + * (`send_page_view: false`) and one page_view is emitted per ROUTE_CHANGED + * event instead. The initial route also fires ROUTE_CHANGED during + * Router.init(), so call Analytics.init() BEFORE App.create(); the latest path + * is remembered so a page_view for it is sent the moment consent is granted. + */ +export class Analytics { + private static isInitialized_ = false; + private static gtag_: ((...args: unknown[]) => void) | null = null; + private static currentPath_: string | null = null; + private static banner_: HTMLDivElement | null = null; + + static init(): void { + if (Analytics.isInitialized_) { + return; + } + if (!GA4_MEASUREMENT_ID || GA4_MEASUREMENT_ID === PLACEHOLDER_ID) { + // Measurement ID not configured yet: stay completely dark. + return; + } + if (navigator.webdriver) { + // Playwright and other automation: no banner over the UI, no traffic counted. + return; + } + Analytics.isInitialized_ = true; + + EventBus.getInstance().on(Events.ROUTE_CHANGED, ({ path }) => { + Analytics.currentPath_ = path; + Analytics.sendPageView_(); + }); + + const choice = Analytics.readChoice_(); + if (choice === 'granted') { + Analytics.loadGtag_(); + } else if (choice === null) { + Analytics.showBanner_(); + } + } + + private static readChoice_(): ConsentChoice | null { + const match = /(?:^|;\s*)sr_analytics_consent=(granted|denied)/u.exec(document.cookie); + + return match ? (match[1] as ConsentChoice) : null; + } + + private static cookieDomain_(): string { + const host = globalThis.location.hostname; + + return host === 'signalrange.space' || host.endsWith('.signalrange.space') ? '; Domain=.signalrange.space' : ''; + } + + private static writeChoice_(choice: ConsentChoice): void { + const secure = globalThis.location.protocol === 'https:' ? '; Secure' : ''; + document.cookie = `${CONSENT_COOKIE}=${choice}; Max-Age=${ONE_YEAR_S}; Path=/${Analytics.cookieDomain_()}; SameSite=Lax${secure}`; + } + + private static loadGtag_(): void { + if (Analytics.gtag_) { + return; + } + window.dataLayer = window.dataLayer ?? []; + // gtag.js requires Arguments objects on the dataLayer, so this must be a + // plain function using `arguments`, not a rest-args push of an array. + const gtag = function gtag() { + // biome-ignore lint/complexity/noArguments: gtag.js requires a real Arguments object. + window.dataLayer!.push(arguments); + } as (...args: unknown[]) => void; + + const script = document.createElement('script'); + script.async = true; + script.src = `https://www.googletagmanager.com/gtag/js?id=${GA4_MEASUREMENT_ID}`; + document.head.appendChild(script); + + gtag('js', new Date()); + gtag('config', GA4_MEASUREMENT_ID, { + send_page_view: false, + allow_google_signals: false, + allow_ad_personalization_signals: false, + }); + Analytics.gtag_ = gtag; + Analytics.sendPageView_(); + } + + private static sendPageView_(): void { + if (!Analytics.gtag_ || Analytics.currentPath_ === null) { + return; + } + Analytics.gtag_('event', 'page_view', { + page_path: Analytics.currentPath_, + page_location: globalThis.location.href, + page_title: document.title, + }); + } + + private static showBanner_(): void { + const banner = document.createElement('div'); + banner.className = 'sr-consent-banner'; + banner.setAttribute('role', 'region'); + banner.setAttribute('aria-label', 'Analytics choice'); + banner.style.cssText = + 'position:fixed;left:16px;right:16px;bottom:16px;z-index:2147483000;max-width:560px;margin:0 auto;' + + 'padding:16px;border-radius:8px;background:#111827;color:#f3f4f6;border:1px solid #374151;' + + 'font:14px/1.5 system-ui,sans-serif;box-shadow:0 8px 24px rgba(0,0,0,.4)'; + + const text = document.createElement('p'); + text.style.cssText = 'margin:0 0 12px'; + text.textContent = 'May we use Google Analytics to count visits and see which pages are used? It stays off unless you allow it. '; + const link = document.createElement('a'); + link.href = PRIVACY_URL; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = 'Privacy policy'; + link.style.cssText = 'color:#93c5fd;text-decoration:underline'; + text.appendChild(link); + + const row = document.createElement('div'); + row.style.cssText = 'display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap'; + const choices: [string, ConsentChoice][] = [ + ['Decline', 'denied'], + ['Allow', 'granted'], + ]; + for (const [label, choice] of choices) { + const button = document.createElement('button'); + button.type = 'button'; + button.textContent = label; + button.style.cssText = 'min-width:96px;padding:8px 16px;border-radius:6px;border:1px solid #9ca3af;background:#1f2937;color:#f3f4f6;font:inherit;cursor:pointer'; + button.addEventListener('click', () => { + Analytics.writeChoice_(choice); + if (choice === 'granted') { + Analytics.loadGtag_(); + } + Analytics.banner_?.remove(); + Analytics.banner_ = null; + }); + row.appendChild(button); + } + + banner.append(text, row); + document.body.appendChild(banner); + Analytics.banner_ = banner; + } +} diff --git a/src/app.ts b/src/app.ts index 035e7f2d..ee9f8ab4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,16 +1,17 @@ import { BaseElement } from '@app/components/base-element'; import { getEl } from '@app/engine/utils/get-el'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; import { Body } from '@app/pages/layout/body/body'; import { Footer } from '@app/pages/layout/footer/footer'; import { Header } from '@app/pages/layout/header/header'; import { SandboxPage } from '@app/pages/sandbox-page'; import { ScenarioSelectionPage } from '@app/pages/scenario-selection'; -import { Router } from './router'; +import { PluginManager } from '@app/plugins/plugin-manager'; import { SimulationManager } from '@app/simulation/simulation-manager'; import { Auth } from '@app/user-account/auth'; import { initUserDataService } from '@app/user-account/user-data-service'; -import { EventBus } from '@app/events/event-bus'; -import { Events } from '@app/events/events'; +import { Router } from './router'; /** * Main Application Class @@ -38,7 +39,7 @@ export class App extends BaseElement { static create(): App { if (App.instance_) { - throw new Error("App instance already exists."); + throw new Error('App instance already exists.'); } this.instance_ = new App(); @@ -85,12 +86,22 @@ export class App extends BaseElement { SimulationManager.getInstance(); + // Start loading plugins before the router resolves the first route: a + // plugin may contribute routes or the antenna a sandbox loadout names. + // The router holds those routes until PluginManager.ready settles. + void PluginManager.getInstance().loadAll(); + // Initialize router this.router.init(); return rootDom; } + /** Installed plugins (for the dev menu, e2e specs, and the console). */ + get plugins(): PluginManager { + return PluginManager.getInstance(); + } + protected addEventListeners_(): void { document.addEventListener('contextmenu', (event) => event.preventDefault()); } @@ -111,4 +122,4 @@ export class App extends BaseElement { SimulationManager['instance_'] = null; App.instance_ = null; } -} \ No newline at end of file +} diff --git a/src/assets/ground-station/ground-station-factory.ts b/src/assets/ground-station/ground-station-factory.ts index b36d2014..04f77db4 100644 --- a/src/assets/ground-station/ground-station-factory.ts +++ b/src/assets/ground-station/ground-station-factory.ts @@ -1,5 +1,5 @@ -import { GroundStation } from "./ground-station"; -import type { GroundStationConfig } from "./ground-station-state"; +import { GroundStation } from './ground-station'; +import type { GroundStationConfig } from './ground-station-state'; /** * Factory function to create a GroundStation instance diff --git a/src/assets/ground-station/ground-station-state.ts b/src/assets/ground-station/ground-station-state.ts index b03a9e39..daf90d9f 100644 --- a/src/assets/ground-station/ground-station-state.ts +++ b/src/assets/ground-station/ground-station-state.ts @@ -1,16 +1,17 @@ -import type { AntennaState } from "@app/equipment/antenna"; -import type { RealTimeSpectrumAnalyzerState } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; -import type { ReceiverState } from "@app/equipment/receiver/receiver"; -import type { RFFrontEndState } from "@app/equipment/rf-front-end/rf-front-end-core"; -import type { TransmitterState } from "@app/equipment/transmitter/transmitter"; +import type { AntennaState } from '@app/equipment/antenna'; +import type { AntennaConfigId } from '@app/equipment/antenna/antenna-registry'; +import type { RealTimeSpectrumAnalyzerState } from '@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer'; +import type { ReceiverState } from '@app/equipment/receiver/receiver'; +import type { RFFrontEndState } from '@app/equipment/rf-front-end/rf-front-end-core'; +import type { TransmitterState } from '@app/equipment/transmitter/transmitter'; /** * Ground station location information */ export interface GroundStationLocation { - latitude: number; // degrees - longitude: number; // degrees - elevation: number; // meters above sea level + latitude: number; // degrees + longitude: number; // degrees + elevation: number; // meters above sea level } /** @@ -29,11 +30,13 @@ export interface GroundStationEquipmentState { */ export interface GroundStationState { uuid: string; - id: string; // "MIA-01" - name: string; // "Miami Ground Station" + id: string; // "MIA-01" + name: string; // "Miami Ground Station" location: GroundStationLocation; isOperational: boolean; equipment: Partial; + /** Station class (see GroundStationConfig.stationClass); undefined = professional */ + stationClass?: 'professional' | 'backyard'; } /** @@ -44,12 +47,24 @@ export interface GroundStationConfig { name: string; isOperational?: boolean; location: GroundStationLocation; - antennas: string[]; // Antenna config IDs - antennasState?: Partial[]; // Initial antenna states (parallel to antennas array) - rfFrontEnds: Partial[]; // RF front-end configs - spectrumAnalyzers?: Partial[]; // Spectrum analyzer configs (optional) + antennas: string[]; // Antenna config IDs + /** + * Antenna hardware config actually instantiated by mission control. + * Opt-in: when omitted the factory default is used, preserving legacy + * campaign behavior (the `antennas` array above is not applied there). + */ + antennaConfigKey?: AntennaConfigId; + antennasState?: Partial[]; // Initial antenna states (parallel to antennas array) + rfFrontEnds: Partial[]; // RF front-end configs + spectrumAnalyzers?: Partial[]; // Spectrum analyzer configs (optional) transmitters?: Partial[]; // Initial transmitter states (optional, parallel to transmitters) receivers?: Partial[]; // Initial receiver states (optional, parallel to receivers) teamId?: number; serverId?: number; + /** + * Station class. Opt-in: 'backyard' (Campaign 3+) enables the hobbyist SDR + * Console tab and hides the TX chain; when omitted the station renders as a + * professional site exactly as before. + */ + stationClass?: 'professional' | 'backyard'; } diff --git a/src/assets/ground-station/ground-station.ts b/src/assets/ground-station/ground-station.ts index b42122a2..12b52a57 100644 --- a/src/assets/ground-station/ground-station.ts +++ b/src/assets/ground-station/ground-station.ts @@ -1,16 +1,16 @@ -import { generateUuid } from "@app/engine/utils/uuid"; -import { ANTENNA_CONFIG_KEYS, AntennaCore } from "@app/equipment/antenna"; -import { AntennaUIHeadless } from "@app/equipment/antenna/antenna-ui-headless"; -import { RealTimeSpectrumAnalyzer } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; -import { Receiver } from "@app/equipment/receiver/receiver"; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; -import { createRFFrontEnd } from "@app/equipment/rf-front-end/rf-front-end-factory"; -import { Transmitter } from "@app/equipment/transmitter/transmitter"; -import { EventBus } from "@app/events/event-bus"; -import { EventMap, Events } from "@app/events/events"; -import { Logger } from "@app/logging/logger"; -import { SimulationManager } from "@app/simulation/simulation-manager"; -import type { GroundStationConfig, GroundStationState } from "./ground-station-state"; +import { generateUuid } from '@app/engine/utils/uuid'; +import { AntennaCore } from '@app/equipment/antenna'; +import { createAntenna } from '@app/equipment/antenna/antenna-factory'; +import { RealTimeSpectrumAnalyzer } from '@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer'; +import { Receiver } from '@app/equipment/receiver/receiver'; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; +import { createRFFrontEnd } from '@app/equipment/rf-front-end/rf-front-end-factory'; +import { Transmitter } from '@app/equipment/transmitter/transmitter'; +import { EventBus } from '@app/events/event-bus'; +import { EventMap, Events } from '@app/events/events'; +import { Logger } from '@app/logging/logger'; +import { SimulationManager } from '@app/simulation/simulation-manager'; +import type { GroundStationConfig, GroundStationState } from './ground-station-state'; /** * GroundStation - Manages a complete ground station with all equipment @@ -43,13 +43,14 @@ export class GroundStation { name: config.name, location: config.location, isOperational: config.isOperational ?? true, + stationClass: config.stationClass, equipment: { antennas: [], rfFrontEnds: [], spectrumAnalyzers: [], transmitters: [], - receivers: [] - } + receivers: [], + }, }; // NOTE: Equipment creation deferred until UI containers are ready @@ -89,39 +90,31 @@ export class GroundStation { // Create antennas (headless mode for mission control) config.antennas.forEach((antennaConfigId, index) => { const initialState = config.antennasState?.[index] ?? {}; - const antenna = new AntennaUIHeadless( - `gs-${this.uuid}-antenna${index + 1}-headless`, - antennaConfigId as ANTENNA_CONFIG_KEYS, - initialState, - config.teamId || 1 - ); + // Through the factory so a plugin antenna with its own core class is + // honoured; built-ins still get AntennaUIHeadless. + const antenna = createAntenna(`gs-${this.uuid}-antenna${index + 1}-headless`, 'headless', antennaConfigId, initialState, config.teamId || 1); + + // Terrestrial-emitter reception (E1) needs the station's geodetic + // position; without it the antenna hears ground emitters never + antenna.attachStationLocation(config.location.latitude, config.location.longitude, config.location.elevation); this.antennas.push(antenna); }); // Create RF front-ends config.rfFrontEnds.forEach((rfConfig, index) => { - const rfFrontEnd = createRFFrontEnd( - `gs-${this.uuid}-rf-front-end${index + 1}-container`, - rfConfig, - 'standard', - ); + const rfFrontEnd = createRFFrontEnd(`gs-${this.uuid}-rf-front-end${index + 1}-container`, rfConfig, 'standard'); this.rfFrontEnds.push(rfFrontEnd); }); // Create spectrum analyzers (if configured) const spectrumAnalyzers = config.spectrumAnalyzers || [null, null, null, null]; spectrumAnalyzers.forEach((specConfig, index) => { - const antennaId = index < 2 ? 0 : 1; // First two use antenna 1, next two use antenna 2 + const antennaId = index < 2 ? 0 : 1; // First two use antenna 1, next two use antenna 2 const rfFrontEnd = this.rfFrontEnds[antennaId]; if (rfFrontEnd) { - const specA = new RealTimeSpectrumAnalyzer( - `gs-${this.uuid}-specA${index + 1}-container`, - rfFrontEnd, - specConfig || {}, - config.teamId || 1 - ); + const specA = new RealTimeSpectrumAnalyzer(`gs-${this.uuid}-specA${index + 1}-container`, rfFrontEnd, specConfig || {}, config.teamId || 1); this.spectrumAnalyzers.push(specA); } }); @@ -136,16 +129,13 @@ export class GroundStation { // Create receivers const receiverCount = config.receivers?.length || 4; for (let i = 1; i <= receiverCount; i++) { - const rx = new Receiver( - `gs-${this.uuid}-rx${i}-container`, - this.antennas, - config.receivers?.[i - 1], - config.teamId || 1 - ); + const rx = new Receiver(`gs-${this.uuid}-rx${i}-container`, this.antennas, config.receivers?.[i - 1], config.teamId || 1); this.receivers.push(rx); } - Logger.info(`Equipment created: ${this.antennas.length} antennas, ${this.rfFrontEnds.length} RF front-ends, ${this.spectrumAnalyzers.length} spectrum analyzers, ${this.transmitters.length} transmitters, ${this.receivers.length} receivers`); + Logger.info( + `Equipment created: ${this.antennas.length} antennas, ${this.rfFrontEnds.length} RF front-ends, ${this.spectrumAnalyzers.length} spectrum analyzers, ${this.transmitters.length} transmitters, ${this.receivers.length} receivers` + ); } /** @@ -163,7 +153,7 @@ export class GroundStation { // Wire transmitters to RF front-ends this.transmitters.forEach((tx, index) => { - const rfIndex = index < 2 ? 0 : 1; // First two use RF 1, next two use RF 2 + const rfIndex = index < 2 ? 0 : 1; // First two use RF 1, next two use RF 2 this.rfFrontEnds[rfIndex]?.connectTransmitter(tx); }); @@ -176,7 +166,7 @@ export class GroundStation { // Wire receivers to RF front-ends this.receivers.forEach((rx, index) => { - const rfIndex = index < 2 ? 0 : 1; // First two use RF 1, next two use RF 2 + const rfIndex = index < 2 ? 0 : 1; // First two use RF 1, next two use RF 2 rx.connectRfFrontEnd(this.rfFrontEnds[rfIndex]); }); @@ -191,11 +181,11 @@ export class GroundStation { // Equipment updates happen automatically via their own EventBus subscriptions // Aggregate equipment states - this.state.equipment.antennas = this.antennas.map(a => a.state); - this.state.equipment.rfFrontEnds = this.rfFrontEnds.map(rf => rf.state); - this.state.equipment.spectrumAnalyzers = this.spectrumAnalyzers.map(s => s.state); - this.state.equipment.transmitters = this.transmitters.map(t => t.state); - this.state.equipment.receivers = this.receivers.map(r => r.state); + this.state.equipment.antennas = this.antennas.map((a) => a.state); + this.state.equipment.rfFrontEnds = this.rfFrontEnds.map((rf) => rf.state); + this.state.equipment.spectrumAnalyzers = this.spectrumAnalyzers.map((s) => s.state); + this.state.equipment.transmitters = this.transmitters.map((t) => t.state); + this.state.equipment.receivers = this.receivers.map((r) => r.state); // Emit ground station state changed if (JSON.stringify(this.state) !== this.lastStateString) { diff --git a/src/campaigns/campaign-manager.ts b/src/campaigns/campaign-manager.ts index 5b16d937..55d64777 100644 --- a/src/campaigns/campaign-manager.ts +++ b/src/campaigns/campaign-manager.ts @@ -35,7 +35,7 @@ export class CampaignManager { * Get a specific campaign by ID */ getCampaign(campaignId: string): CampaignData | undefined { - return this.campaigns_.find(c => c.id === campaignId); + return this.campaigns_.find((c) => c.id === campaignId); } /** @@ -51,36 +51,51 @@ export class CampaignManager { */ getScenario(campaignId: string, scenarioId: string): ScenarioData | undefined { const campaign = this.getCampaign(campaignId); - return campaign?.scenarios.find(s => s.id === scenarioId); + return campaign?.scenarios.find((s) => s.id === scenarioId); } /** * Find which campaign contains a specific scenario */ getCampaignForScenario(scenarioId: string): CampaignData | undefined { - return this.campaigns_.find(campaign => - campaign.scenarios.some(s => s.id === scenarioId) - ); + return this.campaigns_.find((campaign) => campaign.scenarios.some((s) => s.id === scenarioId)); } /** * Get all scenarios across all campaigns (flat list) */ getAllScenarios(): ScenarioData[] { - return this.campaigns_.flatMap(campaign => campaign.scenarios); + return this.campaigns_.flatMap((campaign) => campaign.scenarios); } /** - * Check if a campaign is locked based on prerequisites + * Check if a campaign is locked based on prerequisites. + * + * A campaign can be gated on whole campaigns, on individual scenarios, or + * both; every prerequisite of either kind has to be satisfied. */ - isCampaignLocked(campaign: CampaignData, completedCampaignIds: string[]): boolean { - if (!campaign.prerequisiteCampaignIds || campaign.prerequisiteCampaignIds.length === 0) { + isCampaignLocked(campaign: CampaignData, completedCampaignIds: string[], completedScenarioIds: string[] = []): boolean { + // Same escape hatch isScenarioLocked() honours - without it a developer + // could open a scenario the campaign card still shows as locked. + if (window.DEVELOPER_MODE) { return false; } - return !campaign.prerequisiteCampaignIds.every(prereqId => - completedCampaignIds.includes(prereqId) - ); + const campaignPrereqsMet = (campaign.prerequisiteCampaignIds ?? []).every((prereqId) => completedCampaignIds.includes(prereqId)); + + const scenarioPrereqsMet = (campaign.prerequisiteScenarioIds ?? []).every((prereqId) => completedScenarioIds.includes(prereqId)); + + return !campaignPrereqsMet || !scenarioPrereqsMet; + } + + /** + * First unmet scenario prerequisite for a campaign, so the locked card can + * name what the player has to finish instead of just saying "Locked". + */ + getNextPrerequisiteScenarioForCampaign(campaign: CampaignData, completedScenarioIds: string[]): ScenarioData | undefined { + const nextId = (campaign.prerequisiteScenarioIds ?? []).find((prereqId) => !completedScenarioIds.includes(prereqId)); + + return nextId ? this.getAllScenarios().find((s) => s.id === nextId) : undefined; } /** @@ -99,16 +114,12 @@ export class CampaignManager { } // Exclude sandbox scenarios from progress tracking - const countableScenarios = campaign.scenarios.filter(s => s.missionType !== 'Sandbox'); - const campaignScenarioIds = countableScenarios.map(s => s.id); - const completedInCampaign = completedScenarioIds.filter(id => - campaignScenarioIds.includes(id) - ); + const countableScenarios = campaign.scenarios.filter((s) => s.missionType !== 'Sandbox'); + const campaignScenarioIds = countableScenarios.map((s) => s.id); + const completedInCampaign = completedScenarioIds.filter((id) => campaignScenarioIds.includes(id)); const totalScenarios = countableScenarios.length; - const completionPercentage = totalScenarios > 0 - ? Math.round((completedInCampaign.length / totalScenarios) * 100) - : 0; + const completionPercentage = totalScenarios > 0 ? Math.round((completedInCampaign.length / totalScenarios) * 100) : 0; return { campaignId, @@ -124,10 +135,10 @@ export class CampaignManager { */ getCompletedCampaigns(completedScenarioIds: string[]): string[] { return this.campaigns_ - .filter(campaign => { + .filter((campaign) => { const progress = this.getCampaignProgress(campaign.id, completedScenarioIds); return progress.isCompleted; }) - .map(campaign => campaign.id); + .map((campaign) => campaign.id); } } diff --git a/src/campaigns/campaign-types.ts b/src/campaigns/campaign-types.ts index bdae1101..5e184cc8 100644 --- a/src/campaigns/campaign-types.ts +++ b/src/campaigns/campaign-types.ts @@ -1,5 +1,55 @@ import { ScenarioData } from '@app/ScenarioData'; +/** + * Identity shown in the Mission Control command bar (top left). + * + * The bar used to hardcode "ORBITALOPS" plus a globe icon for every campaign, + * which is wrong for stations that are not an ops floor at all - Campaign 3 is + * a backyard. The name is rendered in two parts so the second can carry the + * campaign accent color, matching the original ORBITAL + OPS treatment. + */ +export interface CampaignHeaderIdentity { + /** First half of the wordmark, rendered in plain text (e.g. 'ORBITAL') */ + name: string; + + /** Second half, rendered in the campaign accent color (e.g. 'OPS') */ + nameAccent: string; + + /** Font Awesome classes for the leading icon (e.g. 'fa-solid fa-satellite-dish') */ + icon: string; +} + +/** + * Which chrome the Mission Control shell wears. + * + * Sits between the shared CSS and the per-campaign accent: layout, typography + * and relief are authored once per variant, hue stays per campaign. That is + * what lets two campaigns read as the same system (C1/C2, C4/C5) without + * duplicating rules, and what keeps 'standard' free - it is defined as the + * rules already in the stylesheet, so its block is empty. + * + * - `standard` - the commercial teleport look (Campaigns 1 and 2) + * - `sdr` - the consumer SDR application look (Campaign 3) + * - `tactical` - a green-army EW workstation look (available, currently unworn) + * - `astro` - the Space Force console look, after the Astro UXDS design + * system the SSC reference apps use (Campaigns 4 and 5) + */ +export type ChromeVariant = 'standard' | 'sdr' | 'tactical' | 'astro'; + +/** + * How finished a campaign is, and therefore what we promise the player. + * + * Every campaign is registered and playable, so this is the only thing that + * tells someone whether they are starting a finished experience or a work in + * progress. The copy for each stage lives in `release-stage.ts`. + * + * - `stable` - finished and supported; the default when a campaign omits it + * - `beta` - content complete, still under public test, saved progress holds + * - `alpha` - actively being built; incomplete, buggy, and future changes are + * expected to invalidate saved progress + */ +export type ReleaseStage = 'stable' | 'beta' | 'alpha'; + /** * Campaign Data Interface * Represents a collection of related scenarios grouped into a campaign @@ -32,6 +82,25 @@ export interface CampaignData { /** Campaign category/type */ campaignType: string; + /** + * Wordmark and icon for the Mission Control command bar. Optional - the bar + * falls back to the historic ORBITAL/OPS globe when a campaign omits it. + */ + headerIdentity?: CampaignHeaderIdentity; + + /** + * Chrome the Mission Control shell wears for this campaign. Optional - the + * Router falls back to 'standard', which is the historic layout, so a + * campaign that omits it is unchanged. + */ + chromeVariant?: ChromeVariant; + + /** + * How finished this campaign is. Optional - a campaign that omits it is + * treated as `stable` and shows no disclaimer, so warnings are opt-in. + */ + releaseStage?: ReleaseStage; + /** Whether this campaign is locked (requires prerequisite campaigns) */ isLocked?: boolean; @@ -41,6 +110,17 @@ export interface CampaignData { /** IDs of campaigns that must be completed before this one unlocks */ prerequisiteCampaignIds?: string[]; + /** + * IDs of individual scenarios that must be completed before this campaign + * unlocks. Separate from `prerequisiteCampaignIds` because a campaign usually + * follows on from a story beat rather than a 100% clear - Campaign 2 opens on + * the Campaign 1 graduation shift (S8), not after all 24 NATS scenarios. + * + * Keep this in step with the `prerequisiteScenarioIds` on the campaign's own + * first scenario, or the card unlocks onto a locked scenario list. + */ + prerequisiteScenarioIds?: string[]; + /** Whether this campaign is coming soon (disabled) */ isDisabled?: boolean; diff --git a/src/campaigns/ccs/ground-stations.ts b/src/campaigns/ccs/ground-stations.ts new file mode 100644 index 00000000..af90b397 --- /dev/null +++ b/src/campaigns/ccs/ground-stations.ts @@ -0,0 +1,418 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { FrequencyBand } from '@app/constants'; +import { type AntennaState } from '@app/equipment/antenna'; +import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; +import { type CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; +import type { Degrees } from 'ootk'; + +/** + * Campaign 4 (9th EWS) - SANDSTORM transportable X-band EA site (SS-01). + * + * A two-aperture electronic-attack site: + * - Antenna 0 (JAM): 5 m X-band, trained on the target COBALT-4 (az 175 / el 50), + * fed by the TX chain (rfFrontEnd 0). BUC LO 7000 MHz low-side, so a 1125 MHz + * IF upconverts to the 8125 MHz target uplink. + * - Antenna 1 (MONITOR): 3 m X-band look-through aperture for battle-damage + * assessment, fed by the RX chain (rfFrontEnd 1). LNB LO 8925 MHz high-side, + * so the 7475 MHz victim downlink comes out at 1450 MHz IF. Starts parked off the + * target (az 90 / el 10) - the operator slews it on for coordination. + * + * Redundancy: the JAM transmitter carries two pre-tuned jam strings - modem 1 + * (primary) and modem 2 (backup). A scheduled hardware fault trips the primary + * mid-mission; the operator fails over to the backup to restore the effect. + * + * The two-antenna / two-front-end wiring uses GroundStation's existing routing + * (antenna i <-> rfFrontEnd i; receivers 0-1 -> rf 0, 2-3 -> rf 1), so the + * monitor receiver sits at index 2 to read the RX front end. + */ +export const sandstormGroundStation = { + id: 'SS-01', + name: 'SANDSTORM Field Site', + location: { + latitude: 34.0, + longitude: -118.0, + elevation: 400, + }, + antennas: [ANTENNA_CONFIG_KEYS.X_BAND_5M, ANTENNA_CONFIG_KEYS.X_BAND_3M_ANTESTAR_RS], + antennasState: [ + { + // JAM aperture: powered and already trained on COBALT-4, so it starts + // boresight-on rather than merely close. These are the bird's true look + // angles from its authored GEO slot (115.1W - see ccs/satellites.ts), not + // the rounded 175/50 the objective asks the operator for: this is a 5 m + // dish with a 0.56 deg beam, and the 0.4 deg of rounding costs ~13 dB of + // beacon C/N. The objective's 3 deg tolerance covers both. + isPowered: true, + azimuth: 174.9 as Degrees, + elevation: 50.4 as Degrees, + polarization: 0 as Degrees, + trackingMode: 'manual', + targetAzimuth: 174.9 as Degrees, + targetElevation: 50.4 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 7290e6 as Hertz, // COBALT-4 telemetry beacon + beaconSearchBwHz: 1e6, + beaconTrackingBwHz: 1e3, + isLocked: false, + } as Partial, + { + // MONITOR aperture: parked off-target; operator slews it onto COBALT-4 + isPowered: true, + azimuth: 90 as Degrees, + elevation: 10 as Degrees, + polarization: 0 as Degrees, + trackingMode: 'manual', + targetAzimuth: 90 as Degrees, + targetElevation: 10 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 7290e6 as Hertz, + beaconSearchBwHz: 1e6, + beaconTrackingBwHz: 1e3, + isLocked: false, + } as Partial, + ], + rfFrontEnds: [ + // rfFrontEnd 0 - JAM transmit chain (X-band up) + { + omt: { + isPowered: true, + txPolarization: 'H', + rxPolarization: 'H', + effectiveTxPol: 'H', + effectiveRxPol: 'H', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: true, + isMuted: false, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 7000 as MHz, // X-band low-side LO (RF = LO + IF) + filterHighHz: FrequencyBand.x.upHigh, + filterLowHz: FrequencyBand.x.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 30 as dB, + outputPower: -10 as dBm, + saturationPower: 20 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: true, + backOff: 8, + outputPower: 50 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 45, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 30 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 13, + bandwidth: 40 as MHz, + insertionLoss: 2.0, + noiseFloor: -101, + }, + lnb: { + isPowered: true, + loFrequency: 8925 as MHz, + gain: 60 as dB, + lnaNoiseFigure: 1.2, + mixerNoiseFigure: 16.0, + noiseTemperature: 90, + noiseTemperatureStabilizationTime: 0, + isExtRefLocked: true, + noiseFloor: -140, + frequencyError: 0, + temperature: 28, + thermalStabilizationTime: 0, + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 10 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, + couplingFactorB: -39, + isEnabledA: true, + isEnabledB: false, + isActiveA: true, + isActiveB: false, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 9, + utcAccuracy: 50, + constellation: 'GPS', + lockDuration: 7200, + frequencyAccuracy: 2e-11, + allanDeviation: 1e-11, + phaseNoise: -110, + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 3, + max10MHzOutputs: 5, + output10MHzLevel: 7, + ppsOutputsEnabled: true, + operatingHours: 4380, + selfTestPassed: true, + agingRate: 1e-10, + }, + }, + // rfFrontEnd 1 - MONITOR receive chain (X-band down) + { + omt: { + isPowered: true, + txPolarization: 'H', + rxPolarization: 'H', + effectiveTxPol: 'H', + effectiveRxPol: 'H', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: false, + isMuted: true, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 7000 as MHz, + filterHighHz: FrequencyBand.x.upHigh, + filterLowHz: FrequencyBand.x.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 30 as dB, + outputPower: -10 as dBm, + saturationPower: 20 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: false, + backOff: 10, + outputPower: 0 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 25, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 30 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 13, + bandwidth: 40 as MHz, + insertionLoss: 2.0, + noiseFloor: -101, + }, + lnb: { + isPowered: true, + loFrequency: 8925 as MHz, // X-band high-side LO (IF = LO - RF -> 1450 MHz) + gain: 62 as dB, + lnaNoiseFigure: 1.0, + mixerNoiseFigure: 16.0, + noiseTemperature: 80, + noiseTemperatureStabilizationTime: 0, + isExtRefLocked: true, + noiseFloor: -140, + frequencyError: 0, + temperature: 28, + thermalStabilizationTime: 0, + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 10 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, + couplingFactorB: -39, + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 9, + utcAccuracy: 50, + constellation: 'GPS', + lockDuration: 7200, + frequencyAccuracy: 2e-11, + allanDeviation: 1e-11, + phaseNoise: -110, + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 3, + max10MHzOutputs: 5, + output10MHzLevel: 7, + ppsOutputsEnabled: true, + operatingHours: 4380, + selfTestPassed: true, + agingRate: 1e-10, + }, + }, + ], + spectrumAnalyzers: [ + { + referenceLevel: -50 as dBm, + centerFrequency: 1125e6 as Hertz, // JAM chain uplink IF (8125 MHz RF) + span: 60e6 as Hertz, + rbw: 1e6 as Hertz, + minAmplitude: -110 as dBm, + maxAmplitude: -40 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + transmitters: [ + { + activeModem: 1, + modems: [ + { + // Modem 1 - PRIMARY jam string (pre-tuned to the 8125 MHz uplink) + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: false, + isTransmittingSwitchUp: false, + isFaultSwitchUp: false, + id: 0, + isLoopback: false, + ifSignal: { + signalId: 'SS-01-JAM-A', + serverId: 1, + noradId: 90042, + polarization: 'H', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1125e6 as IfFrequency, // -> 8125 MHz X-band uplink + power: 0 as dBm, + bandwidth: 5e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + }, + }, + { + // Modem 2 - BACKUP jam string (identical config for fast failover) + isPowered: true, + antenna_id: 1, + modem_number: 2, + isFaulted: false, + isTransmitting: false, + isTransmittingSwitchUp: false, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: 'SS-01-JAM-B', + serverId: 1, + noradId: 90042, + polarization: 'H', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1125e6 as IfFrequency, + power: 0 as dBm, + bandwidth: 5e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + }, + }, + ], + }, + ], + receivers: [ + // Receivers 0-1 route to rfFrontEnd 0 (JAM chain) - unused placeholders + { + activeModem: 1, + modems: [{ modemNumber: 1, isPowered: false, frequency: 1125 as MHz, bandwidth: 5 as MHz, modulation: 'QPSK', fec: '3/4', antenna_id: 1 }], + }, + { + activeModem: 1, + modems: [{ modemNumber: 1, isPowered: false, frequency: 1125 as MHz, bandwidth: 5 as MHz, modulation: 'QPSK', fec: '3/4', antenna_id: 1 }], + }, + // Receiver 2 routes to rfFrontEnd 1 (MONITOR chain) - watches the victim downlink + { + activeModem: 1, + modems: [{ modemNumber: 1, isPowered: true, frequency: 1450 as MHz, bandwidth: 5 as MHz, modulation: 'QPSK', fec: '3/4', antenna_id: 2 }], + }, + ], +} as GroundStationConfig; diff --git a/src/campaigns/ccs/satellites.ts b/src/campaigns/ccs/satellites.ts new file mode 100644 index 00000000..20733f65 --- /dev/null +++ b/src/campaigns/ccs/satellites.ts @@ -0,0 +1,170 @@ +import { OrbitalObserver, OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { TransponderConfig } from '@app/equipment/satellite/satellite'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; + +/** + * Campaign 4 (9th EWS / Counter Communications) satellite roster. + * + * COBALT-4 is the adversary X-band SATCOM bird the operator must deny. Its + * transponder carries the adversary's own service uplink, which is relayed to + * the co-frequency downlink; the operator's jam, once injected into the same + * uplink passband, degrades that downlink's C/I (the denial effect). + * + * SGP4-propagated from a real GEO slot, like Campaign 5's SENTRY birds. It was + * a fixed-geometry Satellite with authored az 175 / el 30 until phase 9; those + * two angles cannot both hold for a geostationary bird seen from 34 deg N + * (azimuth 175 fixes the slot 2.8 deg east of the site meridian, which is 50.4 + * deg of elevation, not 30). A satellite with no ephemeris also cannot appear + * on the world map or ground track. Authoring the orbit fixed both: azimuth was + * kept and elevation follows the geometry. + * + * TLE authored by scripts/author-tle-ccs.mjs against the 2027-11-05 02:00:00 + * UTC scenario epoch: + * - slot 115.1W, inclination 0.05 deg -> az 174.9 / el 50.4 from SANDSTORM, + * holding to within 0.05 deg across the 3-hour scenario window, so the + * authored pointing objectives stay satisfiable at a fixed look angle. + * + * X-band plan (mil band: 7.25-7.75 GHz down, 7.9-8.4 GHz up): + * - Service uplink RF: 8125 MHz (H-pol) -> transponded downlink 7475 MHz + * (frequencyOffset 650 MHz) + * - Telemetry beacon: 7290 MHz (CW) for target ID on the spectrum + * The jam must fall inside the 8100-8150 MHz uplink passband to be transponded onto + * the victim downlink. + */ + +/** SANDSTORM transportable EA site (southern California desert) */ +export const sandstormObserver: OrbitalObserver = { + name: 'SANDSTORM Field Site', + lat: 34.0 as Degrees, + lon: -118.0 as Degrees, + alt: 0.4 as Kilometers, +}; + +export const cobalt4Satellite = new OrbitalSatellite( + 'COBALT-4', + 90042, // Fictional NORAD ID (adversary) + [ + // Adversary service uplink - the "victim" carrier the jam must overpower. + // Routed to TP-X1 by frequency + H polarization, then transponded to 7475 MHz. + { + signalId: 'COBALT-4-SVC-Uplink', + serverId: 1, + noradId: 90042, + frequency: 8125e6 as RfFrequency, + polarization: 'H', + power: 6 as dBm, // At the transponder input (the "S" in J/S) + bandwidth: 5e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + feed: '', + isDegraded: false, + origin: SignalOrigin.SATELLITE_RX, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + ], + [], // Beacon defined on the transponder config + { + tle1: '1 90042U 27300A 27309.08333333 .00000010 00000-0 00000-0 0 9995' as TleLine1, + tle2: '2 90042 0.0500 318.0000 0001000 90.0000 271.0000 1.00273791123459' as TleLine2, + observer: sandstormObserver, + isDopplerEnabled: false, // GEO: negligible Doppler; keep the carrier stationary on the SA + }, + { + rotation: 0 as Degrees, + frequencyOffset: 0.65e9 as Hertz, // Legacy fallback + ephemerisErrorAz: 0.05 as Degrees, + ephemerisErrorEl: 0.04 as Degrees, + transponderConfigs: [ + { + id: 'TP-X1', + uplinkCenterFrequency: 8125e6 as RfFrequency, // Passband 8100-8150 MHz + bandwidth: 50e6 as Hertz, + frequencyOffset: 0.65e9 as Hertz, // Downlink center: 7475 MHz + polarization: 'H', + beacon: { + frequency: 7290e6 as RfFrequency, // X-band telemetry beacon (CW) + signalId: 'COBALT-4-Beacon', + serverId: 1, + noradId: 90042, + power: 4 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + } +); + +/** + * TALON-2 - the unit's own X-band MILSATCOM relay (phase 18 E, ccs S3-S5). + * + * The friendly bird whose 8175-8225 MHz uplink is the protected band in the + * EA scenarios. SANDSTORM doubles as its TT&C backup when the primary + * MILSATCOM terminal is down: the monitor aperture takes its telemetry, the + * TX chain carries its command uplink. Slot 110.0 W (az 166.0 / el 49.6 from + * SANDSTORM, holding to 0.05 deg); TLE authored with the COBALT-4 tool at the + * 2027-11-08 02:00:00 UTC S3 epoch. + * + * X-band plan: TT&C uplink 8200 MHz (H-pol, IF 1200 through the 7000 MHz BUC + * LO); telemetry / beacon downlink 7300 MHz (V-pol CW carrier, IF 1625 + * through the 8925 MHz LNB LO). The state-of-health stream itself is + * settings.telemetry; the beacon is what the aperture points at. + */ +/** TALON-2 element set at the S3 epoch (slot 110.0 W); S5's post-burn set. */ +export const TALON2_TLE = { + tle1: '1 90071U 27300A 27312.08333333 .00000010 00000-0 00000-0 0 9991' as TleLine1, + tle2: '2 90071 0.0500 147.0000 0001000 90.0000 90.0000 1.00273791123450' as TleLine2, +}; + +export const talon2Satellite = new OrbitalSatellite( + 'TALON-2', + 90071, // Fictional NORAD ID (friendly) + [], + [], + { + ...TALON2_TLE, + observer: sandstormObserver, + isDopplerEnabled: false, + }, + { + rotation: 0 as Degrees, + frequencyOffset: 0.9e9 as Hertz, + ephemerisErrorAz: 0.05 as Degrees, + ephemerisErrorEl: 0.04 as Degrees, + transponderConfigs: [ + { + id: 'TP-T1', + uplinkCenterFrequency: 8200e6 as RfFrequency, // Passband 8175-8225 MHz (the protected band) + bandwidth: 50e6 as Hertz, + frequencyOffset: 0.9e9 as Hertz, // Downlink centre: 7300 MHz + polarization: 'H', + beacon: { + frequency: 7300e6 as RfFrequency, // Telemetry carrier (CW) + signalId: 'TALON-2-Beacon', + serverId: 1, + noradId: 90071, + power: 6 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + } +); diff --git a/src/campaigns/ccs/scenario1.ts b/src/campaigns/ccs/scenario1.ts new file mode 100644 index 00000000..700f15ad --- /dev/null +++ b/src/campaigns/ccs/scenario1.ts @@ -0,0 +1,248 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import { sandstormGroundStation } from './ground-stations'; +import { cobalt4Satellite } from './satellites'; + +/** + * Campaign 4 (9th EWS / Counter Communications) - "Blackout" sandbox + * + * Validation level for the electronic-attack feature set: + * - X-band electronic-attack station (jam + look-through monitor apertures), + * - multi-antenna coordination (each aperture targeted by equipmentIndex), + * - player-driven SATCOM denial: raise J/S at the target transponder until the + * adversary downlink is denied (EA Assessment tab), + * - redundant transmit strings: a scheduled fault trips the primary string and + * the operator fails over to the backup, + * - own-force deconfliction: jamming the protected friendly band is an instant + * mission fail (armed via settings.protectedFrequencies). + * + * The RF chain is pre-tuned to the COBALT-4 uplink (8125 MHz); the operator + * coordinates the apertures, keys up the jam, drives the target link down, then + * fails over to the backup string when the primary trips. + */ +export const ccsScenario1Data: ScenarioData = { + id: 'ccs-scenario1', + url: 'ccs/scenarios/ccs-scenario1', + imageUrl: 'nats/21/card.png', + number: 0, + isDisabled: false, + difficulty: 'advanced', + title: 'Blackout', + subtitle: 'X-band SATCOM Denial Trainer', + duration: 'Unlimited', + missionType: 'Sandbox', + description: `COBALT-4 is an adversary X-band SATCOM relay running a service carrier at 8125 MHz. Your transportable EA site, SANDSTORM, has two apertures: a 5-metre jammer already trained on the target and a 3-metre look-through monitor for battle-damage assessment. +

Coordinate both apertures onto COBALT-4, key up the jam waveform into the target transponder, and drive the jam-to-signal ratio high enough to deny the downlink. Watch the EA Assessment tab for effect. When the primary transmit string trips, fail over to the backup string to hold the blackout - and keep your jam off the protected friendly band at all times.`, + equipment: [ + '5-metre X-band Jam Antenna', + '3-metre X-band Look-through Monitor', + 'X-band RF Front End (7000 MHz BUC LO)', + 'Dual Jam Transmit Strings (primary + backup)', + 'EA Assessment Console', + ], + settings: { + isSync: true, + groundStations: [sandstormGroundStation], + satellites: [cobalt4Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-11-05', + scenarioStartWallTime: '02:00:00', + electronicAttack: { + groundStationId: 'SS-01', + targetNoradId: 90042, + jamAntennaIndex: 0, + victimCarrierPowerDbm: 6, + targetUplinkLowHz: 8100e6, + targetUplinkHighHz: 8150e6, + targetPolarization: 'H', + jamPathGainDb: -20, + pointingToleranceDeg: 5, + effectiveJtoSDb: 6, + }, + protectedFrequencies: [ + { + id: 'friendly-milsatcom', + label: 'FRIENDLY MILSATCOM uplink (8175-8225 MHz)', + minHz: 8175e6, + maxHz: 8225e6, + }, + ], + hardwareFaultEvents: [ + { + id: 'jam-a-trip', + groundStationId: 'SS-01', + transmitterIndex: 0, + modemNumber: 1, + startTime: 150, + label: 'JAM-A exciter thermal trip', + }, + ], + }, + objectives: [ + { + id: 'confirm-roe', + title: 'Confirm Rules of Engagement', + description: 'X-band (7.9-8.4 GHz uplink) is a restricted military band. Confirm the deconfliction rule before you radiate.', + groundStation: 'SS-01', + conditions: [ + { + type: 'status-check', + description: 'Deconfliction Rule', + params: { + question: 'A friendly MILSATCOM terminal uplinks in 8175-8225 MHz. Your target COBALT-4 uses 8125 MHz. What must you never do?', + options: [ + 'Let the jam waveform overlap the friendly 8175-8225 MHz band', + 'Transmit below the target power to protect the HPA from overdrive', + 'Point the monitor antenna away from the target while the jam is keyed', + 'Key the backup transmit string before the primary string has tripped', + ], + correctIndex: 0, + explanation: 'Radiating over friendly SATCOM is fratricide and an instant mission fail. Keep the jam on the target uplink (8125 MHz) and clear of the protected band.', + character: Character.SYSTEM, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'coordinate-apertures', + title: 'Coordinate the Apertures', + description: 'The jam antenna is already trained on COBALT-4. Slew the look-through monitor (antenna 2) onto the target as well - azimuth 175, elevation 50.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['confirm-roe'], + // COBALT-4 sits at 115.1W and is seen from SANDSTORM at az 174.9 / el + // 50.4, held to within 0.05 deg for the scenario (see ccs/satellites.ts). + // The angles were az 175 / el 30 while the bird had no ephemeris; that + // pair is not realizable for a GEO satellite from 34 deg N. + conditions: [ + { + type: 'antenna-position', + description: 'Jam Antenna on Target', + params: { equipmentIndex: 0, azimuth: 175, elevation: 50, tolerance: 3 }, + mustMaintain: true, + }, + { + type: 'antenna-position', + description: 'Monitor Antenna on Target', + params: { equipmentIndex: 1, azimuth: 175, elevation: 50, tolerance: 3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'establish-denial', + title: 'Establish the Denial Effect', + description: 'Enable the jam HPA and key up the primary string, then open the EA Assessment tab and drive the target link to DENIED (J/S at or above 6 dB).', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['coordinate-apertures'], + conditions: [ + { + type: 'hpa-enabled', + description: 'Jam HPA Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: false, + }, + { + type: 'jamming-uplink-active', + description: 'Jam Waveform in Target Band', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + { + type: 'jamming-effective', + description: 'Target Link Denied (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'deconfliction-check', + title: 'Verify Deconfliction', + description: 'Confirm your jam frequency is clear of the protected friendly band.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['establish-denial'], + conditions: [ + { + type: 'status-check', + description: 'Frequency Deconfliction', + params: { + question: 'Your jam sits at 8125 MHz with 5 MHz of bandwidth (8122.5-8127.5 MHz). Is it deconflicted from the protected 8175-8225 MHz friendly band?', + options: [ + 'Yes - the jam is well clear of the protected band', + 'No - the jam overlaps the edge of the protected band', + 'Only if the HPA back-off is increased by a few dB', + 'Only if the monitor antenna is stowed for the jam', + ], + correctIndex: 0, + explanation: 'At 8125 MHz ±2.5 MHz the jam is ~48 MHz clear of the 8175 MHz protected edge - deconflicted. Widening the barrage toward 8175 MHz would risk fratricide.', + character: Character.SYSTEM, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'failover-backup', + title: 'Fail Over to the Backup String', + description: 'The primary jam string (modem 1) has tripped and the blackout is lapsing. Select the backup string (modem 2), key it up, and restore the denial effect.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['deconfliction-check'], + timeLimitSeconds: 180, + timerStartTrigger: 'on-activate', + timePenalty: { + elapsedTimeThreshold: 90, + pointsDeducted: 5, + message: 'The adversary link recovered during the failover gap.', + }, + conditions: [ + { + type: 'tx-active-modem', + description: 'Backup String (Modem 2) Selected', + params: { modemNumber: 2 }, + mustMaintain: false, + }, + { + type: 'tx-modem-transmitting', + description: 'Backup String Transmitting', + params: { modemNumber: 2 }, + mustMaintain: false, + }, + { + type: 'jamming-effective', + description: 'Blackout Restored (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'cease-fire', + title: 'Cease Fire', + description: 'On the recall order, stop the jam cleanly: disable the HPA before you stop the exciter, then confirm transmission has ceased.', + groundStation: 'SS-01', + isOptional: true, + prerequisiteObjectiveIds: ['failover-backup'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'Jam String Stopped', + params: { modemNumber: 2 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + ], +}; diff --git a/src/campaigns/ccs/scenario2.ts b/src/campaigns/ccs/scenario2.ts new file mode 100644 index 00000000..343280e0 --- /dev/null +++ b/src/campaigns/ccs/scenario2.ts @@ -0,0 +1,480 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import { sandstormGroundStation } from './ground-stations'; +import { cobalt4Satellite } from './satellites'; + +/** + * Campaign 4 (9th EWS / Counter Communications) - Scenario 2 "Failover" + * + * First scored mission of the arc; builds on the "Blackout" sandbox + * (ccs-scenario1), which already walks the operator through aperture + * coordination, the first denial, and a single failover. This one is the + * contingency shift: the same SANDSTORM site, the same COBALT-4 target, but + * the denial has to be HELD through two transmit-string trips while the + * adversary tries to recover his link. + * + * Fault model (what the engine can and cannot do): + * - `hardwareFaultEvents` trips a transmitter MODEM (the exciter): it faults, + * stops radiating, and stays latched until the operator runs the string's + * fault reset with the string un-keyed. The two jam strings share one + * BUC/HPA on rfFrontEnd 0, so an "HPA trip" cannot be represented - the + * scheduled faults are exciter trips and the diagnosis the operator must + * make is exactly that: exciter down, amplifier healthy, keep the HPA up + * and change strings. + * - `fault-active` / `fault-cleared` read the payload FaultInjector, not the + * hardware-fault schedule, so the trips are detected through the string + * itself: `tx-modem-not-transmitting` on the string that just went down. + * + * Timeline (mission elapsed, HardwareFaultManager clock): + * - T+0 tasking acknowledged, monitor aperture slewed on, JAM-A keyed + * - ~T+4-6 first hold: 120 s of continuous denial on the primary + * - T+480 JAM-A exciter trips -> fail over to JAM-B inside 180 s + * - ~T+11 second hold: 120 s of continuous denial on the backup + * - T+840 JAM-B exciter trips -> reset a cooled string and re-key inside 150 s + * - then hold 180 s to the recall, cease fire cleanly + * + * The scheduled trips fire on the mission clock regardless of objective + * progress. Every objective after the first trip grades the EFFECT (any string + * radiating in band with J/S at threshold), never a particular modem, so a + * slow operator who meets a trip early is never dead-ended: any string can be + * reset and re-keyed. + * + * NICE Framework Alignment: + * Primary Codes: + * - S0582: Skill in troubleshooting RF systems + * - T0153: Monitor system performance + * Supporting Codes: + * - K0645: Knowledge of standard operating procedures + * - K0737: Knowledge of RF spectrum characteristics + * - S0648: Skill in detecting anomalies + * - T0081: Analyze anomalies + */ +export const ccsScenario2Data: ScenarioData = { + id: 'ccs-scenario2', + url: 'ccs/scenarios/ccs-scenario2', + imageUrl: 'nats/21/card.png', + number: 1, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['ccs-scenario1'], + title: 'Failover', + subtitle: 'Hold the Blackout Through a String Trip', + duration: '25-35 min', + missionType: 'Contingency Operations', + description: `Second night on COBALT-4. The tasking order wants the adversary's X-band service link denied for the full window, and the crew chief has left you a warning: the JAM-A exciter has a thermal history, the tent swapped its fan yesterday, and nobody trusts it yet. JAM-B is the spare. Both strings drive the same amplifier. +

Bring the monitor aperture onto the bird, key the primary, and drive the link to DENIED. Then hold it. When a string trips, read the panel before you touch anything: work out which module actually failed, keep the amplifier up, change strings, and restore the effect before the adversary's link comes back. Expect more than one trip. The friendly MILSATCOM band at 8175-8225 MHz is protected the whole shift - radiate into it and the mission is over.`, + equipment: [ + '5-metre X-band Jam Antenna', + '3-metre X-band Look-through Monitor', + 'X-band RF Front End (7000 MHz BUC LO)', + 'Dual Jam Exciters (JAM-A primary, JAM-B backup) on a Shared HPA', + 'EA Assessment Console', + ], + settings: { + isSync: true, + groundStations: [sandstormGroundStation], + satellites: [cobalt4Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-11-06', + scenarioStartWallTime: '10:30:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-4/scenario-2?content-only=true&dark=true', + electronicAttack: { + groundStationId: 'SS-01', + targetNoradId: 90042, + jamAntennaIndex: 0, + victimCarrierPowerDbm: 6, + targetUplinkLowHz: 8100e6, + targetUplinkHighHz: 8150e6, + targetPolarization: 'H', + jamPathGainDb: -20, + pointingToleranceDeg: 5, + effectiveJtoSDb: 6, + }, + protectedFrequencies: [ + { + id: 'friendly-milsatcom', + label: 'FRIENDLY MILSATCOM uplink (8175-8225 MHz)', + minHz: 8175e6, + maxHz: 8225e6, + }, + ], + hardwareFaultEvents: [ + { + id: 'jam-a-trip', + groundStationId: 'SS-01', + transmitterIndex: 0, + modemNumber: 1, + startTime: 480, + label: 'JAM-A exciter over-temperature trip', + }, + { + id: 'jam-b-trip', + groundStationId: 'SS-01', + transmitterIndex: 0, + modemNumber: 2, + startTime: 840, + label: 'JAM-B exciter over-temperature trip', + }, + ], + }, + objectives: [ + { + id: 'acknowledge-tasking', + nice: ['K0645', 'K0737'], + title: 'Acknowledge the Tasking Order', + description: 'Open the shift package and confirm the rules of engagement for the window. The protected band is armed from the moment you radiate.', + groundStation: 'SS-01', + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Package Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Rules of Engagement', + params: { + question: + 'The tasking order denies the COBALT-4 service uplink at 8125 MHz. FRIENDLY MILSATCOM uplinks in 8175-8225 MHz through the same slot of sky. Which of these ends the mission immediately?', + options: [ + 'Any jam waveform overlapping 8175-8225 MHz, for any duration', + 'Letting J/S fall below 6 dB while a string is tripped, for any duration', + 'Changing exciters without disabling the HPA first, on either string', + 'Running the monitor aperture off the target, for more than a few seconds', + ], + correctIndex: 0, + explanation: + 'Radiating into friendly SATCOM is fratricide. A lapse in J/S costs you the effect and points; a protected-band hit costs you the mission. Keep the jam on 8125 MHz at 5 MHz wide and it stays 48 MHz clear of the edge.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'coordinate-apertures', + nice: ['S0421', 'K1032'], + title: 'Coordinate the Apertures', + description: + "The jam antenna is already trained on COBALT-4. The monitor aperture was stowed for last night's wind hold - slew it back onto the bird (azimuth 175, elevation 50) so you have look-through for the whole window.", + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['acknowledge-tasking'], + conditions: [ + { + type: 'antenna-position', + description: 'Jam Antenna on Target', + params: { equipmentIndex: 0, azimuth: 175, elevation: 50, tolerance: 3 }, + mustMaintain: true, + }, + { + type: 'antenna-position', + description: 'Monitor Antenna on Target', + params: { equipmentIndex: 1, azimuth: 175, elevation: 50, tolerance: 3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'establish-denial', + nice: ['S0421', 'K0737', 'K1032'], + title: 'Establish the Denial Effect', + description: 'Enable the jam HPA, key the primary string, and drive the target link to DENIED on the EA Assessment tab (J/S at or above 6 dB).', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['coordinate-apertures'], + conditions: [ + { + type: 'hpa-enabled', + description: 'Jam HPA Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: false, + }, + { + type: 'tx-modem-transmitting', + description: 'Primary String (JAM-A, Modem 1) Keyed', + params: { modemNumber: 1 }, + mustMaintain: false, + }, + { + type: 'jamming-uplink-active', + description: 'Jam Waveform in Target Band', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + { + type: 'jamming-effective', + description: 'Target Link Denied (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'hold-primary', + nice: ['T0153', 'K0740'], + title: 'Hold the Blackout', + description: + 'The adversary ground segment will step its carrier up to punch through. Keep the amplifier up and J/S at or above 6 dB for two continuous minutes - any lapse restarts the clock.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['establish-denial'], + conditions: [ + { + type: 'hpa-enabled', + description: 'Jam HPA Held Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + maintainDuration: 120, + }, + { + type: 'jamming-effective', + description: 'Denial Held 120 s (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: true, + maintainDuration: 120, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'detect-first-trip', + nice: ['S0648', 'T0153'], + title: 'Recognise the Lapse', + description: 'Watch the EA Assessment status and the transmitter panel. The moment the primary string (JAM-A, modem 1) stops radiating, the blackout is lapsing.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['hold-primary'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'JAM-A (Modem 1) Down', + params: { modemNumber: 1 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'failover-backup', + nice: ['T0081', 'S0582', 'S0421'], + title: 'Diagnose and Fail Over', + description: + 'Read the panel before you act: which module tripped? Keep the amplifier up, select the backup string (JAM-B, modem 2), key it, and restore DENIED before the adversary link recovers.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['detect-first-trip'], + timeLimitSeconds: 180, + timerStartTrigger: 'on-activate', + timePenalty: { + elapsedTimeThreshold: 90, + pointsDeducted: 5, + message: 'The adversary link was back up for over ninety seconds before you restored the effect.', + }, + conditions: [ + { + type: 'status-check', + description: 'Fault Isolation', + params: { + question: + 'Transmitter panel: JAM-A shows FAULT. HPA reports enabled, output nominal, no overdrive. BUC unmuted and reference locked. Jam antenna still on target. What failed, and what is the recovery?', + options: [ + 'The JAM-A exciter tripped; the amplifier is healthy - leave the HPA enabled and switch to JAM-B', + 'The HPA tripped; the exciter is healthy - disable the HPA, wait for cool-down, then re-key JAM-A', + 'The BUC lost reference lock; the exciter is healthy - power-cycle the BUC and re-key JAM-A', + 'The jam antenna lost pointing; the chain is healthy - re-slew before touching the transmit chain', + ], + correctIndex: 0, + explanation: + 'The FAULT indicator is on the exciter and every amplifier and BUC alarm is clear. Both strings drive the same HPA, so the fast recovery is to leave it enabled and bring the backup exciter up behind it. Disabling the HPA only extends the lapse.', + character: Character.SYSTEM, + pointPenalty: 5, + }, + mustMaintain: false, + }, + { + type: 'hpa-enabled', + description: 'Jam HPA Still Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: false, + }, + { + type: 'tx-active-modem', + description: 'Backup String (JAM-B, Modem 2) Selected', + params: { modemNumber: 2 }, + mustMaintain: false, + }, + { + type: 'tx-modem-transmitting', + description: 'JAM-B Keyed', + params: { modemNumber: 2 }, + mustMaintain: false, + }, + { + type: 'jamming-effective', + description: 'Blackout Restored (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'hold-backup', + nice: ['T0153', 'K0740'], + title: 'Hold on the Backup', + description: 'JAM-B is carrying the effect on an amplifier that has been hot for ten minutes. Hold J/S at or above 6 dB for another two continuous minutes.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['failover-backup'], + conditions: [ + { + type: 'hpa-enabled', + description: 'Jam HPA Held Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + maintainDuration: 120, + }, + { + type: 'jamming-effective', + description: 'Denial Held 120 s (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: true, + maintainDuration: 120, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'detect-second-trip', + nice: ['S0648', 'T0081'], + title: 'Second Trip', + description: 'Stay on the assessment. If the backup string (JAM-B, modem 2) drops as well, you are out of spare exciters - and the window is not over.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['hold-backup'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'JAM-B (Modem 2) Down', + params: { modemNumber: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'recover-string', + nice: ['S0582', 'S0421'], + title: 'Reset a String and Recover', + description: + 'Both exciters have tripped once. A tripped exciter stays latched until you run its FAULT RESET with the string un-keyed. Select a cooled string, clear its fault, key it, and restore DENIED.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['detect-second-trip'], + timeLimitSeconds: 150, + timerStartTrigger: 'on-activate', + timePenalty: { + elapsedTimeThreshold: 75, + pointsDeducted: 5, + message: 'The adversary link recovered for over seventy-five seconds during the second lapse.', + }, + conditions: [ + { + type: 'status-check', + description: 'Recovery Procedure', + params: { + question: + 'JAM-B now shows FAULT as well; JAM-A has been sitting un-keyed since its own trip. The HPA is still enabled and nominal. What restores the effect fastest without damaging anything?', + options: [ + 'Select a cooled string, run FAULT RESET with it un-keyed, confirm FAULT clears, then key it with the HPA left enabled', + 'Power-cycle the HPA, wait for it to come back nominal, then key JAM-A - a fresh amplifier start clears latched exciter faults', + 'Key JAM-B again immediately, keep it keyed through the FAULT, then wait - the trip clears itself once the string is transmitting', + 'Select whichever string comes up, widen its jam bandwidth, then key it - the extra width makes up for the time already lost', + ], + correctIndex: 0, + explanation: + 'The fault is on the exciter and it latches. Reset only takes with the string un-keyed and needs a few seconds to clear. The HPA never failed - leave it up. Widening the waveform toward 8175 MHz is the one thing that can end the mission outright.', + character: Character.SYSTEM, + pointPenalty: 5, + }, + mustMaintain: false, + }, + { + type: 'hpa-enabled', + description: 'Jam HPA Still Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: false, + }, + { + type: 'tx-modem-transmitting', + description: 'A Jam String Keyed', + mustMaintain: false, + }, + { + type: 'jamming-effective', + description: 'Blackout Restored (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'hold-to-recall', + nice: ['T0153', 'K0740'], + title: 'Hold to the Recall', + description: 'Carry the effect to the end of the window: three continuous minutes at or above 6 dB J/S with the amplifier up.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['recover-string'], + conditions: [ + { + type: 'hpa-enabled', + description: 'Jam HPA Held Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + maintainDuration: 180, + }, + { + type: 'jamming-effective', + description: 'Denial Held 180 s (J/S >= 6 dB)', + params: { requiresObservation: true, observationTab: 'ea-assessment' }, + mustMaintain: true, + maintainDuration: 180, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'cease-fire', + nice: ['K0645', 'S0421'], + title: 'Cease Fire', + description: 'Recall received. Stand the chain down in order: disable the HPA, then un-key the exciter, and confirm nothing is radiating.', + groundStation: 'SS-01', + isOptional: true, + prerequisiteObjectiveIds: ['hold-to-recall'], + conditions: [ + { + type: 'hpa-disabled', + description: 'Jam HPA Disabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Active Jam String Un-keyed', + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + ], +}; diff --git a/src/campaigns/ccs/scenario3.ts b/src/campaigns/ccs/scenario3.ts new file mode 100644 index 00000000..52d025a1 --- /dev/null +++ b/src/campaigns/ccs/scenario3.ts @@ -0,0 +1,554 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { Hertz, MHz } from '@app/types'; +import { sandstormGroundStation } from './ground-stations'; +import { cobalt4Satellite, talon2Satellite } from './satellites'; + +/** + * Campaign 4 (9th EWS / Counter Communications) - Scenario 3 "First Shift" + * + * The contractor's first shift at SANDSTORM (the Campaign 2 bridge: Charlie's + * friend read the attribution report and asked for the operator who wrote + * it). It is not a jamming shift. SANDSTORM's second job is TT&C backup for + * TALON-2, the unit's own X-band MILSATCOM relay - the friendly bird whose + * 8175-8225 MHz uplink is the protected band in the EA tasking - and tonight + * the primary MILSATCOM terminal is down for maintenance. The evening contact + * is SANDSTORM's: point the monitor aperture at TALON-2, take its telemetry, + * read the spacecraft subsystem by subsystem, call its state of health, send + * one housekeeping dump on the TX chain, safe it, log it, hand back. + * + * Phase 18 E: first use of settings.telemetry (the read-only Telemetry tab + * with limit bands) and of the telemetry-* conditions. The state-of-health + * call is a decision graded on the soh-yellow-limit / telemetry-stale facts: + * "nominal" is a claim about fresh frames inside limits, not the absence of + * red badges. No EA settings, so the deconfliction interlock is not armed - + * the jam strings stay cold and the objectives say so. + * + * Timeline (mission elapsed): contact window T+600 .. T+2400. TALON-2 is GEO + * (az 166.0 / el 49.6 from SANDSTORM, stationary on this timescale); the + * monitor aperture parks at az 90 / el 10 and is slewed on by the operator. + * The monitor receiver (receiver 3) is staged on the 1625 MHz telemetry IF by + * the day crew; the TX exciters carry the 1125 MHz jam tune and modem 1 must + * be retuned to the 1200 MHz TT&C uplink IF. + * + * NICE Framework Alignment: + * Primary Codes: + * - K1032: Knowledge of satellite-based communication systems (spacecraft subsystems) + * - S0648: Skill in detecting anomalies (state of health against limits) + * Supporting Codes: + * - T0431: Monitor timing and telemetry reference systems + * - S0421: Skill in operating communications equipment + * - T1567: Configure system hardware, software, peripheral equipment + * - K0645: Knowledge of standard operating procedures + * - T0153: Monitor system performance + * - K0737: Knowledge of RF spectrum characteristics + */ + +/** SS-01 as the day crew staged it for TALON-2: monitor beacon on 7300, monitor receiver on 1625. Deep clone, never spread. */ +const sandstormTalon: GroundStationConfig = structuredClone(sandstormGroundStation) as unknown as GroundStationConfig; +sandstormTalon.antennasState![1] = { ...sandstormTalon.antennasState![1], beaconFrequencyHz: 7300e6 as Hertz }; +sandstormTalon.receivers![2].modems![0] = { ...sandstormTalon.receivers![2].modems![0], frequency: 1625 as MHz }; + +/** TALON-2 state-of-health channels: ten channels across five subsystems, all nominal tonight. */ +const TALON2_CHANNELS = [ + { id: 'bus-v', label: 'Main bus voltage', unit: 'V', subsystem: 'EPS', nominal: 28.1, noise: 0.2, yellowLow: 26.5, yellowHigh: 29.5, redLow: 25.5, redHigh: 30.5 }, + { id: 'batt-soc', label: 'Battery state of charge', unit: '%', subsystem: 'EPS', nominal: 92, noise: 0.4, yellowLow: 70, redLow: 55, decimals: 0 }, + { id: 'sa-i', label: 'Solar array current', unit: 'A', subsystem: 'EPS', nominal: 18.5, noise: 0.3, yellowLow: 12, yellowHigh: 24, redLow: 8, redHigh: 28 }, + { id: 'batt-t', label: 'Battery temperature', unit: 'degC', subsystem: 'TCS', nominal: 21, noise: 0.3, yellowLow: 5, yellowHigh: 32, redLow: 0, redHigh: 40 }, + { id: 'twta-t', label: 'TWTA baseplate temperature', unit: 'degC', subsystem: 'TCS', nominal: 58, noise: 0.5, yellowLow: 40, yellowHigh: 75, redLow: 30, redHigh: 85 }, + { + id: 'wheel-rpm', + label: 'Reaction wheel 1 speed', + unit: 'rpm', + subsystem: 'ADCS', + nominal: 3200, + noise: 30, + yellowLow: 1500, + yellowHigh: 5000, + redLow: 800, + redHigh: 6000, + decimals: 0, + }, + { id: 'sun-err', label: 'Sun sensor pointing error', unit: 'deg', subsystem: 'ADCS', nominal: 0.05, noise: 0.02, yellowHigh: 0.3, redHigh: 0.8, decimals: 2 }, + { id: 'tp-pwr', label: 'TP-T1 output power', unit: 'dBm', subsystem: 'COMM', nominal: 42, noise: 0.2, yellowLow: 38, yellowHigh: 44, redLow: 35, redHigh: 46 }, + { id: 'rx-agc', label: 'Command receiver AGC', unit: 'dBm', subsystem: 'COMM', nominal: -65, noise: 1, yellowLow: -80, yellowHigh: -50, redLow: -90, redHigh: -40, decimals: 0 }, + { id: 'cpu-load', label: 'OBC processor load', unit: '%', subsystem: 'OBC', nominal: 34, noise: 2, yellowHigh: 70, redHigh: 90, decimals: 0 }, +]; + +export const ccsScenario3Data: ScenarioData = { + id: 'ccs-scenario3', + url: 'ccs/scenarios/ccs-scenario3', + imageUrl: 'nats/22/card.png', + number: 2, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['ccs-scenario2'], + title: 'First Shift', + subtitle: 'TALON-2 State of Health', + duration: '25-35 min', + missionType: 'Spacecraft Operations', + description: `Your first shift at SANDSTORM as a contractor, and it is not a jamming shift. The site's second job is TT&C backup for TALON-2, the unit's own X-band relay - the bird behind the protected band in every tasking order here - and tonight the primary MILSATCOM terminal is down for maintenance. The 0210Z contact is yours. +

Point the monitor aperture at TALON-2 and take its telemetry. Read the spacecraft one subsystem at a time - power, thermal, attitude, comms, computer - and call its state of health on what the frames say, not on the colour of the badges. Then one housekeeping dump on the TX chain, safe the uplink, log the contact and hand it back. The jam strings stay cold all night: this is the bird they exist to protect.`, + equipment: [ + '3-metre X-band Look-through Monitor (TALON-2 telemetry aperture)', + '5-metre X-band Jam Antenna (cold)', + 'X-band RF Front End (7000 MHz BUC LO / 8925 MHz LNB LO)', + 'TT&C Console with Telemetry Display', + 'Dual Exciters on a Shared HPA (modem 1 retuned for TT&C)', + ], + settings: { + isSync: true, + groundStations: [sandstormTalon], + satellites: [talon2Satellite, cobalt4Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-11-08', + scenarioStartWallTime: '02:00:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-4/scenario-3?content-only=true&dark=true', + + workingDocument: { + title: 'TALON-2 Contact Log 2027-312-01', + description: 'The 0210Z backup contact: telemetry, state of health, the command, the hand-back.', + }, + + // Phase 18 E - the telemetry stream, on the monitor aperture (antenna 1). + telemetry: { + groundStationId: 'SS-01', + satelliteNoradId: 90071, + antennaIndex: 1, + frameRateHz: 1, + staleAfterS: 10, + channels: TALON2_CHANNELS, + }, + + // The command uplink on the TX chain: 8200 MHz through the 7000 MHz BUC LO + // (modem 1 retuned to 1200 MHz IF). GEO: no Doppler compensation. + commanding: { + groundStationId: 'SS-01', + targetNoradId: 90071, + windowStartS: 600, + windowEndS: 2400, + requireDopplerComp: false, + requireValidKey: true, + commands: [ + { id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }, + { id: 'TLM-RATE-HI', label: 'Telemetry to high rate' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + }, + objectives: [ + { + id: 'read-the-shift-package', + nice: ['K0645', 'K1032'], + title: 'Read the Shift Package', + description: 'Open the shift package: what SANDSTORM does for TALON-2 tonight, what it does not, and why the jam strings stay cold.', + groundStation: 'SS-01', + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Package Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Role Confirmed', + params: { + question: 'What is SANDSTORM doing for TALON-2 at 0210Z, and what is the one thing it must not do?', + options: [ + "Backup TT&C: telemetry on the monitor aperture, one command on the TX chain. It must not radiate anything but that command into 8175-8225 MHz - the band is TALON-2's own uplink", + "Battle-damage assessment: watch TALON-2's downlink for interference while the primary terminal is down", + 'Denial rehearsal: TALON-2 is a friendly stand-in for COBALT-4 so the jam chain can be exercised safely', + "Ranging: measure TALON-2's slant range for the orbit-determination team", + ], + correctIndex: 0, + explanation: + 'The protected band exists because this bird lives in it. Tonight the site is on the other side of that rule: it is the station that keeps TALON-2 alive while the primary terminal is down. Telemetry, one command, hand back.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'jam-strings-cold', + nice: ['K0645', 'K0737'], + title: 'Confirm the Jam Strings Are Cold', + description: 'TX Chain: HPA output disabled, JAM-A and JAM-B un-keyed. Nothing leaves this trailer until the command is ready.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['read-the-shift-package'], + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { equipmentIndex: 0, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'JAM-A (Modem 1) Un-keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'JAM-B (Modem 2) Un-keyed', + params: { modemNumber: 2 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'point-the-monitor', + nice: ['S0421', 'K1032'], + title: 'Point the Monitor at TALON-2', + description: 'The monitor aperture (antenna 2) is parked at az 90 / el 10. TALON-2 sits at azimuth 166, elevation 50. Slew it on and hold it there for the contact.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['jam-strings-cold'], + conditions: [ + { + type: 'antenna-position', + description: 'Monitor Antenna on TALON-2', + params: { equipmentIndex: 1, azimuth: 166, elevation: 50, tolerance: 3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'acquire-telemetry', + nice: ['T0431', 'S0421'], + title: 'Acquire Telemetry', + description: 'Receiver 3 is on the 1625 MHz telemetry IF. Open the Telemetry tab and watch the frame counter: ten frames on a LIVE link is a stream you can read.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['point-the-monitor'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'Monitor Receiver on 1625 MHz', + params: { equipmentIndex: 2, modemNumber: 1, frequency: 1625e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'tab-active', + hidden: true, + description: 'Telemetry Tab Open', + params: { tab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-frames-received', + description: 'Ten Frames Received', + params: { minFrames: 10 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-power-bus', + nice: ['K1032', 'S0648'], + title: 'Read the Power Subsystem', + description: 'EPS: main bus voltage and battery state of charge, both inside their yellow limits on fresh frames. Read them on the Telemetry tab and say what they mean.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['acquire-telemetry'], + conditions: [ + { + type: 'telemetry-channel-in-band', + description: 'Bus Voltage Read Nominal', + params: { channelId: 'bus-v', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-channel-in-band', + description: 'Battery State of Charge Read Nominal', + params: { channelId: 'batt-soc', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'EPS Understood', + params: { + question: 'Bus 28.1 V, battery 92 %, array current 18.5 A, in the middle of the evening. What does that set of three tell you?', + options: [ + 'The array is carrying the load and topping the battery: a healthy sunlit bus. The number to watch is the battery on the next eclipse, not any of these three now', + 'The battery is discharging: 92 % is below full and the bus is a volt above nominal, so a cell is failing', + 'The array is undersized: 18.5 A at 28 V is only 518 W and a relay of this size needs more', + 'Nothing on its own: EPS telemetry only means something against the previous contact', + ], + correctIndex: 0, + explanation: + 'Voltage says the regulator is holding the bus; state of charge says the battery is where a sunlit bird keeps it; array current says the sun is on the panels and the load is covered. Three numbers, one picture. The interesting one is the battery at the bottom of the next eclipse.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-thermal', + nice: ['K1032', 'S0648'], + title: 'Read the Thermal Picture', + description: 'TCS: battery temperature and the TWTA baseplate. Read both nominal on the Telemetry tab and say which one would move first if something were wrong.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['read-the-power-bus'], + conditions: [ + { + type: 'telemetry-channel-in-band', + description: 'Battery Temperature Read Nominal', + params: { channelId: 'batt-t', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-channel-in-band', + description: 'TWTA Temperature Read Nominal', + params: { channelId: 'twta-t', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'TCS Understood', + params: { + question: 'Battery 21 degC with a 32 degC yellow limit; TWTA baseplate 58 degC with a 75 degC yellow limit. Which is the one to watch, and why?', + options: [ + 'The battery: its limits are tight because a lithium cell above 32 degC ages fast and above 40 is a fire risk, and a stuck heater moves it slowly enough to miss between contacts. The TWTA runs hot by design', + 'The TWTA: 58 degC is already three-quarters of the way to its limit and an amplifier failure takes the whole payload', + 'Neither: both are inside limits, and a thermal problem on a GEO bird announces itself with a red badge', + 'Both equally: any channel that moves is the one to watch', + ], + correctIndex: 0, + explanation: + 'Read a limit as the distance to harm, not as a fraction. The TWTA is designed to live at 58 and its limit is a long way off; the battery lives at 21 and eleven degrees of headroom is a stuck heater and a missed contact away. Slow channels with tight limits are the ones you write down.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-attitude-and-comm', + nice: ['K1032', 'S0648'], + title: 'Read Attitude and Comms', + description: 'ADCS and COMM: reaction wheel 1 and the TP-T1 output power. Read both nominal and say what a wheel speed is actually telling you.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['read-the-thermal'], + conditions: [ + { + type: 'telemetry-channel-in-band', + description: 'Reaction Wheel Speed Read Nominal', + params: { channelId: 'wheel-rpm', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-channel-in-band', + description: 'Transponder Output Power Read Nominal', + params: { channelId: 'tp-pwr', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'ADCS Understood', + params: { + question: 'Reaction wheel 1 at 3200 rpm, sun sensor error 0.05 deg, transponder output 42 dBm. What is the wheel speed telling you?', + options: [ + 'Stored momentum: a wheel spins to hold the bird still against the torques on it, and a speed that climbs contact after contact is momentum that will need a dump. 3200 in the middle of the band is a bird at rest', + 'Pointing accuracy: the faster the wheel, the tighter the pointing, and 3200 rpm is why the sun error is small', + 'A fault: a reaction wheel should be at rest on a stationary GEO bird; 3200 rpm means it is fighting something', + 'Nothing unless it changes: wheel speed is a housekeeping channel with no operational meaning', + ], + correctIndex: 0, + explanation: + "The wheel is the attitude system's bank account. Its speed is how much momentum the bird has had to absorb, and the trend across contacts is the number that says when a momentum dump is due. The sun error says the pointing is good; the wheel says how hard the bird is working to keep it that way.", + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-state-of-health', + nice: ['S0648', 'T0153', 'K1032'], + title: 'Call the State of Health', + description: 'Every channel green on fresh frames. Read the whole panel on the Telemetry tab, then give RAMPART the state-of-health call for the log.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['read-attitude-and-comm'], + conditions: [ + { + id: 'soh-seen', + type: 'telemetry-soh-nominal', + description: 'State of health read on Telemetry', + params: { requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'State of Health', + params: { + character: Character.SYSTEM, + prompt: 'RAMPART: "SANDSTORM, TALON-2 state of health for the 0210 contact?" What do you call?', + evidence: ['soh-seen'], + decisionOptions: [ + { + label: 'Nominal - every channel inside yellow limits on fresh frames, stream LIVE. Logged; proceeding to the housekeeping dump', + correctWhen: { + all: [ + { fact: 'soh-yellow-limit', is: false }, + { fact: 'telemetry-stale', is: false }, + ], + }, + consequence: { log: 'TALON-2 state of health called NOMINAL on fresh frames' }, + }, + { + label: 'Degraded - a channel is in its yellow band. Holding the command until Rotterdam concurs', + correctWhen: { fact: 'soh-yellow-limit', is: true }, + consequence: { log: 'TALON-2 state of health called DEGRADED' }, + feedback: 'Every badge on the panel reads NOMINAL. A degraded call on a green panel is a call you did not read.', + }, + { + label: 'Unknown - the stream is stale. Re-acquiring before I call anything', + correctWhen: { fact: 'telemetry-stale', is: true }, + consequence: { log: 'TALON-2 state of health called UNKNOWN - stale stream' }, + feedback: 'The link badge reads LIVE and the frame counter is climbing. Stale is a fact about the stream, not a way to avoid the call.', + }, + { + label: 'Nominal by default - no red badges, so nothing to report', + feedback: + 'No red badges is not a state of health. Nominal means every channel inside its limits on frames that arrived just now - which happens to be true, and is what you say when it is.', + }, + ], + explanation: + 'A state-of-health call is a claim about fresh frames inside limits, made after reading them. Tonight that claim is true. The discipline is that "nominal" and "no red badges" are different sentences, and only one of them goes in the log.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'arm-the-uplink', + nice: ['T1567', 'K0737'], + title: 'Arm the TT&C Uplink', + description: + 'TX Chain: modem 1 carries the 1125 MHz jam tune. Retune it to the 1200 MHz TT&C IF - 8200 MHz through the 7000 MHz BUC LO - and leave the HPA disabled until the window.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['call-state-of-health'], + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'Modem 1 on 1200 MHz (8200 MHz RF)', + params: { modemNumber: 1, frequency: 1200e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'housekeeping-dump', + nice: ['T1567', 'K1032', 'S0421'], + title: 'Housekeeping Dump', + description: 'Window opens at T+10:00. HPA up, key modem 1, send HK-DUMP from the TT&C console and confirm the ACK. One command, inside the window, on the right carrier.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['arm-the-uplink'], + conditions: [ + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + { + type: 'tx-modem-transmitting', + description: 'Modem 1 Keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'command-acknowledged', + description: 'HK-DUMP Acknowledged', + params: { commandId: 'HK-DUMP' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645'], + title: 'Safe the Uplink', + description: 'ACK in hand. Un-key modem 1 and disable the HPA. The chain goes back to cold before the log entry, not after.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['housekeeping-dump'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'Modem 1 Un-keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'log-and-hand-back', + nice: ['T0153', 'K0645'], + title: 'Log the Contact and Hand Back', + description: 'The contact log line for RAMPART: what was read, what was called, what was sent, what came back, and the state the chain was left in.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + conditions: [ + { + type: 'status-check', + description: 'Contact Logged', + params: { + question: 'Which line goes in the TALON-2 contact log?', + options: [ + '0210Z contact SANDSTORM (backup). Telemetry acquired on the monitor aperture, stream LIVE; EPS/TCS/ADCS/COMM/OBC all nominal on fresh frames; SOH NOMINAL called. HK-DUMP sent 8200 MHz, ACK. Chain safed. Handed back to RAMPART', + 'TALON-2 contact complete, no issues, spacecraft healthy', + 'Backup contact flown while the primary terminal was down; SANDSTORM confirms TALON-2 is fine and the jam chain was not used', + '0210Z: one command sent and acknowledged; telemetry looked normal', + ], + correctIndex: 0, + explanation: + 'Where the telemetry came from, what state the stream was in, what was read subsystem by subsystem, what was called, what was sent and what came back, and how the chain was left. The next operator reads that line before they touch anything.', + character: Character.SYSTEM, + pointPenalty: 2, + documentSection: 'Contact', + documentLine: + '0210Z TALON-2 backup contact, SANDSTORM. Monitor aperture on 166/50, receiver 3 on 1625 MHz IF, telemetry LIVE. EPS bus 28.1 V / SOC 92 %; TCS batt 21 degC / TWTA 58 degC; ADCS wheel 3200 rpm; COMM TP-T1 42 dBm; OBC 34 % - all nominal. SOH NOMINAL. HK-DUMP 8200 MHz ACK. Chain safed (HPA off, modem 1 un-keyed). Handed back to RAMPART.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + ], +}; diff --git a/src/campaigns/ccs/scenario4.ts b/src/campaigns/ccs/scenario4.ts new file mode 100644 index 00000000..7b486f7f --- /dev/null +++ b/src/campaigns/ccs/scenario4.ts @@ -0,0 +1,527 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { Hertz, IfFrequency, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; +import { sandstormGroundStation } from './ground-stations'; +import { cobalt4Satellite, talon2Satellite } from './satellites'; + +/** + * Campaign 4 (9th EWS / Counter Communications) - Scenario 4 "State of Health" + * + * Second TALON-2 backup contact, the night after First Shift, and the panel + * is not green. Two things move: reaction wheel 1 spikes into yellow for a + * minute at T+4:00 and comes back on its own - a transient, logged and left + * alone - and from T+7:00 the battery temperature climbs at three degrees a + * minute and does not come back. The heater that ran through the eclipse + * exit has stuck on. The state-of-health call is DEGRADED with the channel + * named and the trend stated; the response is a command (TCS-HTR-OFF) inside + * the window; the verification is the same channel turning over on the + * Telemetry tab - not the ACK. Then safe, log, hand back. + * + * Phase 18 E: evaluating state of health against expected values (9.8) as a + * decision graded on soh-yellow-limit, and command verification (9.10) as a + * telemetry read after the ACK. The excursion carries endsOnCommandId, so the + * battery only recovers once TCS-HTR-OFF has been acknowledged; a command + * sent outside the window, or to the wrong mnemonic, leaves the channel + * climbing and the verification unmet. + * + * Timeline (mission elapsed): window T+300 .. T+2400. Wheel transient + * T+240 .. T+300 (step to 5200 rpm, yellow). Battery heater from T+420: 21 to + * 38 degC over 400 s (yellow at 32 from ~T+680; red at 40 never reached + * inside the window if the heater is commanded off by ~T+900). Recovery ramps + * back to nominal over 240 s from the ACK. + * + * NICE Framework Alignment: + * Primary Codes: + * - S0648: Skill in detecting anomalies (state of health against limits) + * - T0431: Monitor telemetry and reference systems + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - T1567: Configure system hardware, software, peripheral equipment + * - S0421: Skill in operating communications equipment + * - K0645: Knowledge of standard operating procedures + * - T0153: Monitor system performance + * - S0593: Skill in handling incidents + */ + +/** SS-01 as last night left it: monitor already on TALON-2, receiver 3 on 1625, modem 1 on the TT&C IF. Deep clone, never spread. */ +const sandstormSoh: GroundStationConfig = structuredClone(sandstormGroundStation) as unknown as GroundStationConfig; +sandstormSoh.antennasState![1] = { + ...sandstormSoh.antennasState![1], + azimuth: 166.0 as Degrees, + elevation: 49.6 as Degrees, + targetAzimuth: 166.0 as Degrees, + targetElevation: 49.6 as Degrees, + beaconFrequencyHz: 7300e6 as Hertz, +}; +sandstormSoh.receivers![2].modems![0] = { ...sandstormSoh.receivers![2].modems![0], frequency: 1625 as MHz }; +sandstormSoh.transmitters![0].modems[0] = { + ...sandstormSoh.transmitters![0].modems[0], + ifSignal: { ...sandstormSoh.transmitters![0].modems[0].ifSignal, frequency: 1200e6 as IfFrequency }, +}; + +const TALON2_CHANNELS = [ + { id: 'bus-v', label: 'Main bus voltage', unit: 'V', subsystem: 'EPS', nominal: 28.1, noise: 0.2, yellowLow: 26.5, yellowHigh: 29.5, redLow: 25.5, redHigh: 30.5 }, + { id: 'batt-soc', label: 'Battery state of charge', unit: '%', subsystem: 'EPS', nominal: 88, noise: 0.4, yellowLow: 70, redLow: 55, decimals: 0 }, + { id: 'sa-i', label: 'Solar array current', unit: 'A', subsystem: 'EPS', nominal: 18.2, noise: 0.3, yellowLow: 12, yellowHigh: 24, redLow: 8, redHigh: 28 }, + { id: 'batt-t', label: 'Battery temperature', unit: 'degC', subsystem: 'TCS', nominal: 21, noise: 0.3, yellowLow: 5, yellowHigh: 32, redLow: 0, redHigh: 40 }, + { id: 'htr-state', label: 'Battery heater state', unit: '', subsystem: 'TCS', nominal: 0, yellowHigh: 0.5, decimals: 0 }, + { id: 'twta-t', label: 'TWTA baseplate temperature', unit: 'degC', subsystem: 'TCS', nominal: 58, noise: 0.5, yellowLow: 40, yellowHigh: 75, redLow: 30, redHigh: 85 }, + { + id: 'wheel-rpm', + label: 'Reaction wheel 1 speed', + unit: 'rpm', + subsystem: 'ADCS', + nominal: 3250, + noise: 30, + yellowLow: 1500, + yellowHigh: 5000, + redLow: 800, + redHigh: 6000, + decimals: 0, + }, + { id: 'sun-err', label: 'Sun sensor pointing error', unit: 'deg', subsystem: 'ADCS', nominal: 0.05, noise: 0.02, yellowHigh: 0.3, redHigh: 0.8, decimals: 2 }, + { id: 'tp-pwr', label: 'TP-T1 output power', unit: 'dBm', subsystem: 'COMM', nominal: 42, noise: 0.2, yellowLow: 38, yellowHigh: 44, redLow: 35, redHigh: 46 }, + { id: 'cpu-load', label: 'OBC processor load', unit: '%', subsystem: 'OBC', nominal: 35, noise: 2, yellowHigh: 70, redHigh: 90, decimals: 0 }, +]; + +export const ccsScenario4Data: ScenarioData = { + id: 'ccs-scenario4', + url: 'ccs/scenarios/ccs-scenario4', + imageUrl: 'nats/23/card.png', + number: 3, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['ccs-scenario3'], + title: 'State of Health', + subtitle: 'A Channel That Does Not Come Back', + duration: '25-35 min', + missionType: 'Spacecraft Operations', + description: `Second backup contact on TALON-2. The primary terminal is still down and the bird came out of eclipse forty minutes ago. Last night every channel was green and the call was easy. +

Tonight two things will move on the panel. One of them comes back on its own; one of them does not. Tell them apart, name the channel and the trend, make the call, send the command that fixes it inside the window - and then prove it worked with the telemetry, not with the ACK.`, + equipment: [ + '3-metre X-band Look-through Monitor (on TALON-2)', + '5-metre X-band Jam Antenna (cold)', + 'X-band RF Front End (7000 MHz BUC LO / 8925 MHz LNB LO)', + 'TT&C Console with Telemetry Display', + 'Dual Exciters on a Shared HPA (modem 1 on the TT&C IF)', + ], + settings: { + isSync: true, + groundStations: [sandstormSoh], + satellites: [talon2Satellite, cobalt4Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-11-09', + scenarioStartWallTime: '02:00:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-4/scenario-4?content-only=true&dark=true', + + workingDocument: { + title: 'TALON-2 Contact Log 2027-313-01', + description: 'The 0205Z backup contact: what moved, what was called, what was commanded, what the telemetry said afterwards.', + }, + + telemetry: { + groundStationId: 'SS-01', + satelliteNoradId: 90071, + antennaIndex: 1, + frameRateHz: 1, + staleAfterS: 10, + channels: TALON2_CHANNELS, + excursions: [ + // The transient: wheel 1 spikes for a minute and comes back on its own. + { id: 'wheel-transient', channelId: 'wheel-rpm', startTime: 240, duration: 60, rampToValue: 5200, label: 'Reaction wheel 1 transient' }, + // The trend: the eclipse-exit heater stays on until commanded off. + { id: 'htr-stuck-state', channelId: 'htr-state', startTime: 420, rampToValue: 1, endsOnCommandId: 'TCS-HTR-OFF', recoverySeconds: 0, label: 'Battery heater ON' }, + { + id: 'htr-stuck', + channelId: 'batt-t', + startTime: 420, + rampToValue: 38, + rampSeconds: 400, + endsOnCommandId: 'TCS-HTR-OFF', + recoverySeconds: 240, + label: 'Battery heater stuck on', + }, + ], + }, + + commanding: { + groundStationId: 'SS-01', + targetNoradId: 90071, + windowStartS: 300, + windowEndS: 2400, + requireDopplerComp: false, + requireValidKey: true, + commands: [ + { id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }, + { id: 'TCS-HTR-OFF', label: 'Battery heater OFF' }, + { id: 'TCS-HTR-ON', label: 'Battery heater ON' }, + { id: 'ADCS-MOM-DUMP', label: 'Momentum dump' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + }, + objectives: [ + { + id: 'read-the-shift-package', + nice: ['K0645', 'K1032'], + title: 'Read the Shift Package', + description: 'Open the shift package: what changed since last night (eclipse exit), which commands are in the stack, and what "verify" means.', + groundStation: 'SS-01', + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Package Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Verification Understood', + params: { + question: 'You send a command and the console says ACK. What has been verified?', + options: [ + 'That the bird received and accepted the frame. Whether it did anything is verified in telemetry: the channel the command was meant to change, changing', + 'That the command executed: an ACK is only sent after the on-board sequence completes', + 'That the uplink, key and window were good - which is the same as the command having worked', + 'Nothing until the next contact: state changes are only reported in the next housekeeping dump', + ], + correctIndex: 0, + explanation: + 'ACK is receipt. Execution is what the spacecraft does next, and the only place it shows is the telemetry. A heater-off command is verified by the temperature turning over, and by nothing else.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-telemetry', + nice: ['T0431', 'S0421'], + title: 'Acquire Telemetry', + description: 'The monitor is still on TALON-2 from last night. Open the Telemetry tab and take ten frames on a LIVE link.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['read-the-shift-package'], + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Telemetry Tab Open', + params: { tab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-frames-received', + description: 'Ten Frames Received', + params: { minFrames: 10 }, + mustMaintain: false, + }, + { + type: 'telemetry-channel-in-band', + description: 'Battery Temperature Read Nominal at Acquisition', + params: { channelId: 'batt-t', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-transient', + nice: ['S0648', 'K1032'], + title: 'Transient on Wheel 1', + description: 'At T+4:00 reaction wheel 1 jumps into yellow. Read it on the Telemetry tab while it is there, watch it come back, and say what it was.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['acquire-telemetry'], + conditions: [ + { + type: 'telemetry-channel-in-band', + description: 'Wheel Speed Read Yellow', + params: { channelId: 'wheel-rpm', telemetryBand: 'yellow', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Transient Named', + params: { + question: 'Wheel 1 went to 5200 rpm for about a minute and came back to 3250 on its own. What was it, and what do you do?', + options: [ + 'A transient: a momentum bump the wheel absorbed and unloaded on its own. Log the time and the peak; it goes in the trend, not in a command', + 'A fault: a wheel that changes speed by 2000 rpm is failing and needs a momentum dump commanded now', + 'A sensor artefact: nothing on a GEO bird moves that fast, so the channel is bad', + 'Nothing worth logging: it is back in green', + ], + correctIndex: 0, + explanation: + 'A channel that departs and returns by itself is a transient. It is real, it is logged with its time and peak, and it becomes one point in a trend that might mean something in a month. It is not a command tonight.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'spot-the-trend', + nice: ['S0648', 'T0431'], + title: 'Spot the Trend', + description: 'From T+7:00 the battery temperature climbs and does not turn over. Read it in yellow on the Telemetry tab, with the heater state channel next to it.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['the-transient'], + conditions: [ + { + type: 'telemetry-channel-in-band', + description: 'Battery Temperature Read Yellow', + params: { channelId: 'batt-t', telemetryBand: 'yellow', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-channel-in-band', + description: 'Heater State Read ON', + params: { channelId: 'htr-state', telemetryBand: 'yellow', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Trend Named', + params: { + question: 'Battery temperature 33 degC and rising about three degrees a minute; heater state ON forty minutes after eclipse exit. What is this?', + options: [ + 'A stuck heater: the eclipse-exit heater should have cycled off at 20 degC. At this rate the battery is at the 40 degC red limit inside three minutes. This is a command, tonight, now', + 'Normal eclipse-exit warming: the battery is catching up with the sun and will settle', + 'A sensor drift: the heater channel is a flag, not a temperature, and the two disagree', + 'A transient like the wheel: log it and see if it comes back', + ], + correctIndex: 0, + explanation: + 'A channel with a cause visible next to it (heater ON) and a rate that reaches harm inside the window is a trend, not a transient. The difference between the two is exactly what the state-of-health call has to say.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-state-of-health', + nice: ['S0648', 'S0593', 'K1032'], + title: 'Call the State of Health', + description: 'RAMPART wants the call. Read the whole panel - one channel yellow and climbing, the rest green - and say what it is and what you will do about it.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['spot-the-trend'], + conditions: [ + { + id: 'batt-seen', + type: 'telemetry-channel-in-band', + description: 'Battery temperature read on Telemetry', + params: { channelId: 'batt-t', telemetryBand: 'yellow', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'State of Health', + params: { + character: Character.SYSTEM, + prompt: 'RAMPART: "SANDSTORM, TALON-2 state of health?" What do you call, and what comes next?', + evidence: ['batt-seen'], + decisionOptions: [ + { + label: + 'Degraded - battery temperature in yellow and climbing at three degrees a minute, heater stuck ON since eclipse exit. Commanding TCS-HTR-OFF inside the window and verifying on the temperature trend', + correctWhen: { + all: [ + { fact: 'soh-yellow-limit', is: true }, + { fact: 'telemetry-stale', is: false }, + ], + }, + consequence: { log: 'TALON-2 state of health called DEGRADED: battery heater stuck ON; TCS-HTR-OFF to be commanded' }, + }, + { + label: 'Nominal - one channel in yellow is not a degraded bird; log it and dump housekeeping', + correctWhen: { fact: 'soh-yellow-limit', is: false }, + consequence: { log: 'TALON-2 state of health called NOMINAL with a channel in yellow' }, + feedback: + 'Nominal means every channel inside limits on fresh frames. One channel outside its limits with a cause showing and a rate that reaches red inside the window is the definition of degraded.', + }, + { + label: 'Unknown - the stream is stale and the temperature is frozen history; re-acquire before calling anything', + correctWhen: { fact: 'telemetry-stale', is: true }, + consequence: { log: 'TALON-2 state of health called UNKNOWN - stale stream' }, + feedback: 'The link reads LIVE and the frames are climbing. The temperature is not frozen; it is rising, which is the problem.', + }, + { + label: 'Critical - payload to safe mode now, before the battery reaches red', + feedback: + 'PLD-SAFE turns off the mission to protect a battery that a heater command protects just as well. Match the response to the cause: the heater is on; turn the heater off.', + }, + ], + explanation: + 'Degraded, named: the channel, the direction, the rate, the cause showing beside it, and the action. A state-of-health call that names the channel is a call somebody can act on. Then act on it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'arm-the-uplink', + nice: ['T1567', 'K0645'], + title: 'Arm the Uplink', + description: 'Modem 1 is on the 1200 MHz TT&C IF from last night. HPA up, modem 1 keyed. The window has been open since T+5:00.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['call-state-of-health'], + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'Modem 1 on 1200 MHz (8200 MHz RF)', + params: { modemNumber: 1, frequency: 1200e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + { + type: 'tx-modem-transmitting', + description: 'Modem 1 Keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'heater-off', + nice: ['T1567', 'K1032', 'S0593'], + title: 'Command the Heater Off', + description: 'TT&C console: TCS-HTR-OFF. One command, the right mnemonic, inside the window. The ACK is receipt - not the end of this.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['arm-the-uplink'], + conditions: [ + { + type: 'command-acknowledged', + description: 'TCS-HTR-OFF Acknowledged', + params: { commandId: 'TCS-HTR-OFF' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'verify-the-command', + nice: ['T0431', 'S0648', 'K1032'], + title: 'Verify the Command', + description: + 'Back to the Telemetry tab. The heater state should read OFF and the battery temperature should turn over and come back inside limits. Read it green. That is the verification.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['heater-off'], + conditions: [ + { + type: 'telemetry-channel-in-band', + description: 'Heater State Read OFF', + params: { channelId: 'htr-state', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'telemetry-channel-in-band', + description: 'Battery Temperature Back in Limits', + params: { channelId: 'batt-t', telemetryBand: 'green', requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Verification Read', + params: { + question: 'Heater OFF on the next frame; battery temperature turned over and is back under 32 degC four minutes later. Which of those verified the command?', + options: [ + 'Both, in order: the heater state changing proves the command executed; the temperature turning over proves it fixed the thing you commanded it for', + 'The ACK: it was received, so it worked', + 'The heater state alone: the temperature is a consequence and would have come down anyway once the eclipse warming ended', + 'Neither: verification needs a housekeeping dump and a comparison with the previous contact', + ], + correctIndex: 0, + explanation: + 'A command is verified twice: the direct effect (the state it was meant to change) and the reason you sent it (the trend it was meant to stop). Tonight both are on the same panel, four minutes apart. Log both times.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645'], + title: 'Safe the Uplink', + description: 'Verified. Un-key modem 1 and disable the HPA before the log entry.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['verify-the-command'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'Modem 1 Un-keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'log-and-hand-back', + nice: ['T0153', 'K0645'], + title: 'Log the Contact and Hand Back', + description: 'The contact log line: the transient, the trend, the call, the command, the verification, the state the chain was left in.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + conditions: [ + { + type: 'status-check', + description: 'Contact Logged', + params: { + question: 'Which line goes in the TALON-2 contact log?', + options: [ + '0205Z contact SANDSTORM (backup). Telemetry LIVE. T+4:00 wheel 1 transient to 5200 rpm, self-cleared, logged. T+7:00 battery temperature climbing ~3 degC/min, heater ON (stuck after eclipse exit); SOH DEGRADED called. TCS-HTR-OFF sent, ACK; heater OFF next frame; temperature turned over, back in limits. Chain safed. TCS anomaly report to RAMPART', + 'TALON-2 contact: one anomaly, resolved by command', + 'Battery over-temperature emergency on TALON-2 averted by SANDSTORM; heater failure suspected; spacecraft at risk until repaired', + '0205Z: wheel and battery anomalies; heater commanded off; bird healthy', + ], + correctIndex: 0, + explanation: + 'Two events, each with its time, its reading, what was called, what was done, and how it was proven. "Resolved" is a conclusion; "turned over, back in limits" is what the panel said. The heater still needs an engineer - that is the report, not the log.', + character: Character.SYSTEM, + pointPenalty: 2, + documentSection: 'Contact', + documentLine: + '0205Z TALON-2 backup contact, SANDSTORM. Telemetry LIVE. T+4:00 wheel 1 transient to 5200 rpm, self-cleared in 60 s, logged. T+7:00 battery temperature climbing ~3 degC/min with heater ON (stuck after eclipse exit); SOH DEGRADED. TCS-HTR-OFF 8200 MHz ACK; heater OFF on the next frame; battery temperature turned over and returned inside limits. Chain safed. TCS heater anomaly reported to RAMPART for engineering.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/ccs/scenario5.ts b/src/campaigns/ccs/scenario5.ts new file mode 100644 index 00000000..0a16ce1c --- /dev/null +++ b/src/campaigns/ccs/scenario5.ts @@ -0,0 +1,432 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { Hertz, IfFrequency, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; +import { sandstormGroundStation } from './ground-stations'; +import { cobalt4Satellite, TALON2_TLE, talon2Satellite } from './satellites'; + +/** + * Campaign 4 (9th EWS / Counter Communications) - Scenario 5 "Ranging Pass" + * + * Third TALON-2 backup contact. The spacecraft engineers (callsign LANTERN) + * have an east-west station-keeping burn scheduled at 02:14Z and want a + * ranging arc from SANDSTORM around it: three ranging tones before the burn, + * the post-burn element set loaded when it is issued, three tones after. + * Each tone goes up the command path like a command, and when it ACKs the + * console records the true slant range to the bird. The step in range + * between the last pre-burn tone and the first post-burn tone, with the bird + * still in the beam, is the burn - and the reason the pass schedule reads + * EPHEMERIS STALE until the new set is loaded. + * + * Phase 18 E: ranging and tracking to refine a position (9.11) as a + * measurement the operator takes, a step they read, and a set they load. + * Reuses the M4 ephemeris mechanic on a GEO bird: initialTle is the pre-burn + * set (mean anomaly -0.10 deg, ~1.5 km of slant range from SANDSTORM), the + * bird moves onto the authored set at T+840, the station predicts from the + * old one until the update is loaded. + * + * Timeline (mission elapsed): window T+300 .. T+2400. Burn T+840. Ranging + * needs six ACKed tones for a solution (settings.commanding.ranging). + * + * NICE Framework Alignment: + * Primary Codes: + * - K1032: Knowledge of satellite-based communication systems (orbit determination inputs) + * - T1138: Update ephemeris / orbital data + * Supporting Codes: + * - T0431: Monitor timing and telemetry reference systems + * - S0421: Skill in operating communications equipment + * - T1567: Configure system hardware, software, peripheral equipment + * - K0645: Knowledge of standard operating procedures + * - T0153: Monitor system performance + * - S0648: Skill in detecting anomalies + */ + +/** SS-01 as last night left it: monitor on TALON-2, receiver 3 on 1625, modem 1 on the TT&C IF. Deep clone, never spread. */ +const sandstormRanging: GroundStationConfig = structuredClone(sandstormGroundStation) as unknown as GroundStationConfig; +sandstormRanging.antennasState![1] = { + ...sandstormRanging.antennasState![1], + azimuth: 166.0 as Degrees, + elevation: 49.6 as Degrees, + targetAzimuth: 166.0 as Degrees, + targetElevation: 49.6 as Degrees, + beaconFrequencyHz: 7300e6 as Hertz, +}; +sandstormRanging.receivers![2].modems![0] = { ...sandstormRanging.receivers![2].modems![0], frequency: 1625 as MHz }; +sandstormRanging.transmitters![0].modems[0] = { + ...sandstormRanging.transmitters![0].modems[0], + ifSignal: { ...sandstormRanging.transmitters![0].modems[0].ifSignal, frequency: 1200e6 as IfFrequency }, +}; + +/** The pre-burn set the station predicts from at 02:00 (mean anomaly -0.10 deg: ~1.5 km of slant range). */ +const TALON2_PRE_BURN_TLE2 = '2 90071 0.0500 147.0000 0001000 90.0000 89.9000 1.00273791123450'; + +const TALON2_CHANNELS = [ + { id: 'bus-v', label: 'Main bus voltage', unit: 'V', subsystem: 'EPS', nominal: 28.0, noise: 0.2, yellowLow: 26.5, yellowHigh: 29.5, redLow: 25.5, redHigh: 30.5 }, + { id: 'batt-soc', label: 'Battery state of charge', unit: '%', subsystem: 'EPS', nominal: 90, noise: 0.4, yellowLow: 70, redLow: 55, decimals: 0 }, + { id: 'batt-t', label: 'Battery temperature', unit: 'degC', subsystem: 'TCS', nominal: 20, noise: 0.3, yellowLow: 5, yellowHigh: 32, redLow: 0, redHigh: 40 }, + { id: 'prop-p', label: 'Propellant tank pressure', unit: 'bar', subsystem: 'PROP', nominal: 14.2, noise: 0.05, yellowLow: 9, redLow: 6 }, + { + id: 'wheel-rpm', + label: 'Reaction wheel 1 speed', + unit: 'rpm', + subsystem: 'ADCS', + nominal: 3300, + noise: 30, + yellowLow: 1500, + yellowHigh: 5000, + redLow: 800, + redHigh: 6000, + decimals: 0, + }, + { id: 'tp-pwr', label: 'TP-T1 output power', unit: 'dBm', subsystem: 'COMM', nominal: 42, noise: 0.2, yellowLow: 38, yellowHigh: 44, redLow: 35, redHigh: 46 }, + { id: 'cpu-load', label: 'OBC processor load', unit: '%', subsystem: 'OBC', nominal: 36, noise: 2, yellowHigh: 70, redHigh: 90, decimals: 0 }, +]; + +export const ccsScenario5Data: ScenarioData = { + id: 'ccs-scenario5', + url: 'ccs/scenarios/ccs-scenario5', + imageUrl: 'nats/24/card.png', + number: 4, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['ccs-scenario4'], + title: 'Ranging Pass', + subtitle: 'A Burn, a Step, and a New Set', + duration: '25-35 min', + missionType: 'Spacecraft Operations', + description: `Third backup contact on TALON-2, and this one the engineers asked for. LANTERN has an east-west station-keeping burn at 0214Z and wants a ranging arc around it from SANDSTORM: tones before, the post-burn set loaded when it is issued, tones after. +

A ranging tone goes up like a command and comes back with a number: the distance to the bird, to a tenth of a kilometre. Take three. Watch the burn happen as a step in that number with the bird still in the beam. Load the set that explains the step. Take three more. Hand LANTERN a solution.`, + equipment: [ + '3-metre X-band Look-through Monitor (on TALON-2)', + '5-metre X-band Jam Antenna (cold)', + 'X-band RF Front End (7000 MHz BUC LO / 8925 MHz LNB LO)', + 'TT&C Console with Ranging Tone and Telemetry Display', + 'Pass Schedule with Ephemeris Inbox', + ], + settings: { + isSync: true, + groundStations: [sandstormRanging], + satellites: [talon2Satellite, cobalt4Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-11-10', + scenarioStartWallTime: '02:00:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-4/scenario-5?content-only=true&dark=true', + + workingDocument: { + title: 'TALON-2 Contact Log 2027-314-01', + description: 'The 0205Z ranging contact: the arc, the burn, the set, the solution.', + }, + + telemetry: { + groundStationId: 'SS-01', + satelliteNoradId: 90071, + antennaIndex: 1, + frameRateHz: 1, + staleAfterS: 10, + channels: TALON2_CHANNELS, + }, + + commanding: { + groundStationId: 'SS-01', + targetNoradId: 90071, + windowStartS: 300, + windowEndS: 2400, + requireDopplerComp: false, + requireValidKey: true, + ranging: { requiredMeasurements: 6, toneId: 'RANGE' }, + commands: [{ id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }], + }, + + // M4 on a GEO bird: the burn moves TALON-2 onto the authored set at T+840; + // the station keeps predicting from the pre-burn set until the update is loaded. + spaceEvents: [ + { + id: 'TALON2-SK', + satelliteNoradId: 90071, + maneuverAtS: 840, + label: 'TALON-2 east-west station-keeping burn executed 02:14Z; post-burn element set issued by LANTERN', + newTle: { tle1: TALON2_TLE.tle1 as string, tle2: TALON2_TLE.tle2 as string }, + initialTle: { tle1: TALON2_TLE.tle1 as string, tle2: TALON2_PRE_BURN_TLE2 }, + }, + ], + }, + objectives: [ + { + id: 'read-the-shift-package', + nice: ['K0645', 'K1032'], + title: 'Read the Shift Package', + description: "Open the shift package: the burn, the arc LANTERN wants, and what a single station's ranging can and cannot tell an orbit-determination team.", + groundStation: 'SS-01', + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Package Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Ranging Understood', + params: { + question: 'Why does LANTERN want three tones before the burn and three after, rather than one good one?', + options: [ + 'A single range is one number on one line of sight. A series over time, either side of a known event, is what constrains an orbit - and the step between the two halves is the burn, measured', + 'Redundancy: tones fail often and three gives one good one', + 'The tone has to be repeated until the ACK confirms the bird heard it', + 'Averaging: six ranges average out the noise on the tone and give a more precise single distance', + ], + correctIndex: 0, + explanation: + 'Orbit determination is a fit to measurements over time. One range from one station is nearly useless; a series from a known slot, with the pointing angles the tracker already knows, is an arc. Two arcs across a burn is the burn itself, measured from the ground.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-telemetry', + nice: ['T0431', 'S0648'], + title: 'Acquire Telemetry', + description: 'Monitor on the bird from last night. Telemetry tab: ten frames on a LIVE link and a nominal state of health before anything goes up.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['read-the-shift-package'], + conditions: [ + { + type: 'telemetry-frames-received', + description: 'Ten Frames Received', + params: { minFrames: 10 }, + mustMaintain: false, + }, + { + type: 'telemetry-soh-nominal', + description: 'State of Health Read Nominal', + params: { requiresObservation: true, observationTab: 'telemetry' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'arm-the-uplink', + nice: ['T1567', 'K0645'], + title: 'Arm the Uplink', + description: 'Modem 1 on the 1200 MHz TT&C IF. HPA up, modem 1 keyed. The window opens at T+5:00.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['acquire-telemetry'], + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'Modem 1 on 1200 MHz (8200 MHz RF)', + params: { modemNumber: 1, frequency: 1200e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + { + type: 'tx-modem-transmitting', + description: 'Modem 1 Keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'pre-burn-arc', + nice: ['K1032', 'S0421'], + title: 'Pre-Burn Arc', + description: 'TT&C console: three ranging tones inside the window, before 02:14Z. Each ACK records the slant range; watch the number.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['arm-the-uplink'], + conditions: [ + { + type: 'ranging-measurements', + description: 'Three Ranges Recorded', + params: { minCount: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'watch-the-burn', + nice: ['S0648', 'K1032'], + title: 'Watch the Burn', + description: + 'After 02:14Z take one more tone. The range steps by about a kilometre and a half with the bird still in the beam and the telemetry still LIVE. Say what that is.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['pre-burn-arc'], + conditions: [ + { + type: 'ranging-measurements', + description: 'Post-Burn Range Recorded', + params: { minCount: 4 }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Step Read', + params: { + question: 'The fourth range is about 1.5 km longer than the third, the monitor is still on the bird, telemetry is LIVE and nominal. What happened?', + options: [ + 'The burn: the bird moved along its orbit, the slant range from here changed with it, and the set the station is predicting from is now stale - the pass schedule will say so', + 'A tracking error: the monitor drifted off the bird and the tone took a longer path', + 'A tone fault: the ranging tone returned late and the range is wrong; discard it', + "Nothing: 1.5 km on 37,000 is inside the tone's own noise", + ], + correctIndex: 0, + explanation: + 'Range that steps while pointing and telemetry hold steady is the spacecraft moving, not the station. At GEO distance a tenth of a degree of longitude is a kilometre and a half of slant range from here - and the element set you had is now a description of where the bird used to be.', + character: Character.SYSTEM, + pointPenalty: 2, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'load-the-ephemeris', + nice: ['T1138', 'K1032'], + title: 'Load the Post-Burn Set', + description: + "Pass Schedule tab: the TALON-2 entry reads EPHEMERIS STALE and LANTERN's post-burn set is in the inbox. Load it. The prediction and the measurement agree again.", + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['watch-the-burn'], + conditions: [ + { + type: 'ephemeris-updated', + description: 'Post-Burn Ephemeris Loaded', + params: { eventId: 'TALON2-SK' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'post-burn-arc', + nice: ['K1032', 'S0421'], + title: 'Post-Burn Arc', + description: 'Two more tones on the new set. Six ranges either side of a known burn is the arc LANTERN asked for.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['load-the-ephemeris'], + conditions: [ + { + type: 'ranging-measurements', + description: 'Six Ranges Recorded (solution ready)', + params: { minCount: 6 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'od-handoff', + nice: ['K1032', 'T0153'], + title: 'Hand Off the Solution', + description: 'What goes to LANTERN: the six ranges with their times, the burn between the third and fourth, and the set they were taken against.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['post-burn-arc'], + conditions: [ + { + type: 'status-check', + description: 'Solution Handed Off', + params: { + question: 'What does the ranging hand-off to LANTERN contain?', + options: [ + 'Six timed ranges from SANDSTORM, three pre-burn on the old set and three post-burn on the new one, the ~1.5 km step at 02:14Z, and the pointing angles the tracker held throughout', + 'The average of the six ranges and the time of the contact', + 'The post-burn element set, which already contains the orbit', + 'A single best range from after the burn, since the pre-burn ones describe an orbit that no longer exists', + ], + correctIndex: 0, + explanation: + 'The engineers fit an orbit to measurements; give them all of the measurements, when they were taken, what they were taken against, and the event between them. The pre-burn arc is not obsolete - it is the "before" that makes the burn measurable.', + character: Character.SYSTEM, + pointPenalty: 2, + documentSection: 'Ranging', + documentLine: + 'Ranging arc for LANTERN: 3 tones pre-burn (pre-burn set), station-keeping burn 02:14Z observed as a ~1.5 km step in slant range with pointing and telemetry steady, post-burn set loaded from the inbox, 3 tones post-burn. Six timed ranges and the held pointing angles handed off.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645'], + title: 'Safe the Uplink', + description: 'Arc complete. Un-key modem 1 and disable the HPA before the log entry.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['od-handoff'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'Modem 1 Un-keyed', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { equipmentIndex: 0 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'log-and-hand-back', + nice: ['T0153', 'K0645'], + title: 'Log the Contact and Hand Back', + description: + 'The contact log line: telemetry state, the arc, the burn, the set, the hand-off, the chain state. The last backup contact before the primary terminal comes back.', + groundStation: 'SS-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + conditions: [ + { + type: 'status-check', + description: 'Contact Logged', + params: { + question: 'Which line goes in the TALON-2 contact log?', + options: [ + '0205Z contact SANDSTORM (backup). Telemetry LIVE, SOH NOMINAL. Ranging arc: 3 tones pre-burn; burn 02:14Z seen as ~1.5 km range step; post-burn set loaded; 3 tones post-burn; arc handed to LANTERN. Chain safed. Primary terminal returns 0600Z', + 'Ranging pass complete; orbit updated', + 'TALON-2 manoeuvred during the contact; the station lost track briefly and recovered after loading a new element set', + '0205Z: six ranges taken and sent to the engineers', + ], + correctIndex: 0, + explanation: + 'The station never lost track; the set went stale, which is a different sentence. What was measured, when, against what, what was seen, what was loaded, what was handed off, how the chain was left. Three contacts, three logs, one bird handed back healthy.', + character: Character.SYSTEM, + pointPenalty: 2, + documentSection: 'Contact', + documentLine: + '0205Z TALON-2 backup contact, SANDSTORM. Telemetry LIVE, SOH NOMINAL. Ranging arc: tones 1-3 pre-burn; station-keeping burn 02:14Z observed as ~1.5 km slant-range step with pointing steady; post-burn set loaded; tones 4-6 post-burn; six timed ranges handed to LANTERN. Chain safed. Primary MILSATCOM terminal returns to service 0600Z; SANDSTORM backup role ends.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/chrome-variant.ts b/src/campaigns/chrome-variant.ts new file mode 100644 index 00000000..c8077fe8 --- /dev/null +++ b/src/campaigns/chrome-variant.ts @@ -0,0 +1,24 @@ +import { CampaignManager } from '@app/campaigns/campaign-manager'; +import { ChromeVariant } from '@app/campaigns/campaign-types'; +import { ScenarioManager } from '@app/scenario-manager'; + +/** + * Chrome variant of the campaign that owns the running scenario. + * + * Most of the variant is applied as CSS through the `chrome-` body class + * the Router applies. This is for the handful of places where the difference is + * *content* rather than style - the command bar's clock format and timer labels + * - and cannot be expressed in a stylesheet. + * + * Falls back to 'standard' when there is no scenario (menus, sandbox boot), + * matching how the command bar already resolves its header identity. + */ +export function activeChromeVariant(): ChromeVariant { + try { + const scenarioId = ScenarioManager.getInstance().data.id; + + return CampaignManager.getInstance().getCampaignForScenario(scenarioId)?.chromeVariant ?? 'standard'; + } catch { + return 'standard'; + } +} diff --git a/src/campaigns/ham-sdr/ground-stations.ts b/src/campaigns/ham-sdr/ground-stations.ts new file mode 100644 index 00000000..ec6b29b0 --- /dev/null +++ b/src/campaigns/ham-sdr/ground-stations.ts @@ -0,0 +1,424 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { type AntennaState } from '@app/equipment/antenna'; +import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; +import { type CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import type { RFFrontEndState } from '@app/equipment/rf-front-end/rf-front-end-core'; +import type { dB, dBm, Hertz, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; + +/** + * Campaign 3 (Backyard Operator) - Riley's backyard stations, Burlington VT. + * + * Three receive-only hobbyist setups sharing one yard. Each is a full + * GroundStationConfig so the existing equipment pipeline works unchanged, + * but with the Campaign 3 opt-ins: + * - stationClass: 'backyard' -> SDR Console tab, no TX chain tab + * - lnb.isDirectSampling -> RF passes through unmixed (RTL-SDR style) + * - omt rx/tx 'RHCP' -> circular pass-through (handedness modeled + * at the antenna feed, not the OMT) + */ + +/** + * Shared RF front end for a budget SDR dongle: direct sampling, no real TX path. + * + * @param filterBandwidthIndex Index into FILTER_BANDWIDTH_CONFIGS. The filter + * drives the site noise floor (narrower = lower), and the backyard signals are + * ~15 dB weaker at the AGC output than the teleport campaigns' - a narrowband + * downlink needs a narrowband receive filter or it drowns in the noise gate. + * Index 6 = 200 kHz (VHF/UHF birds; insertion loss 3.0 stays under the alarm + * threshold), index 10 = 5 MHz (GPS L1 spread spectrum). + */ +const makeSdrFrontEnd = (filterBandwidthIndex: number, filterBandwidthMhz: number): Partial => ({ + omt: { + isPowered: true, + txPolarization: 'RHCP', + rxPolarization: 'RHCP', + effectiveTxPol: 'RHCP', + effectiveRxPol: 'RHCP', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: false, // receive-only: no upconverter + isMuted: true, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 0 as MHz, + filterHighHz: 450e6 as Hertz, + filterLowHz: 130e6 as Hertz, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 0 as dB, + outputPower: -60 as dBm, + saturationPower: 0 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: false, // no power amplifier in the backyard + backOff: 10, + outputPower: 0 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 25, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 0 as dB, + }, + filter: { + isPowered: true, + // bandwidth/insertionLoss/noiseFloor are recomputed from the index each + // update (updateFilterCharacteristics_), so only the index matters here + bandwidthIndex: filterBandwidthIndex, + bandwidth: filterBandwidthMhz as MHz, + insertionLoss: 3.0, + noiseFloor: -121, + }, + lnb: { + isPowered: true, + isDirectSampling: true, // SDR dongle: RF frequency = IF frequency + loFrequency: 0 as MHz, // unused in direct-sampling mode + // LNA + SDR front-end gain, driven by the console's RF GAIN slider. With + // the AGC bypassed this sets the ADC input level directly: 79 dB puts a + // mid-pass bird at the ~-30 dBm sweet spot; crank it and the ADC clips, + // starve it and quantization noise eats the C/N (real RTL-SDR behavior). + gain: 79 as dB, + lnaNoiseFigure: 1.0, // budget hardware (right at the alarm threshold) + mixerNoiseFigure: 16.0, + noiseTemperature: 110, + noiseTemperatureStabilizationTime: 0, + isExtRefLocked: true, + noiseFloor: -140, + frequencyError: 0, + temperature: 28, + thermalStabilizationTime: 0, + }, + agc: { + isPowered: true, + // Bypassed: a bare SDR dongle has no AGC stage, so the RF GAIN slider has + // real consequences at the ADC (clipping high, quantization low) + isBypassed: true, + targetLevel: -30 as dBm, + currentGain: 0 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, + couplingFactorB: -39, + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 8, + utcAccuracy: 50, + constellation: 'GPS', + lockDuration: 3600, + frequencyAccuracy: 2e-11, + allanDeviation: 1e-11, + phaseNoise: -110, + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 1, + max10MHzOutputs: 2, + output10MHzLevel: 7, + ppsOutputsEnabled: true, + operatingHours: 1200, + selfTestPassed: true, + agingRate: 1e-10, + }, +}); + +/** QFH on a fence post: fixed skyward, catches the 137 MHz weather birds */ +export const backyardQfhStation = { + id: 'BKYD-QFH', + name: 'Weather Rig (QFH)', + stationClass: 'backyard', + location: { + latitude: 44.48, + longitude: -73.21, + elevation: 50, + }, + antennas: [ANTENNA_CONFIG_KEYS.VHF_QFH_137], + antennaConfigKey: ANTENNA_CONFIG_KEYS.VHF_QFH_137, + antennasState: [ + { + isPowered: true, + azimuth: 0 as Degrees, + elevation: 90 as Degrees, // zip-tied pointing straight up + polarization: 0 as Degrees, + circularHandedness: 'RHCP', + trackingMode: 'manual', + targetAzimuth: 0 as Degrees, + targetElevation: 90 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 137.1e6 as Hertz, + beaconSearchBwHz: 100e3, // covers +/-3 kHz VHF Doppler with margin + beaconTrackingBwHz: 1e3, + isLocked: false, + } as Partial, + ], + rfFrontEnds: [makeSdrFrontEnd(6, 0.2)], // 200 kHz filter for the 34 kHz APT downlink + spectrumAnalyzers: [ + { + referenceLevel: -60 as dBm, + centerFrequency: 137.1e6 as Hertz, + span: 200e3 as Hertz, + rbw: 1e3 as Hertz, + minAmplitude: -75 as dBm, + maxAmplitude: -25 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + transmitters: [{}], // single idle default transmitter; nothing in the yard transmits + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 137.1 as MHz, // direct sampling: IF = RF + bandwidth: 0.05 as MHz, + modulation: 'BPSK', + fec: '1/2', + antenna_id: 1, + }, + ], + }, + ], +} as GroundStationConfig; + +/** Crossed yagi on a TV rotator: the 70cm bird chaser with the handedness switch */ +export const backyardYagiStation = { + id: 'BKYD-YAGI', + name: 'Yagi Rig (70cm)', + stationClass: 'backyard', + location: { + latitude: 44.48, + longitude: -73.21, + elevation: 50, + }, + antennas: [ANTENNA_CONFIG_KEYS.UHF_CROSSED_YAGI_70CM], + antennaConfigKey: ANTENNA_CONFIG_KEYS.UHF_CROSSED_YAGI_70CM, + antennasState: [ + { + isPowered: true, + azimuth: 180 as Degrees, // parked; operator program-tracks the pass + elevation: 5 as Degrees, + polarization: 0 as Degrees, + circularHandedness: 'RHCP', + trackingMode: 'manual', + targetAzimuth: 180 as Degrees, + targetElevation: 5 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 435.25e6 as Hertz, + beaconSearchBwHz: 100e3, // covers +/-10 kHz UHF Doppler + beaconTrackingBwHz: 1e3, + isLocked: false, + } as Partial, + ], + rfFrontEnds: [makeSdrFrontEnd(6, 0.2)], // 200 kHz filter: 15 kHz FM channel + Doppler excursion + spectrumAnalyzers: [ + { + referenceLevel: -60 as dBm, + centerFrequency: 435.25e6 as Hertz, + span: 100e3 as Hertz, // Doppler drift is plainly visible at this span + rbw: 1e3 as Hertz, + minAmplitude: -75 as dBm, + maxAmplitude: -25 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + transmitters: [{}], + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 435.25 as MHz, + bandwidth: 0.03 as MHz, // +/-10 kHz Doppler slides outside +/-15 kHz: chase it or use AFC + modulation: 'QPSK', + fec: '1/2', + antenna_id: 1, + }, + ], + }, + ], +} as GroundStationConfig; + +/** + * S8 "Callsign" variant of the yagi rig: same antenna and receiver, plus the + * TX side of the SDR transceiver powered up - a ~5 W brick amplifier (HPA + * maxOutputPower 37 dBm) behind the always-on upconverter (BUC LO 0, + * 130-450 MHz passband, so RF = IF at 435 MHz). BUC boots powered AND + * unmuted: the HPA-without-drive insta-fail checks exactly those two flags, + * and a ham rig has no separate BUC/HPA panels to sequence them from. + * The TX modem boots parked below the transponder passband - entering the + * uplink frequency is S8's first-transmission objective. + */ +const makeTxFrontEnd = (): Partial => { + const frontEnd = makeSdrFrontEnd(6, 0.2); + return { + ...frontEnd, + buc: { ...frontEnd.buc, isPowered: true, isMuted: false }, + hpa: { + ...frontEnd.hpa, + isPowered: true, + isHpaEnabled: true, + isHpaSwitchEnabled: true, + backOff: 3, + maxOutputPower: 37 as dBm, // ~5 W brick + p1db: 34 as dBm, + }, + }; +}; + +export const backyardTxStation = { + ...backyardYagiStation, + name: 'Yagi Rig (70cm + TX)', + rfFrontEnds: [makeTxFrontEnd()], + transmitters: [ + { + activeModem: 1, + modems: [ + { + modem_number: 1, + isPowered: true, + isTransmitting: false, + ifSignal: { + frequency: 435.8e6, // parked off the transponder; S8 has you set 435.900 + power: -20 as dBm, // drive level; the brick amp does the rest + bandwidth: 15e3, // SSTV-ish channel, fits the 30 kHz transponder + modulation: 'QPSK', + fec: '1/2', + feed: 'blue-2.mp4', // your own SSTV frame, seen again on the downlink + polarization: null, + }, + }, + ], + }, + ], +} as GroundStationConfig; + +/** GPS patch on a paint-stick mast: L1 detection experiment */ +export const backyardGpsStation = { + id: 'BKYD-GPS', + name: 'GPS Experiment (Patch)', + stationClass: 'backyard', + location: { + latitude: 44.48, + longitude: -73.21, + elevation: 50, + }, + antennas: [ANTENNA_CONFIG_KEYS.L_BAND_GPS_PATCH], + antennaConfigKey: ANTENNA_CONFIG_KEYS.L_BAND_GPS_PATCH, + antennasState: [ + { + isPowered: true, + azimuth: 0 as Degrees, + elevation: 90 as Degrees, // fixed skyward + polarization: 0 as Degrees, + circularHandedness: 'RHCP', + trackingMode: 'manual', + targetAzimuth: 0 as Degrees, + targetElevation: 90 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 1575.42e6 as Hertz, + beaconSearchBwHz: 2e6, + beaconTrackingBwHz: 100e3, + isLocked: false, + } as Partial, + ], + rfFrontEnds: [makeSdrFrontEnd(10, 5)], // 5 MHz filter for the 2 MHz spread-spectrum hump + spectrumAnalyzers: [ + { + referenceLevel: -60 as dBm, + centerFrequency: 1575.42e6 as Hertz, + span: 8e6 as Hertz, // wide: the whole 2 MHz spread-spectrum hump in view + rbw: 30e3 as Hertz, + minAmplitude: -75 as dBm, + maxAmplitude: -25 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + transmitters: [{}], + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 1575.42 as MHz, + bandwidth: 2.5 as MHz, + // Deliberately mismatched to the spread-spectrum signal: GPS is detected + // as a carrier/energy rise but can never "lock" like a comms downlink + modulation: 'BPSK', + fec: '1/2', + antenna_id: 1, + }, + ], + }, + ], +} as GroundStationConfig; diff --git a/src/campaigns/ham-sdr/sandbox.ts b/src/campaigns/ham-sdr/sandbox.ts new file mode 100644 index 00000000..b66a1160 --- /dev/null +++ b/src/campaigns/ham-sdr/sandbox.ts @@ -0,0 +1,212 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardGpsStation, backyardQfhStation, backyardYagiStation } from './ground-stations'; +import { cubehop1Satellite, navstar77Satellite, wxsat19Satellite } from './satellites'; + +/** + * Campaign 3 (Backyard Operator) - Sandbox: "Riley's Backyard" + * + * Mechanics testbed for the amateur campaign. Guided objectives exercise every + * new Campaign 3 mechanic end to end (missionType 'Sandbox' keeps it out of + * campaign progress): + * - Fixed-gain DIY antennas (QFH / crossed yagi / GPS patch) at VHF/UHF/L-band + * - Direct-sampling SDR chain (LNB mixer bypass) + SDR Console tab + * - Circular polarization handedness switch on the crossed yagi + * - Doppler chasing / AFC on the 70cm FM bird + * - SatNOGS-style Observations list (pass schedule reskin) + * - GPS L1 spread-spectrum detection from a MEO bird + * + * Pass timeline (scenario clock starts 2027-06-19 16:00:00 UTC): + * - WXSAT-19: AOS T+3.0 min, max el 55.0 deg T+10.6, LOS T+18.2 min + * - CUBEHOP-1: AOS T+18.0 min, max el 48.2 deg T+24.2, LOS T+30.5 min + * - NAVSTAR-77: already at el 89 deg, drifts slowly (MEO) - visible all session + */ +export const hamSdrSandboxData: ScenarioData = { + id: 'ham-sdr-sandbox', + url: 'ham-sdr/scenarios/ham-sdr-sandbox', + imageUrl: 'nats/8/card.png', + number: 0, + isDisabled: false, + difficulty: 'beginner', + title: "Riley's Backyard", + subtitle: 'DIY Satellite Tracking', + duration: 'Unlimited', + missionType: 'Sandbox', + description: `Charlie's niece Riley (KD2RLY) has turned the family backyard in Burlington into a satellite ground station built from scrap: a hand-wound quadrifilar helix on a fence post, a crossed yagi bolted to an old TV rotator, and a GPS patch antenna taped to a paint stick. Total budget: about $80 and one weekend. +

No mission control, no shift supervisor - just an SDR dongle, a laptop running SkyWatcher, and physics. Everything runs from the SDR Console: tuning, polarization, the rotator, the decoder. Catch a weather satellite with the QFH, chase a cubesat's Doppler on the yagi (mind the polarization switch), and find the GPS constellation hiding under the noise floor.`, + equipment: ['DIY 137 MHz Quadrifilar Helix', 'DIY 70cm Crossed Yagi on TV Rotator', 'GPS L1 Patch Antenna', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console'], + settings: { + isSync: true, + groundStations: [backyardQfhStation, backyardYagiStation, backyardGpsStation], + satellites: [wxsat19Satellite, cubehop1Satellite, navstar77Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-19', + scenarioStartWallTime: '16:00:00', + }, + objectives: [ + { + id: 'check-observations', + title: 'Check the Observations List', + description: + 'Open the Observations tab and see what is coming over the horizon. WXSAT-19 rises in about three minutes; note its AOS time. NAVSTAR-77 is already overhead - MEO birds hang around for hours, not minutes.', + groundStation: 'BKYD-QFH', + conditions: [ + { + type: 'tab-active', + description: 'Observations Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'wx-first-contact', + title: 'First Contact: Decode the Weather Bird', + description: + 'The QFH needs no pointing - it stares straight up and its beam covers most of the sky. On the Weather Rig, watch 137.100 MHz on the SDR Console as WXSAT-19 rises, and lock the APT downlink. VHF Doppler is only a few kHz, so the channel holds it without retuning. The channel BW must bracket the signal, like a modem symbol rate: the APT signal is 34 kHz wide, so the stock 50 kHz channel works - much narrower or wider than the signal and the demodulator drops out (watch the lock indicator, it tells you which way you are off).', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['check-observations'], + conditions: [ + { + type: 'signal-detected', + description: 'WXSAT-19 APT Detected at 137.1 MHz', + params: { + signalId: 'WXSAT-19-APT', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'APT Downlink Locked', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'yagi-track-cubehop', + title: 'Track CUBEHOP-1 with the Yagi', + description: + 'CUBEHOP-1 rises at T+18 min. On the Yagi Rig, select CUBEHOP-1 in the SDR Console rotator panel and engage TRACK so the yagi follows the bird. The crossed yagi has a switchable feed: CUBEHOP-1 transmits right-hand circular (RHCP) - pick the wrong handedness and you throw away ~18 dB.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['check-observations'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'CUBEHOP-1 Downlink Detected at 435.25 MHz', + params: { + signalId: 'CUBEHOP-1-FM', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'doppler-chase', + title: 'Chase the Doppler', + description: + 'At 435 MHz the Doppler shift runs +/-10 kHz across the pass - more than the 15 kHz channel can swallow. Watch the carrier slide across the waterfall and keep the VFO on it with the tune buttons (or discover the AFC checkbox). Hold the lock with decent C/N.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['yagi-track-cubehop'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'FM Downlink Locked Through the Drift', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 10 dB (correct handedness required)', + params: { modemNumber: 1, minCNRatio: 10, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'gps-detect', + title: 'Find GPS Under the Noise', + description: + 'Switch to the GPS Experiment rig. NAVSTAR-77 is nearly overhead, but you will not see a carrier: GPS L1 is spread-spectrum, a 2 MHz-wide bump barely above the noise floor. That is the whole point - the signal is below the noise until a receiver despreads it. Spot the hump around 1575.42 MHz.', + groundStation: 'BKYD-GPS', + isOptional: true, + prerequisiteObjectiveIds: ['check-observations'], + conditions: [ + { + type: 'signal-detected', + description: 'L1 Spread-Spectrum Energy Detected', + params: { + signalId: 'NAVSTAR-77-L1', + minPower: -120 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

Hey! You must be the one Uncle Charlie keeps talking about. I'm Riley - KD2RLY. Welcome to my ground station! Okay, it's a fence post, a TV rotator from the dump, and eighty dollars of SDR dongle, but it hears actual satellites, which makes it a ground station.

Uncle Charlie tracks birds with a nine-meter dish and a control room. We're going to do the same physics with coat hangers. First rule of backyard tracking: you don't need big iron, you need to know when to listen and where to point. Pull up the Observations list and let's see what's flying over.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + objectives: { + 'check-observations': { + text: `

See it? WXSAT-19 comes over the horizon in about three minutes - that's a weather satellite screaming along at seven and a half kilometers a second. It'll be gone again in fifteen.

And look at NAVSTAR-77: it's basically parked overhead. That's a GPS bird in MEO, twenty thousand kilometers up. Higher orbit, slower sky. We'll mess with that one later.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'wx-first-contact': { + text: `

YES! Hear that? Okay, you can't hear it, but LOOK at it on the waterfall - that's a satellite talking to your fence post!

The QFH is the secret: it's circularly polarized and sees almost the whole sky at once, so no rotator needed. And at 137 megahertz the Doppler shift is tiny - the channel just eats it. Enjoy the easy one. The next bird will not be this polite.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'yagi-track-cubehop': { + text: `

The yagi's on the bird! Now, about that switch on the feed: satellite signals come in right-hand or left-hand circular polarization, like a screw thread. CUBEHOP-1 is right-handed. Flip the feed to LHCP and you lose about eighteen dB - that's the difference between a picture and static.

Fun fact: when a signal bounces off something, the handedness flips. That's why your antenna cares.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'doppler-chase': { + text: `

You held it through the drift! That slide across the waterfall is Doppler - same reason an ambulance siren drops pitch as it passes. At four-thirty-five megahertz it's plus-or-minus ten kilohertz, and our little channel is only fifteen wide, so somebody has to keep chasing it.

If you found the AFC checkbox: yeah, the software can chase it for you. I still make everyone do it by hand once. Builds character.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'gps-detect': { + text: `

There it is - that fat little bump at 1575.42. That's GPS. Notice there's no carrier spike? The signal is spread across two megahertz ON PURPOSE, so it's basically hiding under the noise floor. Your phone digs it out with math - correlation, not muscle.

Congratulations: you just found the signal that runs the whole world, with a patch antenna taped to a paint stick. Uncle Charlie's dish can't even tune this low. Don't tell him I said that.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/satellites.ts b/src/campaigns/ham-sdr/satellites.ts new file mode 100644 index 00000000..f3103f1b --- /dev/null +++ b/src/campaigns/ham-sdr/satellites.ts @@ -0,0 +1,178 @@ +import { OrbitalObserver, OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { TransponderConfig } from '@app/equipment/satellite/satellite'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; + +/** + * Campaign 3 (Backyard Operator) satellite roster. + * + * All receive-only from Riley's backyard: downlinks are direct-transmit + * "beacons" on transponder configs (no uplink traffic). TLEs are authored + * against the 2027-06-19 16:00:00 UTC sandbox start with scripts/author-tle.mjs: + * - WXSAT-19 (137.1 MHz APT): AOS T+3.0 min, max el 55.0 deg T+10.6, LOS T+18.2 + * - CUBEHOP-1 (435.25 MHz FM): AOS T+18.0 min, max el 48.2 deg T+24.2, LOS T+30.5 + * - NAVSTAR-77 (1575.42 MHz L1): MEO, el 89.3 deg at T+0, still 62.2 deg at T+60 min + * + * Downlink powers are calibrated from real EIRPs against the fixed-gain + * antenna model (QFH 3 dBi / crossed yagi 12 dBi / patch 5 dBi) for mid-pass + * C/N around 20-25 dB; validate live in-app before shipping scored scenarios. + */ + +/** Riley's backyard, Burlington VT — observer for all relative telemetry */ +export const backyardObserver: OrbitalObserver = { + name: "Riley's Backyard", + lat: 44.48 as Degrees, + lon: -73.21 as Degrees, + alt: 0.05 as Kilometers, +}; + +/** NOAA-style polar weather satellite with a 137 MHz APT downlink (RHCP) */ +export const wxsat19Satellite = new OrbitalSatellite( + 'WXSAT-19', + 63001, + [], // receive-only campaign: no uplink traffic + [], // downlinks defined in transponderConfigs + { + tle1: '1 63001U 27042A 27170.66666667 .00001000 00000-0 10000-3 0 9996' as TleLine1, + tle2: '2 63001 98.7000 242.0000 0010000 90.0000 6.0000 14.19000000123450' as TleLine2, + observer: backyardObserver, + }, + { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.1 as Degrees, + ephemerisErrorEl: 0.1 as Degrees, + transponderConfigs: [ + { + id: 'APT', + uplinkCenterFrequency: 148e6 as RfFrequency, // unused command uplink slot + bandwidth: 34e3 as Hertz, + frequencyOffset: 0 as Hertz, + polarization: 'RHCP', + beacon: { + frequency: 137.1e6 as RfFrequency, + signalId: 'WXSAT-19-APT', + serverId: 1, + noradId: 63001, + power: 36 as dBm, // ~5 W APT transmitter + antenna gain + bandwidth: 34e3 as Hertz, + modulation: 'BPSK' as ModulationType, + fec: '1/2' as FECType, + polarization: 'RHCP', + feed: 'blue-1.mp4', // placeholder for APT weather imagery + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + } +); + +export const CUBEHOP1_TLE1 = '1 63002U 27042A 27170.66666667 .00001000 00000-0 10000-3 0 9997' as TleLine1; +export const CUBEHOP1_TLE2 = '2 63002 97.5000 94.0000 0010000 90.0000 226.0000 14.90000000123456' as TleLine2; + +/** Fresh options per instance - transponders/beacons must never be shared */ +const makeCubehop1Options = () => ({ + rotation: 0 as Degrees, + ephemerisErrorAz: 0.15 as Degrees, + ephemerisErrorEl: 0.15 as Degrees, + transponderConfigs: [ + { + // V/U linear transponder (S8 "Callsign"): 435.90 up -> 435.29 down. + // The 0.61 MHz offset keeps the return inside the console's 100 kHz + // waterfall view and clear of the 435.25 beacon +/- Doppler. Gain is + // calibrated against the real uplink budget (backyard ~42 dBm EIRP, + // ~147 dB FSPL mid-pass -> about -105 dBm arriving here) so the + // transponded downlink leaves at roughly beacon strength. Inert in + // S1-S7: nothing transmits on 435.90 until S8. + id: 'VU-XPD', + uplinkCenterFrequency: 435.9e6 as RfFrequency, + bandwidth: 30e3 as Hertz, + frequencyOffset: 0.61e6 as Hertz, + polarization: 'RHCP', + gain: 132 as dBi, + } as TransponderConfig, + { + id: 'FM-DL', + uplinkCenterFrequency: 145.9e6 as RfFrequency, // unused uplink slot + bandwidth: 15e3 as Hertz, + frequencyOffset: 0 as Hertz, + polarization: 'RHCP', + beacon: { + frequency: 435.25e6 as RfFrequency, // ~+/-10 kHz Doppler: exceeds the 15 kHz channel, must be chased + signalId: 'CUBEHOP-1-FM', + serverId: 1, + noradId: 63002, + power: 28 as dBm, // ~0.5 W cubesat transmitter + bandwidth: 15e3 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '1/2' as FECType, + polarization: 'RHCP', + feed: 'blue-2.mp4', // placeholder for SSTV frame imagery + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], +}); + +/** + * Scenario-local CUBEHOP-1 instance. S6 needs its own bird because + * SpaceEventManager mutates the instance's TLE (initial tamper + player + * update); sharing the roster instance would leak the tamper into S2-S4 + * within one SPA session. + */ +export const makeCubehop1Satellite = (): OrbitalSatellite => + new OrbitalSatellite('CUBEHOP-1', 63002, [], [], { tle1: CUBEHOP1_TLE1, tle2: CUBEHOP1_TLE2, observer: backyardObserver }, makeCubehop1Options()); + +/** Amateur FM cubesat with a 70cm downlink carrying SSTV frames (RHCP) */ +export const cubehop1Satellite = makeCubehop1Satellite(); + +/** GPS Block III bird in MEO — L1 spread spectrum, a broad hump near the noise floor */ +export const navstar77Satellite = new OrbitalSatellite( + 'NAVSTAR-77', + 63003, + [], + [], + { + tle1: '1 63003U 27042A 27170.66666667 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 63003 55.0000 32.0000 0010000 90.0000 328.0000 2.00565000123455' as TleLine2, + observer: backyardObserver, + }, + { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.05 as Degrees, + ephemerisErrorEl: 0.05 as Degrees, + transponderConfigs: [ + { + id: 'L1', + uplinkCenterFrequency: 2000e6 as RfFrequency, // unused + bandwidth: 2.046e6 as Hertz, + frequencyOffset: 0 as Hertz, + polarization: 'RHCP', + beacon: { + frequency: 1575.42e6 as RfFrequency, + signalId: 'NAVSTAR-77-L1', + serverId: 1, + noradId: 63003, + // Boosted above the real ~27 dBW EIRP so the spread-spectrum hump + // peeks a few dB above the waterfall noise (teaching visibility) + power: 70 as dBm, + bandwidth: 2.046e6 as Hertz, + modulation: 'null' as ModulationType, // spread spectrum: never "locks", only detected + fec: 'null' as FECType, + polarization: 'RHCP', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + } +); diff --git a/src/campaigns/ham-sdr/scenario1.ts b/src/campaigns/ham-sdr/scenario1.ts new file mode 100644 index 00000000..72b0f79a --- /dev/null +++ b/src/campaigns/ham-sdr/scenario1.ts @@ -0,0 +1,319 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import { backyardQfhStation } from './ground-stations'; +import { cubehop1Satellite, wxsat19Satellite } from './satellites'; + +/** + * ham-sdr Scenario 1 - "First Light" / QFH + APT first contact + * + * The campaign opener: one antenna (the fence-post QFH), one bird (WXSAT-19), + * one job - catch the pass and decode the picture. Teaches the SDR Console's + * three core interactions in sequence: click-to-tune (the rig boots with the + * VFO parked 70 kHz high), channel bandwidth (it boots on a 15 kHz voice + * channel that clips the 34 kHz APT signal), and leaving a working lock + * alone (a 45 s hands-off hold). Plants the Doppler contrast that Scenario 2 + * cashes in: at 137 MHz the drift fits inside the channel, so once locked, + * this one needs no chasing. + * + * Pass timeline (scenario clock starts 2027-06-19 16:00:00 UTC, same evening + * as the sandbox - the RF envelope phase 1 verified live): + * - WXSAT-19: AOS T+3.0 min, max el 55.1 deg at T+10.6, LOS T+18.3 min + * - CUBEHOP-1 rises at T+18.0 (foreshadowed; the yagi arrives in Scenario 2) + * + * NICE Framework Alignment: + * Primary Codes: + * - K1032: Knowledge of satellite-based communication systems + * - S0421: Skill in operating communications equipment + * Supporting Codes: + * - K0645: Knowledge of standard operating procedures + * - T0153: Monitor system performance + */ + +/** + * S1 boots the rig exactly as Riley left it after last night's FM session: + * VFO parked at 137.170 MHz (70 kHz above the APT downlink, still inside the + * 200 kHz waterfall view so the parked VFO box is visibly sitting beside the + * stripe) on a 15 kHz voice channel. The 34 kHz APT signal cannot fit a + * 15 kHz channel (the receiver drops signals wider than the modem bandwidth), + * so the player must click-to-tune AND open the channel to 50 kHz - the two + * SDR Console interactions this scenario exists to teach. + */ +const firstLightQfhStation = { + ...backyardQfhStation, + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 137.17 as MHz, // parked 70 kHz above the APT downlink + bandwidth: 0.015 as MHz, // last night's FM voice channel: clips 34 kHz APT + modulation: 'BPSK', + fec: '1/2', + antenna_id: 1, + }, + ], + }, + ], +} as GroundStationConfig; + +export const hamSdrScenario1Data: ScenarioData = { + id: 'ham-sdr-scenario1', + url: 'ham-sdr/scenarios/ham-sdr-scenario1', + imageUrl: 'nats/1/card.png', + number: 1, + isDisabled: false, + difficulty: 'beginner', + title: 'First Light', + subtitle: 'Catch a Weather Satellite with a Fence Post', + duration: '20-25 min', + missionType: 'Backyard Session', + description: `Riley's rule for new operators: before you get the rotator, you earn the fence post.

The quadrifilar helix zip-tied to the back fence stares straight up and hears most of the sky at once. WXSAT-19 - a polar weather bird - rises in three minutes and will spend a quarter of an hour drawing a picture of the weather, one scan line at a time, on 137.100 MHz.

The rig is exactly as Riley left it last night: VFO parked off-frequency, channel set for FM voice. Your whole job: find the signal, put the radio on it, open the channel wide enough to swallow it - and then keep your hands off.

No mission control. No checklist. An SDR dongle, a waterfall, and physics.`, + equipment: ['DIY 137 MHz Quadrifilar Helix (fixed skyward)', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console'], + settings: { + isSync: true, + groundStations: [firstLightQfhStation], + satellites: [wxsat19Satellite, cubehop1Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-19', + scenarioStartWallTime: '16:00:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-1?content-only=true&dark=true', + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: 'Open the brief taped to the laptop lid. It has the pass time, the frequency, and exactly one rule about the channel bandwidth.', + groundStation: 'BKYD-QFH', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Antenna Concept Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'The QFH has no rotator and never moves. Why does that work for a satellite screaming by at 7.5 km/s?', + options: [ + 'Its beam is enormous - about 140 degrees wide - so the whole pass happens inside the beam.', + 'The satellite steers its own beam - aimed at the ground below it - so any fixed antenna hears the pass.', + 'The pass is high enough - 55 degrees at the top - so the bird stays inside even a narrow beam.', + ], + correctIndex: 0, + explanation: 'Right. Gain and beamwidth trade off: 3 dBi of gain buys a beam so fat the bird never leaves it. Big dishes point; little antennas wait.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'check-observations', + nice: ['K1032', 'T0153'], + title: 'Check the Observations List', + description: 'Open the Observations tab and find WXSAT-19. Note the AOS time and the maximum elevation - a 55 degree pass is a good one for the fence post.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'tab-active', + description: 'Observations Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'detect-apt', + nice: ['S0421', 'T0153'], + title: 'See First Light', + description: + 'Watch the SDR Console waterfall. When WXSAT-19 clears the horizon its APT downlink appears as a steady stripe at 137.100 MHz. That stripe is your first satellite - and notice it is NOT where the VFO is parked.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['check-observations'], + conditions: [ + { + type: 'signal-detected', + description: 'WXSAT-19 APT Detected at 137.1 MHz', + params: { + signalId: 'WXSAT-19-APT', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'tune-apt', + nice: ['S0421'], + title: 'Put the VFO on the Bird', + description: + 'The VFO is still parked where Riley left it last night, 70 kHz above the bird. Click the stripe on the waterfall to tune to it (the bookmark and the tune buttons work too). Get the VFO within 5 kHz of 137.100 MHz.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['detect-apt'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'VFO on 137.100 MHz (+/-5 kHz)', + params: { + modemNumber: 1, + frequency: 137.1e6, + frequencyTolerance: 5e3, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'lock-apt', + nice: ['S0421', 'K1032'], + title: 'Open the Channel and Lock', + description: + 'On frequency and still no decode? Look at the channel: 15 kHz of FM voice channel cannot swallow 34 kHz of APT - the demodulator never even sees it. Set the channel bandwidth to 50 kHz to bracket the signal, and the lock will follow. Too narrow clips the signal; too wide drowns it in noise.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['tune-apt'], + conditions: [ + { + type: 'rx-modem-bandwidth-set', + description: 'Channel Opened to ~50 kHz', + params: { + modemNumber: 1, + bandwidth: 50e3, + bandwidthTolerance: 25e3, + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'APT Downlink Locked, Picture Decoding', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'hold-the-picture', + nice: ['K1032', 'T0153'], + title: "Don't Touch What's Working", + description: + 'The picture is coming down one scan line at a time. Rule three of the backyard: when it works, hands off. Hold the lock for 45 seconds without losing it - no retuning, no fiddling.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['lock-apt'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'Lock Held Hands-Off (45 s)', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 45, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'first-light-log', + nice: ['K1032'], + title: 'Log the Observation', + description: 'Forty-five seconds of lock and you never touched the tuning. Record why in the log.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['hold-the-picture'], + conditions: [ + { + type: 'status-check', + description: 'Doppler Contrast Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'The bird is moving at 7.5 km/s, which shifts its frequency (Doppler). Why has the lock held without you retuning?', + options: [ + 'At 137 MHz the whole Doppler swing is about +/-3 kHz - the 50 kHz channel swallows it.', + 'The satellite pre-corrects its own Doppler - the downlink arrives on 137.100 MHz exactly.', + 'The RTL-SDR strips Doppler in hardware - the VFO readout never sees the shift at all.', + ], + correctIndex: 0, + explanation: + 'Exactly. Doppler scales with frequency. At 137 MHz it hides inside the channel. Remember that phrasing - tomorrow we work a bird at 435 MHz, and it will NOT hide.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

Okay! First real session. Forget everything Uncle Charlie told you about procedures - out here the procedure is: know when the bird rises, be on frequency, don't touch what's working.

WXSAT-19 is up in three minutes. It draws weather maps with radio, live, for anyone with an antenna. Tonight that's you. Fair warning: the radio is still set up from my FM session last night, so it's YOUR job to put it on the bird. Read my note first.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

See? One page. Uncle Charlie's briefs have annexes. Mine has a frequency, a channel rule, and a snack recommendation.

Now pull up Observations and find our bird - the list is just orbital math, the same math the big stations use, running on my laptop.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'check-observations': { + text: `

There it is. AOS in a couple minutes, fifty-five degrees at the top - that's a good pass. Above about twenty-five degrees the QFH hears everything; below that the trees eat it.

Eyes on the waterfall now. You're waiting for a stripe to fade in at 137.1. It won't knock. It'll just appear - and no, it won't be where the VFO is parked. That's tomorrow-you's first lesson, happening today.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'detect-apt': { + text: `

THERE! That stripe is a satellite. Your fence post is hearing a spacecraft. I never get tired of this part.

Now look at the VFO box - it's sitting in empty spectrum 70 kHz up, right where I left it last night. Click the stripe. The whole point of a waterfall radio: see the signal, touch the signal.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'tune-apt': { + text: `

On frequency! And... nothing decoding. Welcome to the second-most-common mistake in satellite radio.

Check the channel width: 15 kilohertz, set for FM voice. The APT signal is 34 kilohertz wide - the demodulator literally can't see a signal that doesn't fit its channel. Open it up to 50 and bracket the bird.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'lock-apt': { + text: `

Locked! And look - the picture. That's the Atlantic seaboard, scan line by scan line, straight off the bird. No internet involved. People two hundred years ago would have called this witchcraft. People today mostly don't know it's possible.

Now the hard part for every new operator: do nothing. Forty-five seconds, hands in your pockets. Watch what the lock does. Or rather, doesn't.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'hold-the-picture': { + text: `

Forty-five seconds, zero retunes, picture still rolling in. See? The bird is doing 7.5 kilometers a second and the lock never blinked.

Notice what you're NOT doing: chasing it. Log the observation and tell me why.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'first-light-log': { + text: `

First light, logged. That's a real term, by the way - astronomers use it for a telescope's first image. Radio counts.

Enjoy how easy the VHF bird was. Tomorrow the yagi goes up, we work a cubesat at 435 megahertz, and you find out what Doppler does when it doesn't fit in the channel. Bring the snack.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario2.ts b/src/campaigns/ham-sdr/scenario2.ts new file mode 100644 index 00000000..040b2f09 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario2.ts @@ -0,0 +1,210 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardYagiStation } from './ground-stations'; +import { cubehop1Satellite } from './satellites'; + +/** + * ham-sdr Scenario 2 - "The Slippery Bird" / manual Doppler chase on the yagi + * + * The yagi and its TV rotator enter the campaign. CUBEHOP-1's 435.25 MHz FM + * downlink drifts +/-10 kHz across the pass - more than the 15 kHz channel can + * swallow - so the operator must ride the VFO with the tune buttons for the + * whole pass. AFC exists and is deliberately forbidden: the chase objective + * asserts receiver-afc-enabled { afcEnabled: false } while the lock is held + * (Riley: "I make everyone do it by hand once"). Scenario 4 pays this off. + * + * Pass timeline (scenario clock starts 2027-06-19 16:14:00 UTC - the same + * evening as Scenario 1, one bird later): + * - CUBEHOP-1: AOS T+4.0 min, max el 48.2 deg at T+10.2, LOS T+16.5 min + * + * NICE Framework Alignment: + * Primary Codes: + * - S0421: Skill in operating communications equipment + * - K1032: Knowledge of satellite-based communication systems + * Supporting Codes: + * - K0740: Knowledge of system performance indicators + * - T0153: Monitor system performance + */ +export const hamSdrScenario2Data: ScenarioData = { + id: 'ham-sdr-scenario2', + url: 'ham-sdr/scenarios/ham-sdr-scenario2', + imageUrl: 'nats/2/card.png', + number: 2, + isDisabled: false, + difficulty: 'beginner', + prerequisiteScenarioIds: ['ham-sdr-scenario1'], + title: 'The Slippery Bird', + subtitle: 'Chase a Cubesat by Hand', + duration: '20-25 min', + missionType: 'Backyard Session', + description: `The crossed yagi is bolted to the rotator and the rotator mostly obeys. Tonight's target is CUBEHOP-1, an amateur FM cubesat on 435.25 MHz - and at 435 MHz, Doppler stops being polite.

The downlink will slide ten kilohertz high to ten kilohertz low across the pass, and the channel is only fifteen wide. Nobody is going to chase it for you: Riley has taped over the AFC checkbox. Track the bird with the rotator, ride the VFO with the tune buttons, and hold the lock from horizon to horizon.

Every operator does this by hand once. Tonight is your once.`, + equipment: ['DIY 70cm Crossed Yagi on TV Rotator', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console'], + settings: { + isSync: true, + groundStations: [backyardYagiStation], + satellites: [cubehop1Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-19', + scenarioStartWallTime: '16:14:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-2?content-only=true&dark=true', + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: 'The brief covers the pass, the rotator, and which way the frequency is going to run. Read it before AOS - the bird will not wait.', + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Doppler Direction Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'CUBEHOP-1 rises in a few minutes. Which way does its downlink frequency move over the pass?', + options: [ + 'Starts ~10 kHz HIGH on approach, slides through nominal at closest approach, ends ~10 kHz LOW as it recedes.', + 'Starts ~10 kHz LOW on the horizon, climbs through nominal as it gains elevation, ends ~10 kHz HIGH at the top.', + 'Starts on 435.25 MHz nominal, wanders ~10 kHz either side at random, ends back on nominal at LOS.', + ], + correctIndex: 0, + explanation: 'The ambulance siren, in radio: pitch is high approaching, drops as it passes. So start your VFO high and be ready to walk it down all pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'track-cubehop', + nice: ['S0421', 'K1032'], + title: 'Put the Yagi on the Bird', + description: + 'In the SDR Console rotator panel, select CUBEHOP-1 and engage TRACK so the yagi follows the pass. The beam is 40 degrees wide - the rotator does not have to be perfect, it has to be pointing the right general way.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Rotator Tracking CUBEHOP-1', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'CUBEHOP-1 Downlink Detected at 435.25 MHz', + params: { + signalId: 'CUBEHOP-1-FM', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'chase-by-hand', + nice: ['S0421', 'K0740', 'T0153'], + title: 'Ride the VFO', + description: + 'Lock the FM downlink and HOLD it for two minutes of the drift - by hand. Watch the carrier walk across the waterfall and keep the VFO on it with the +/-1 kHz and +/-10 kHz tune buttons. The AFC checkbox stays OFF; if it goes on, the objective resets.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['track-cubehop'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'Lock Held Through the Drift (2 min)', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 120, + }, + { + type: 'receiver-afc-enabled', + description: 'AFC Off - This One Is By Hand', + params: { modemNumber: 1, afcEnabled: false }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'slippery-log', + nice: ['K1032', 'K0740'], + title: 'Log the Chase', + description: 'You held a moving frequency with your fingers for a whole pass segment. Before your hands stop shaking, log what you noticed about the middle of the pass.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['chase-by-hand'], + conditions: [ + { + type: 'status-check', + description: 'Closest-Approach Insight Recorded', + params: { + character: Character.RILEY_BROOKS, + question: 'Near the top of the pass the drift briefly slowed to almost nothing, then sped up again. Why?', + options: [ + 'At closest approach the range rate passes through zero - no motion along the line of sight, no Doppler shift.', + 'At high elevation the satellite throttles its transmitter - less power on the downlink, less Doppler shift.', + 'At the top of the pass the rotator caught up with the bird - the beam settled on it and cancelled the drift.', + ], + correctIndex: 0, + explanation: + 'Doppler comes from motion TOWARD or AWAY from you. Overhead, all the motion is sideways for a moment - the frequency stands still, then the slide resumes. That pause is closest approach, every pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

The rotator works! Mostly. It groans on the way past north, ignore that.

Tonight you meet the slippery bird. CUBEHOP-1 does everything WXSAT-19 did, except at four-thirty-five megahertz, where Doppler is three times bigger and our channel is three times smaller. The signal will NOT stay put. And no, you can't use the AFC - I taped over the checkbox. By hand, once. Everyone. House rule.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

High to low, every pass, forever. Once you've internalized that, you're ahead of half the people who buy an SDR and give up.

Now get the yagi moving: pick CUBEHOP-1 in the rotator panel and hit TRACK. The rotator's slow, so give it a head start.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'track-cubehop': { + text: `

Yagi's on it - see the signal come up as the boom swings around? Twelve dB of gain means the yagi actually has to point, unlike your fence post. Forty degrees of beam means "point" is a generous word. It's a very forgiving instrument. The frequency is not.

Here it comes. VFO high. Fingers on the tune buttons.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'chase-by-hand': { + text: `

YES! Two whole minutes on a runaway frequency! Feel how it accelerated through the middle and eased off at the ends? You just FELT orbital mechanics. In your fingertips. That's the thing the checkbox would have stolen from you.

Okay, log it. There's one detail from mid-pass I want to make sure you caught.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'slippery-log': { + text: `

The standing-still moment. Closest approach. Some operators time it with a stopwatch and work out the orbit from when it happens - that's a whole hobby in itself.

You've earned the checkbox, by the way. Not tonight, though. Tomorrow the yagi gets... a small surprise. Nothing you can't diagnose.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario3.ts b/src/campaigns/ham-sdr/scenario3.ts new file mode 100644 index 00000000..74a9ecd4 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario3.ts @@ -0,0 +1,252 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import type { AntennaState } from '@app/equipment/antenna'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardYagiStation } from './ground-stations'; +import { cubehop1Satellite } from './satellites'; + +/** + * ham-sdr Scenario 3 - "Wrong-Handed" / circular polarization diagnosis + * + * Riley re-terminated the yagi's feed harness overnight and the handedness + * switch was left on LHCP (per the phase-1 retro note: the scenario STARTS + * wrong-handed). CUBEHOP-1 is RHCP, so the pass opens with the signal ~18 dB + * in the hole - visible on the waterfall, hopeless to lock. The player has to + * read the symptom (bird tracked, signal present but weak, C/N in the floor), + * flip the feed to RHCP (the new antenna-polarization-set condition), and + * prove it with a >= 10 dB C/N lock through the rest of the pass. + * + * Pass timeline (scenario clock starts 2027-06-20 16:24:00 UTC - the next + * evening; the TLE epoch is 2027-06-19 16:00, well inside SGP4 validity): + * - CUBEHOP-1: AOS T+4.3 min, max el 63.4 deg at T+10.6, LOS T+17.0 min + * + * NICE Framework Alignment: + * Primary Codes: + * - S0421: Skill in operating communications equipment + * - K0740: Knowledge of system performance indicators + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor system performance + */ + +/** The yagi rig as Riley left it: feed harness on the WRONG handedness */ +const yagiStationLhcpStart: GroundStationConfig = { + ...backyardYagiStation, + antennasState: [ + { + ...(backyardYagiStation.antennasState![0] as Partial), + circularHandedness: 'LHCP', + } as Partial, + ], +}; + +export const hamSdrScenario3Data: ScenarioData = { + id: 'ham-sdr-scenario3', + url: 'ham-sdr/scenarios/ham-sdr-scenario3', + imageUrl: 'nats/3/card.png', + number: 3, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['ham-sdr-scenario2'], + title: 'Wrong-Handed', + subtitle: 'Diagnose the Polarization', + duration: '20-25 min', + missionType: 'Backyard Session', + description: `Riley rebuilt the yagi's feed harness last night - "improved" it, allegedly - and now something is off. The rotator tracks fine. The frequency is right. The bird is up there. And the signal is a ghost of what it was yesterday.

Circularly polarized signals have a handedness, like a screw thread: right-hand or left-hand. Match your antenna to the signal and you lose half a decibel. Get it backwards and a well-built crossed yagi throws away eighteen. Somewhere in last night's rewiring is a switch in the wrong position.

Track the pass, read the symptom, find the switch. The bird gives you seventeen minutes.`, + equipment: ['DIY 70cm Crossed Yagi on TV Rotator (feed harness "improved")', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console'], + settings: { + isSync: true, + groundStations: [yagiStationLhcpStart], + satellites: [cubehop1Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-20', + scenarioStartWallTime: '16:24:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-3?content-only=true&dark=true', + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: 'The note is mostly an apology about the feed harness. The useful part: what handedness does to a circular signal, and how many dB are on the line.', + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Handedness Physics Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'A right-hand circular (RHCP) signal reflects off a metal roof on its way to you. What arrives?', + options: [ + 'A left-hand circular signal - reflection reverses the handedness.', + 'A right-hand circular signal - reflection only costs it some strength.', + 'A linearly polarized signal - reflection collapses the circular twist.', + ], + correctIndex: 0, + explanation: + 'Reflection flips the screw thread. It is why satellite antennas care so much about handedness - the direct signal and its reflections fight with opposite hands.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'track-cubehop', + nice: ['S0421', 'K1032'], + title: 'Track the Pass', + description: + 'Select CUBEHOP-1 in the rotator panel and engage TRACK, same as yesterday. The downlink should appear at 435.25 MHz as the bird rises... but look at how weak it is.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Rotator Tracking CUBEHOP-1', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'CUBEHOP-1 Downlink Visible (Weak)', + params: { + signalId: 'CUBEHOP-1-FM', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'fix-the-feed', + nice: ['S0421', 'K0740'], + title: 'Find the Switch', + description: + 'The rotator is on the bird and the VFO is on frequency, so the missing decibels are in the antenna. CUBEHOP-1 transmits right-hand circular. Check the feed handedness switch on the SDR Console and put it back on RHCP.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['track-cubehop'], + conditions: [ + { + type: 'antenna-polarization-set', + description: 'Feed Restored to RHCP', + params: { circularHandedness: 'RHCP' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'prove-the-link', + nice: ['S0421', 'K0740', 'T0153'], + title: 'Prove It with a Lock', + description: + 'With the feed right-handed the signal should come up like a light switch - about 18 dB. Chase the Doppler by hand like yesterday and hold a lock with at least 10 dB of C/N to prove the diagnosis.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['fix-the-feed'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'FM Downlink Locked', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 60, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N At or Above 10 dB', + params: { modemNumber: 1, minCNRatio: 10, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 60, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'wrong-handed-log', + nice: ['K1032', 'K0740'], + title: 'Log the Diagnosis', + description: 'Eighteen decibels found in one switch. Log what the symptom looked like so you recognize it in ten seconds next time instead of ten minutes.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['prove-the-link'], + conditions: [ + { + type: 'status-check', + description: 'Symptom Pattern Recorded', + params: { + character: Character.RILEY_BROOKS, + question: 'Next month a signal is 18 dB weaker than expected, but pointing, frequency, and hardware all check out. What does the symptom pattern suggest first?', + options: [ + 'Polarization mismatch - a large, CONSTANT loss with everything else nominal points at handedness.', + 'Transmitter failure - a large, growing loss with everything else nominal points at the satellite.', + 'Doppler error - a large, varying loss with everything else nominal points at the channel edge.', + ], + correctIndex: 0, + explanation: + 'A flat 15-20 dB deficit that does not vary with pointing or time is the polarization signature. Doppler moves, fades flutter - a wrong-handed feed just sits there, uniformly terrible.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

So. Funny story. I re-terminated the feed harness last night because the old connectors were corroding, and I *may* have left something in the wrong position, and I'm not going to tell you what, because this is the best lesson in the whole campaign and I'd hate to spoil it.

Bird's up in four minutes. Everything worked yesterday. Something doesn't work today. Go.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

Screw threads. That's the whole concept: a right-handed bolt doesn't go into a left-handed nut, and a right-handed wave doesn't go into a left-handed antenna. Well - it goes in eighteen decibels worth of badly.

Get tracking. And keep one eye on the signal strength as it comes up. Compare it to your memory of yesterday.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'track-cubehop': { + text: `

Right? RIGHT? The rotator's dead on, the frequency's right there, and the signal is a whisper. Yesterday it filled the waterfall. Today it's barely out of the noise.

So run the checklist in your head. Pointing: good. Frequency: good. What's left between the bird and the receiver? ... The antenna. Specifically, the thing I rewired.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'fix-the-feed': { + text: `

THERE it is! Look at the waterfall - eighteen decibels, one switch. Cheapest signal boost in radio. The feed was cross-threaded, radio-wise: left-handed antenna, right-handed bird.

Now prove the fix holds. Chase it by hand and show me a real lock with real margin.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'prove-the-link': { + text: `

Locked, ten dB of margin, drift and all. Diagnosis confirmed by experiment - that's the difference between guessing and knowing.

For the record: I owe you one pass. The switch was my fault. But you'd never have LOOKED at that switch if it had never bitten you, and someday, on some antenna, it will bite you for real.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'wrong-handed-log': { + text: `

Constant loss, everything else nominal: check the polarization. Ten-second diagnosis. You're building the pattern library every operator carries around in their head.

Tomorrow, one more pass with this bird - and you finally get the checkbox. I want you to see exactly what the machine does, now that you know what it's replacing.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario4.ts b/src/campaigns/ham-sdr/scenario4.ts new file mode 100644 index 00000000..138f0691 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario4.ts @@ -0,0 +1,275 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardYagiStation } from './ground-stations'; +import { cubehop1Satellite } from './satellites'; + +/** + * ham-sdr Scenario 4 - "Set and Forget" / AFC discovery, staged A/B in one pass + * + * The AFC payoff (per the phase-1 retro note: manual chase first, AFC second). + * A single pass carries both halves - Doppler drift is fastest near AOS and + * LOS and pauses at closest approach, so the first half is a real manual + * chase and the second half shows the loop doing the same job hands-off: + * 1. chase the first half by hand (receiver-afc-enabled { afcEnabled: false }, + * lock held 90 s), + * 2. engage AFC (receiver-afc-enabled, the new Campaign 3 condition), + * 3. hands off through the back half (lock + C/N >= 10 held 120 s with AFC on). + * + * Pass timeline (scenario clock starts 2027-06-21 16:34:00 UTC - the third + * evening; TLE epoch 2027-06-19, still well inside SGP4 validity). This is + * the strongest pass of the campaign so far, nearly overhead: + * - CUBEHOP-1: AOS T+4.8 min, max el 83.1 deg at T+11.2, LOS T+17.5 min + * + * NICE Framework Alignment: + * Primary Codes: + * - S0421: Skill in operating communications equipment + * - K0740: Knowledge of system performance indicators + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor system performance + */ +export const hamSdrScenario4Data: ScenarioData = { + id: 'ham-sdr-scenario4', + url: 'ham-sdr/scenarios/ham-sdr-scenario4', + imageUrl: 'nats/4/card.png', + number: 4, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['ham-sdr-scenario3'], + title: 'Set and Forget', + subtitle: 'Earn the AFC Checkbox', + duration: '20-25 min', + missionType: 'Backyard Session', + description: `The tape comes off the checkbox tonight.

CUBEHOP-1 is coming over nearly dead overhead - the strongest pass yet, and the fastest Doppler at the edges. Riley's deal: fly the first half of the pass by hand, like you learned. At closest approach, flip on the AFC and take your hands off the dial. Watch what the loop does with the back half of the drift, and understand exactly what it's measuring to do it.

Automation you understand is a tool. Automation you don't is a prayer.`, + equipment: ['DIY 70cm Crossed Yagi on TV Rotator', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console (AFC unlocked)'], + settings: { + isSync: true, + groundStations: [backyardYagiStation], + satellites: [cubehop1Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-21', + scenarioStartWallTime: '16:34:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-4?content-only=true&dark=true', + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: 'The note explains what the AFC loop actually measures. Read it now - the quiz at the end of the night assumes you did.', + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'AFC Error Signal Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'When AFC is on, what does the receiver measure to decide which way to retune?', + options: [ + "The carrier's offset from the center of the channel - it slews the VFO to re-center whatever it is locked to.", + "The satellite's position from the Observations list - it computes the Doppler and steps the VFO ahead of it.", + "The rotator's azimuth and elevation rates - it converts the pointing speed into a VFO correction.", + ], + correctIndex: 0, + explanation: + "AFC is beautifully dumb: it sees a carrier off-center in the passband and walks the VFO toward it. No orbit knowledge at all - which is both its power and, as you'll see someday, its weakness.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'track-cubehop', + nice: ['S0421', 'K1032'], + title: 'Track the Overhead Pass', + description: 'Select CUBEHOP-1 and engage TRACK. An 83 degree pass means the rotator swings hard through the middle - let it work, your job tonight is the frequency.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Rotator Tracking CUBEHOP-1', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'CUBEHOP-1 Downlink Detected at 435.25 MHz', + params: { + signalId: 'CUBEHOP-1-FM', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'manual-first-half', + nice: ['S0421', 'K0740'], + title: 'Fly the First Half by Hand', + description: 'Lock the downlink and hold it by hand for 90 seconds - AFC stays off. The inbound drift is the fastest you have chased yet; stay ahead of it.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['track-cubehop'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'Lock Held by Hand (90 s)', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 90, + }, + { + type: 'receiver-afc-enabled', + description: 'AFC Still Off', + params: { modemNumber: 1, afcEnabled: false }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'engage-afc', + nice: ['S0421'], + title: 'Take the Tape Off', + description: 'Near closest approach - while the drift is briefly standing still - check the AFC box on the SDR Console. Hand the dial to the machine.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['manual-first-half'], + conditions: [ + { + type: 'receiver-afc-enabled', + description: 'AFC Engaged', + params: { modemNumber: 1, afcEnabled: true }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'hands-off', + nice: ['S0421', 'K0740', 'T0153'], + title: 'Hands Off the Dial', + description: + 'Let the loop fly the outbound drift. Hold lock with at least 10 dB C/N for two minutes with AFC on and your hands in your pockets. Watch the VFO readout follow the carrier down.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['engage-afc'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'Lock Held by the Loop (2 min)', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 120, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N At or Above 10 dB', + params: { modemNumber: 1, minCNRatio: 10, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: true, + maintainDuration: 120, + }, + { + type: 'receiver-afc-enabled', + description: 'AFC Remains Engaged', + params: { modemNumber: 1, afcEnabled: true }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'graduation-log', + nice: ['K1032', 'K0740'], + title: 'Log the Graduation', + description: 'Manual chase and machine chase, same pass, same bird. One question before the checkbox is yours for keeps.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['hands-off'], + conditions: [ + { + type: 'status-check', + description: 'AFC Limits Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'When would the AFC loop fail you, and manual skills save the pass?', + options: [ + 'When the carrier disappears from the passband - a deep fade, or drift it cannot slew - AFC has nothing to measure.', + 'Never - the loop measures the carrier hundreds of times a second, faster than any hand - AFC always wins.', + 'Only when the rotator loses the satellite - the beam swings off the bird - until then AFC never needs a hand.', + ], + correctIndex: 0, + explanation: + "AFC follows what it can see. Lose the carrier and the loop goes quiet right when you need it most - it has no model of the orbit, only of the passband. A human can predict where the signal WILL be. That's why you learned the hand version first.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

Big night. Nearly-overhead pass - eighty-three degrees, the strongest signal you'll have seen, and the nastiest Doppler at the edges. Perfect conditions for graduation.

The deal: first half by hand, to prove Tuesday wasn't luck. At the top of the pass, tape comes off, AFC goes on, hands go in pockets. Deal? Deal.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

"Beautifully dumb" is the phrase to keep. The loop centers a carrier. That's the whole machine. It doesn't know there's a satellite, an orbit, or a sky.

Get tracking - and brace for the rotator theatrics on this one, it has to whip around near the top. Ignore it. Frequency is your instrument tonight.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'track-cubehop': { + text: `

Look at that signal strength - overhead passes are the good stuff. Short range, clean sky, all decibels for us.

Here comes the drift, and it's QUICK on a high pass. VFO high, fingers ready, just like Tuesday. Show me.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'manual-first-half': { + text: `

Ninety seconds, hand-flown, on the fastest drift yet. Tuesday was not luck. Officially confirmed.

Feel that? The slide is easing off... easing... that's closest approach coming up. NOW - flip the checkbox. Right at the still moment. Perfect handoff conditions.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'engage-afc': { + text: `

And... hands off. Watch the VFO readout. See it? Little steps, down and down, following the carrier as the drift picks back up. It's doing exactly what your fingers did - measure the offset, close the gap, repeat forever, never bored.

Two minutes. Don't touch anything. I know it's hard.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'hands-off': { + text: `

The machine flew it to the horizon and never wobbled. And here's the thing - you can WATCH it work and know if it's healthy, because you've done its job with your own hands. That's the difference between using automation and trusting it blindly.

One question, then the checkbox is yours forever.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'graduation-log': { + text: `

"It has no model of the orbit, only of the passband." Frame that. Every automated system you'll ever meet has a version of that sentence, and finding it is the whole job of an operator.

Congratulations - you're a real satellite operator now. A cheap one. The best kind. Next time: we point this thing at something weirder.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario5.ts b/src/campaigns/ham-sdr/scenario5.ts new file mode 100644 index 00000000..b8be7902 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario5.ts @@ -0,0 +1,361 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardGpsStation } from './ground-stations'; +import { navstar77Satellite } from './satellites'; + +/** + * ham-sdr Scenario 5 - "The Noise Bump" / GPS L1 + spoofing detection + * + * Opens the security half of the campaign. Act 1: find GPS - a signal that + * lives BELOW the noise floor and can be detected but never locked (spread + * spectrum). Act 2: a GPS spoofer comes up a few blocks away. The tells are + * the campaign's first adversarial lesson: + * - a too-clean, too-strong carrier rises out of the gentle L1 hump + * (terrestrial emitter, E1 - and it never Dopplers); + * - the SDR console's CLK deltaT readout starts walking while the SATS count + * stays healthy (a real outage drops satellites; a spoof keeps them). + * Defense: stop trusting GPS - flip the reference to HOLDOVER and ride the + * disciplined oscillator until the spoofer goes off the air (E4 REF control, + * gpsdo-reference-mode-set condition). + * + * Timeline (scenario clock starts 2027-06-22 16:00:00 UTC): + * - NAVSTAR-77 (MEO) is high overhead all scenario - no pass to catch + * - T+7:00 spoofer on the air (gnssThreat spoofStartS + terrestrial event) + * - T+15:00 spoofer off the air; offset freezes wherever it walked to + * + * NICE Framework Alignment: + * Primary Codes: + * - K0752: Knowledge of system vulnerabilities (threat recognition) + * - S0421: Skill in operating communications equipment + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor system performance + */ +export const hamSdrScenario5Data: ScenarioData = { + id: 'ham-sdr-scenario5', + url: 'ham-sdr/scenarios/ham-sdr-scenario5', + imageUrl: 'nats/5/card.png', + number: 5, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['ham-sdr-scenario4'], + title: 'The Noise Bump', + subtitle: 'Find GPS. Then Stop Trusting It.', + duration: '20-25 min', + missionType: 'Backyard Session', + description: `Riley's newest experiment is a GPS patch antenna hose-clamped to a paint stick. Tonight's first job is humble: find GPS at all. The signal is so weak it lives below the noise floor - what you can see is a gentle two-megahertz bump of extra noise at 1575.42. You can detect it. You can never lock it. That asymmetry is the whole lesson.

The second job nobody planned. Somewhere in the neighborhood, something starts transmitting on L1 - strong, clean, and wrong. Your clock offset starts walking while the satellite count stays perfect. Riley has been waiting years to show somebody this.

RF is unauthenticated. Physics is your authentication.`, + equipment: ['GPS Patch on a Paint-Stick Mast (fixed skyward)', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console (GPS-disciplined reference)'], + settings: { + isSync: true, + groundStations: [backyardGpsStation], + satellites: [navstar77Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-22', + scenarioStartWallTime: '16:00:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-5?content-only=true&dark=true', + gnssThreat: { + groundStationIds: ['BKYD-GPS'], + spoofStartS: 420, + spoofEndS: 900, + offsetDriftUsPerS: 5, + }, + interferenceEvents: [ + { + // The spoofer's own signal: a narrow, too-clean carrier riding on L1, + // received over the air from a rooftop a few blocks southeast. No + // Doppler, ever - it is standing still on the ground. + id: 'l1-spoofer', + frequency: 1575.42e6, + bandwidth: 500e3, + power: 30, // EIRP dBm - lights up ~25 dB above the real GPS hump + polarization: 'RHCP', + startTime: 420, + duration: 480, + periodSeconds: 480, + onSeconds: 480, + path: 'terrestrial', + emitter: { latitude: 44.46, longitude: -73.18 }, + }, + ], + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: "The note explains why tonight's signal is different: GPS reaches you weaker than the noise in your own receiver, on purpose, and works anyway.", + groundStation: 'BKYD-GPS', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Spread Spectrum Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'GPS arrives ~20 dB BELOW your noise floor, yet a $10 receiver uses it. How?', + options: [ + 'It is spread across 2 MHz by a known code; the receiver correlates against that code and pulls it out of the noise.', + 'The satellites transmit megawatts from orbit; the receiver sees a strong signal and the 2 MHz bump is its sidebands.', + 'The receiver cools its own front end; its noise floor drops below the signal and the 2 MHz bump is what shows through.', + ], + correctIndex: 0, + explanation: + 'Spreading buys processing gain: correlate 2 MHz of "noise" against the right code and a 43-dB gain appears. On a waterfall, all you ever see is a gentle bump of extra noise.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'find-the-hump', + nice: ['S0421', 'K1032'], + title: 'Find the Noise Bump', + description: + 'Watch the waterfall at 1575.42 MHz. NAVSTAR-77 is nearly overhead right now - look for a two-megahertz-wide swelling of the noise floor. That swelling is every GPS fix in the neighborhood.', + groundStation: 'BKYD-GPS', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'NAVSTAR-77 L1 Detected', + params: { + signalId: 'NAVSTAR-77-L1', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Detect vs Demodulate Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'The bump is plainly there, but the lock indicator will never say LOCKED. Why not?', + options: [ + 'The demodulator has no despreading code - without it the signal IS noise, so detection never becomes demodulation.', + 'The channel bandwidth is set too narrow - the 2 MHz bump spills past the edges, so the demodulator never sees all of it.', + 'The patch antenna has the wrong handedness - GPS is right-hand circular, so the bump arrives too weak to lock on.', + ], + correctIndex: 0, + explanation: + 'Right. Detection and demodulation are different privileges: you can prove energy exists without being able to read it. Remember that direction: it also means something can TRANSMIT energy you cannot vet. Hold that thought.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'spot-the-spoofer', + nice: ['T0153', 'S0648'], + title: 'Something New on L1', + description: + 'A new signal has appeared inside the GPS band - narrow, strong, and clean. Real L1 is a whisper spread over megahertz. This is a shout. Get it on the waterfall.', + groundStation: 'BKYD-GPS', + prerequisiteObjectiveIds: ['find-the-hump'], + conditions: [ + { + type: 'signal-detected', + description: 'Unknown L1 Carrier Detected', + params: { + signalId: 'INTERFERER-l1-spoofer', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-tell', + nice: ['K0752', 'K0684'], + title: 'Read the Clock', + description: 'Check the SOURCE panel: CLK ΔT is walking upward, a few microseconds every second. Now check SATS. Compare the two and name what is happening.', + groundStation: 'BKYD-GPS', + prerequisiteObjectiveIds: ['spot-the-spoofer'], + conditions: [ + { + type: 'status-check', + description: 'Spoof Signature Identified', + params: { + character: Character.RILEY_BROOKS, + question: 'The timing offset is growing steadily but the receiver still reports 8 healthy satellites. What does that combination mean?', + options: [ + 'Spoofing - a fake GPS signal is being tracked. A real outage LOSES satellites; a spoof keeps them and walks your clock.', + 'Normal GPSDO aging - the oscillator itself is drifting. Every reference walks like this; the 8 satellites prove GPS is fine.', + 'A stuck display - the satellite count is frozen. The receiver has actually lost lock; the offset is plain holdover drift.', + ], + correctIndex: 0, + explanation: + 'That is the signature. Jamming is loud and obvious - you lose everything. Spoofing is polite: full bars, wrong time. The stronger, cleaner "GPS" your receiver found is the one on a roof three blocks away.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'go-holdover', + nice: ['S0421', 'K0752'], + title: 'Stop Trusting GPS', + description: + 'Flip the REF control in the SOURCE panel to HOLDOVER. The disciplined oscillator free-runs on its own inertia - it drifts nanoseconds per hour instead of microseconds per second of lies.', + groundStation: 'BKYD-GPS', + prerequisiteObjectiveIds: ['read-the-tell'], + conditions: [ + { + type: 'gpsdo-reference-mode-set', + description: 'Reference in Holdover', + params: { referenceMode: 'holdover' }, + mustMaintain: true, + maintainDuration: 60, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'all-clear', + nice: ['S0421', 'T0153'], + title: 'Ride It Out, Then Come Back', + description: + 'Stay on holdover until the intruder leaves the band - you will see the carrier vanish from the waterfall and the ΔT freeze. Then, and only then, put the reference back on GPS and let it re-acquire.', + groundStation: 'BKYD-GPS', + prerequisiteObjectiveIds: ['go-holdover'], + conditions: [ + { + type: 'status-check', + description: 'All-Clear Called Correctly', + params: { + character: Character.RILEY_BROOKS, + question: 'What did you verify before trusting GPS again?', + options: [ + 'The rogue carrier is gone from the waterfall AND the timing offset has stopped growing - the spoofer is off the air.', + 'Fifteen minutes have passed on the shift clock AND the offset has settled - a spoof cannot hold a lock longer than that.', + 'The receiver still reports 8 satellites AND the position fix is steady - a real outage would have dropped them by now.', + ], + correctIndex: 0, + explanation: 'Verify the SIGNAL environment, not the clock face. Satellite count was healthy the whole time - it was never evidence of anything.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + { + type: 'gpsdo-reference-mode-set', + description: 'Reference Back on GPS', + params: { referenceMode: 'gnss' }, + mustMaintain: false, + }, + { + type: 'gpsdo-gnss-locked', + description: 'GNSS Re-Acquired (4+ satellites)', + params: {}, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'noise-bump-log', + nice: ['K0684', 'K1032'], + title: 'Log the Incident', + description: 'First contact with an adversary, handled. Write down the principle before the adrenaline fades.', + groundStation: 'BKYD-GPS', + prerequisiteObjectiveIds: ['all-clear'], + conditions: [ + { + type: 'status-check', + description: 'Principle Recorded', + params: { + character: Character.RILEY_BROOKS, + question: 'What made the spoofer detectable, given that GPS signals carry no authentication at all?', + options: [ + 'Its physics were wrong: too strong, too clean, and standing still - legitimate signals must obey orbits.', + 'Its frequency was wrong: a few kilohertz off L1, too narrow, and unmodulated - legitimate signals sit on 1575.42 MHz.', + 'Its identity was wrong: a callsign, no almanac, and no ephemeris - legitimate signals carry a full navigation message.', + ], + correctIndex: 0, + explanation: + 'RF is unauthenticated; physics is your authentication. Orbits leave fingerprints. A real bird is weak, spread, and moving. Anything else is a claim, not a satellite. The rest of this campaign is that sentence, over and over.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

New antenna day! Ignore the paint stick, it is holding the mast up. Tonight we hunt the weakest signal you will ever chase: GPS. It's up there right now, twenty thousand kilometers out, whispering at every device in the neighborhood.

Fair warning: you will find it and you will never lock it, and understanding WHY is worth more than a hundred easy passes. Read the note.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

Below the noise floor, and it works anyway. Spread spectrum is the closest thing radio has to magic, and it's just arithmetic.

Now go find it. 1575.42. Don't look for a stripe - look for the noise itself getting gently... fatter.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'find-the-hump': { + text: `

THAT'S IT. That soft two-megahertz swell - that's a spacecraft older than you, heard on a patch antenna clamped to a paint stick. Detected, never decoded. Your receiver doesn't have the code, so to you it stays noise-shaped.

Keep the waterfall up while I get snacks. L1 never does anything interesting anyw—

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'spot-the-spoofer': { + text: `

...okay. That is NOT GPS. Real L1 is a whisper spread thin across megahertz - this thing is narrow and LOUD. Something in the neighborhood is transmitting in a protected band, and every receiver that can hear it is now listening to IT instead of the sky.

Check your clock panel. Quickly.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'read-the-tell': { + text: `

Walking clock, perfect constellation. Textbook spoof - and I mean that literally, it's in the textbooks, and seeing it live is still something else.

Your GPSDO believes every word that roof is saying. Stop it. REF to HOLDOVER - the oscillator's own flywheel is more honest than a liar with full bars.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'go-holdover': { + text: `

ΔT frozen. The lie is still on the air but nobody here is listening to it anymore. That's the whole defense: a good clock and the nerve to trust it over the sky.

Now we wait the intruder out. Watch the waterfall - you'll know the moment they give up.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'all-clear': { + text: `

Carrier gone, offset flat, and NOW we trust GPS again - because we verified the environment, not because we got tired of waiting.

You just detected and defeated a GPS spoofing attack with hardware that costs less than a textbook about GPS spoofing attacks. Log it.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'noise-bump-log': { + text: `

"Physics is your authentication." Underline it. Every scenario from here on is an argument between what a signal CLAIMS and what its physics prove.

Next session the network comes to us with a job - and a lesson about trusting other people's orbital elements.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario6.ts b/src/campaigns/ham-sdr/scenario6.ts new file mode 100644 index 00000000..ffd4a908 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario6.ts @@ -0,0 +1,324 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardQfhStation, backyardYagiStation } from './ground-stations'; +import { CUBEHOP1_TLE1, CUBEHOP1_TLE2, makeCubehop1Satellite, wxsat19Satellite } from './satellites'; + +/** + * ham-sdr Scenario 6 - "The Network Wants Vermont" / tampered-TLE diagnosis + * + * Riley's station joins a volunteer observation network, which asks for two + * contacts tonight: a WXSAT-19 image pass and a CUBEHOP-1 telemetry pass. + * The wrinkle: the CUBEHOP TLE Riley pulled from a mirror site is TAMPERED + * (RAAN shifted 60 deg) - the Observations list shows no CUBEHOP pass all + * afternoon while the network's request sheet promises one. The tell is the + * disagreement between two sources of truth; the fix is fetching fresh + * elements (M4 ephemeris panel, reskinned to amateur voice) and catching the + * bird on the recovered prediction. + * + * Engine notes: the scenario's CUBEHOP is a scenario-local instance + * (makeCubehop1Satellite) and the tamper is applied via spaceEvents[].initialTle + * on every load - replay-safe, and the roster instance S2-S4 share is never + * touched. First campaign use of settings.timeSkip (deferred from S4). + * + * Timeline (scenario clock starts 2027-06-23 14:40:00 UTC): + * - WXSAT-19: AOS 14:50, max el 35.9 deg, LOS 15:04 (network request #1) + * - CUBEHOP-1: briefed window ~16:59-17:12 at 56.8 deg (network request #2) + * - tampered TLE predicts NO CUBEHOP pass this afternoon + * - fresh elements restore the 16:59 prediction; timeSkip crosses the gap + * + * NICE Framework Alignment: + * Primary Codes: + * - K0820: Knowledge of supply chain risks + * - T0153: Monitor system performance + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - S0421: Skill in operating communications equipment + */ + +/** Tampered mirror TLE: RAAN 94 -> 154 deg. The bird this describes never rises over Vermont this afternoon. */ +const TAMPERED_CUBEHOP_TLE2 = '2 63002 97.5000 154.0000 0010000 90.0000 226.0000 14.90000000123456'; + +/** Scenario-local bird - the tamper must never leak into S2-S4's shared instance */ +const s6CubehopSatellite = makeCubehop1Satellite(); + +export const hamSdrScenario6Data: ScenarioData = { + id: 'ham-sdr-scenario6', + url: 'ham-sdr/scenarios/ham-sdr-scenario6', + imageUrl: 'nats/6/card.png', + number: 6, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['ham-sdr-scenario5'], + title: 'The Network Wants Vermont', + subtitle: 'Trust, but Cross-Check the Elements', + duration: '25-30 min', + missionType: 'Backyard Session', + description: `Riley signed the station up for a volunteer observation network - amateurs around the world catching passes the big stations can't be bothered with. Tonight the network wants Vermont twice: a WXSAT-19 weather image at 14:50, and CUBEHOP-1 telemetry at 16:59.

The weather pass is routine by now. The cubesat is the problem: the request sheet promises a high evening pass, and your Observations list says CUBEHOP-1 will not rise over Vermont at all today. One of them is lying.

Orbital elements are data files, fetched from mirrors, passed hand to hand. Nobody signs them. Tonight you find out what that costs - and how a good operator catches it.`, + equipment: [ + 'DIY 137 MHz Quadrifilar Helix (fixed skyward)', + 'DIY 70cm Crossed Yagi on TV Rotator', + 'RTL-SDR Receivers (Direct Sampling)', + 'SkyWatcher SDR Console + Observations', + ], + settings: { + isSync: true, + groundStations: [backyardQfhStation, backyardYagiStation], + satellites: [wxsat19Satellite, s6CubehopSatellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-23', + scenarioStartWallTime: '14:40:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-6?content-only=true&dark=true', + timeSkip: {}, + spaceEvents: [ + { + id: 'CUBEHOP-TLE', + satelliteNoradId: 63002, + maneuverAtS: 30, + label: "CUBEHOP-1 elements fail the network's cross-check", + // The truth, as published by the network + newTle: { tle1: CUBEHOP1_TLE1 as string, tle2: CUBEHOP1_TLE2 as string }, + // The tampered mirror file the station booted with (re-applied every + // load so replays keep the puzzle) + initialTle: { tle1: CUBEHOP1_TLE1 as string, tle2: TAMPERED_CUBEHOP_TLE2 }, + }, + ], + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0820'], + title: 'Read the Observation Requests', + description: + "The network's request sheet: two passes, two birds, exact windows. Note the CUBEHOP window - 16:59, fifty-seven degrees - and remember where your own predictions come from.", + groundStation: 'BKYD-QFH', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Request Sheet Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Element Supply Chain Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'Where did the TLE your Observations list runs on actually come from?', + options: [ + 'A text file, fetched from a volunteer mirror site, with no signature and no way to verify it.', + 'A telemetry frame, sent down by the satellite itself, with its own orbit and no need to verify it.', + 'A rotator log, measured during the first pass tonight, with a fitted orbit and nothing to verify.', + ], + correctIndex: 0, + explanation: + "Element sets are just files. Someone tracks the bird, someone publishes numbers, mirrors copy mirrors. It works because everyone is honest. 'Because everyone is honest' is not a security model.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'catch-wxsat', + nice: ['S0421', 'K1032'], + title: 'Network Request #1: WXSAT-19', + description: 'The weather bird rises at 14:50 - business as usual on the fence post. Lock the APT downlink and let the network have its picture.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'WXSAT-19 APT Detected', + params: { + signalId: 'WXSAT-19-APT', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'APT Locked, Image Flowing', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'spot-the-discrepancy', + nice: ['T0153', 'K0820'], + title: 'Two Sources, One Sky', + description: + 'Open Observations and find the 16:59 CUBEHOP-1 window the network promised. Take your time. It is not there - no CUBEHOP pass all afternoon. Decide who you believe.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['catch-wxsat'], + conditions: [ + { + type: 'tab-active', + description: 'Observations Checked', + params: { tab: 'pass-schedule' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Discrepancy Diagnosed', + params: { + character: Character.RILEY_BROOKS, + question: "The network's request sheet says CUBEHOP rises at 16:59. Your Observations list says it never rises today. What is the most likely explanation?", + options: [ + 'The local element file is wrong - stale or tampered. Predictions are only as good as the TLE they run on.', + 'The satellite is gone - destroyed or decayed since the sheet went out. Predictions cannot show a bird that no longer exists.', + 'The Observations list is the wrong tool - weather birds only. Predictions for a cubesat have to come from the request sheet.', + ], + correctIndex: 0, + explanation: + 'The sky has one truth and you hold two descriptions of it. The network cross-checks elements against actual observations from dozens of stations; your copy came from one unsigned mirror. Bet on the network - and verify with the pass.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'fetch-elements', + nice: ['S0421', 'K0820'], + title: 'Fetch Fresh Elements', + description: + 'The Observations tab has flagged the CUBEHOP element set - the network cross-check agrees with your suspicion. Fetch the fresh elements and watch the 16:59 window appear in the list.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['spot-the-discrepancy'], + conditions: [ + { + type: 'ephemeris-updated', + description: 'Fresh Elements Loaded', + params: { eventId: 'CUBEHOP-TLE' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'catch-cubehop', + nice: ['S0421', 'K1032', 'T0153'], + title: 'Network Request #2: CUBEHOP-1', + description: + 'The prediction is back and it matches the request sheet: 16:59, fifty-seven degrees. Skip ahead if you like the button, wait if you like the suspense - then track, tune, and lock. AFC is yours now.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['fetch-elements'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Rotator Tracking CUBEHOP-1', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'CUBEHOP-1 Downlink Detected', + params: { + signalId: 'CUBEHOP-1-FM', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'Telemetry Locked for the Network', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'supply-chain-log', + nice: ['K0820', 'K1032'], + title: 'Log the Lesson', + description: 'Two requests delivered, one bad element file caught. Write down the habit that caught it.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['catch-cubehop'], + conditions: [ + { + type: 'status-check', + description: 'Cross-Check Habit Recorded', + params: { + character: Character.RILEY_BROOKS, + question: 'What actually caught the tampered TLE tonight?', + options: [ + 'Comparing two independent sources - the network sheet against local predictions - and not explaining the disagreement away.', + 'Scanning the file with antivirus - the laptop flagged the download as modified - and refusing to load it into the console.', + 'The rotator refusing bad elements - the tracker rejected the tampered TLE - and holding at stow until a fresh file loaded.', + ], + correctIndex: 0, + explanation: + 'Cross-checking is the whole defense. Elements, timestamps, frequencies - any input someone hands you is a claim. Two independent claims that agree are evidence. One claim alone is a vibe.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

Big night! We're on the network roster now - real observation requests, from real people, who will send real passive-aggressive emails if we miss our windows.

Two jobs: weather picture at 14:50, cubesat telemetry at 16:59. I already pulled fresh TLEs from a mirror this morning, so we should be all set. Read the request sheet.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

Unsigned text files from volunteer mirrors. I know, I know - it's held since the seventies, mostly because nobody bothered to lie to amateurs before.

Weather bird first. Fence post, 137.1, you could do this asleep by now. Prove it.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'catch-wxsat': { + text: `

Request one, delivered. The network's plot of Vermont just got a weather map with your fingerprints on it.

Now line up the cubesat window. Pull up Observations and find the 16:59 pass... hm. Take a good look and tell me what you see. Or don't see.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'spot-the-discrepancy': { + text: `

Right. The sheet says 16:59 and the list says never. And look - the tab flagged it too: the element set failed the network's cross-check. My "fresh" morning TLEs. From the mirror. Great.

Someone fed that mirror garbage - by accident or not, doesn't matter tonight. Fetch the real elements and let's get our bird back.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'fetch-elements': { + text: `

And there it is - 16:59, fifty-seven degrees, exactly what the network promised. One good file and the whole sky snaps back into agreement.

We've got time before AOS. The skip button exists. I'm just saying.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'catch-cubehop': { + text: `

Locked! Request two, delivered, on a prediction we rebuilt ourselves after catching a bad element file. That's a full supply-chain incident, detected and recovered, before dinner.

Log it while I draft a VERY polite note to whoever runs that mirror.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'supply-chain-log': { + text: `

"Two independent claims that agree are evidence. One claim alone is a vibe." I'm putting that on a sticker.

Next time: something in the neighborhood starts making noise on our frequencies, and we get to find out where the yagi's REAL superpower is. Bring walking shoes.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario7.ts b/src/campaigns/ham-sdr/scenario7.ts new file mode 100644 index 00000000..8956ed22 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario7.ts @@ -0,0 +1,285 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardYagiStation } from './ground-stations'; +import { cubehop1Satellite } from './satellites'; + +/** + * ham-sdr Scenario 7 - "Margin Call" / local RFI foxhunt + marginal pass + * + * Local interference arc on the yagi rig. A neighbor's failing device starts + * hashing the 70cm band (E1 terrestrial emitter at 435.36 MHz, ~1.2 km due + * east): the noise floor jumps and tonight's only CUBEHOP pass is a LOW one - + * 18.4 deg max el, the thinnest link margin of the campaign. The player: + * 1. hears the hash and confirms it is local (no Doppler, no pass predicted), + * 2. DFs it with manual yagi sweeps (MAN AZ control - signal peaks on the + * emitter bearing, due east), + * 3. notches it out (FILTER section, one-knob notch at 435.360), + * 4. narrows the IF filter to 100 kHz (lower noise gate) and catches the + * marginal pass with the margin they just clawed back. + * + * Timeline (scenario clock starts 2027-06-24 15:15:00 UTC): + * - T+1:00 RFI on the air (continuous - accidents don't have duty cycles) + * - CUBEHOP-1: AOS 15:35, max el 18.4 deg, LOS 15:46 (the margin call) + * + * NICE Framework Alignment: + * Primary Codes: + * - S0648: Skill in detecting anomalies + * - S0421: Skill in operating communications equipment + * Supporting Codes: + * - K0740: Knowledge of system performance indicators + * - T0153: Monitor system performance + */ +export const hamSdrScenario7Data: ScenarioData = { + id: 'ham-sdr-scenario7', + url: 'ham-sdr/scenarios/ham-sdr-scenario7', + imageUrl: 'nats/7/card.png', + number: 7, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['ham-sdr-scenario6'], + title: 'Margin Call', + subtitle: 'Find the Noise. Then Beat It.', + duration: '20-25 min', + missionType: 'Backyard Session', + description: `Something in the neighborhood is sick. The 70cm band has grown a hash of noise that was not there yesterday - and tonight's only CUBEHOP-1 pass tops out at eighteen degrees, right in the trees, with no margin to spare.

A directional antenna is a signal finder AND a direction finder. Sweep the yagi by hand until the hash peaks and you own the bearing. Then take the interference out of the receiver: notch the hash, narrow the front-end filter until the noise gate drops below the bird, and catch a pass most operators would write off.

Decibels are a budget. Tonight you audit it.`, + equipment: ['DIY 70cm Crossed Yagi on TV Rotator (manual slew)', 'RTL-SDR Receiver (Direct Sampling)', 'SkyWatcher SDR Console (FILTER section unlocked)'], + settings: { + isSync: true, + groundStations: [backyardYagiStation], + satellites: [cubehop1Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-24', + scenarioStartWallTime: '15:15:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-7?content-only=true&dark=true', + interferenceEvents: [ + { + // The neighbor's failing device: continuous hash, 1.2 km due east. + // Continuous on purpose - deliberate interference has a duty cycle, + // accidents just run until somebody unplugs them. + id: 'ballast-rfi', + frequency: 435.36e6, + bandwidth: 80e3, + power: 20, // EIRP dBm + polarization: 'V', + startTime: 60, + duration: 5400, + periodSeconds: 5400, + onSeconds: 5400, + path: 'terrestrial', + emitter: { latitude: 44.48, longitude: -73.1949 }, + }, + ], + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: "The note covers two things: what a noise floor actually is, and why tonight's eighteen-degree pass leaves no room for one that has grown.", + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Link Margin Understood', + params: { + character: Character.RILEY_BROOKS, + question: 'Why does a LOW pass leave less margin than the overhead passes you have worked so far?', + options: [ + 'The bird is farther away and the signal grazes the trees - every dB of extra noise comes straight out of the decode margin.', + 'The bird transmits less power toward the horizon - every dB it holds back at low elevation comes out of the decode margin.', + 'The rotator has to move faster on a low pass - every degree the beam lags behind the bird costs a dB of decode margin.', + ], + correctIndex: 0, + explanation: + 'Slant range nearly doubles at 18 degrees versus overhead - more path loss - and the horizon is full of obstructions. On a fat pass you never notice a noisy band. Tonight you will.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'hear-the-hash', + nice: ['S0648', 'T0153'], + title: 'Confirm the Intruder', + description: + 'Get the hash on the waterfall - a raised, ragged band around 435.360. Note what it is NOT doing: not drifting, not scheduled, not in the Observations list. Everything about it says local.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'RFI Hash Detected near 435.360', + params: { + signalId: 'INTERFERER-ballast-rfi', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'df-the-source', + nice: ['S0421', 'S0648'], + title: 'Foxhunt: Take a Bearing', + description: + 'Use MAN AZ to sweep the yagi around the horizon and watch the hash strength. Twelve dB of forward gain means it peaks hard when the boom crosses the source. Park the beam on the peak.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['hear-the-hash'], + conditions: [ + { + type: 'antenna-position', + description: 'Beam on the Bearing (due east, +/-12 deg)', + params: { azimuth: 90, tolerance: 12 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'notch-it', + nice: ['S0421', 'K0740'], + title: 'Notch the Hash', + description: + "You cannot unplug the neighbor's gear, but you can carve it out of your receiver. In the FILTER section, enable the notch and center it on 435.360 - watch the hash drop out of the waterfall.", + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['df-the-source'], + conditions: [ + { + type: 'notch-filter-configured', + description: 'Notch Centered on 435.360 MHz', + params: { + notchCenterFrequency: 435.36, + notchCenterFrequencyTolerance: 0.03, + }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'narrow-and-catch', + nice: ['S0421', 'K0740', 'T0153'], + title: 'Make the Margin, Catch the Pass', + description: + 'Last dB: narrow the IF filter to 100 kHz - the front-end noise gate drops with it. Then TRACK CUBEHOP-1 and hold a lock through the eighteen-degree pass. AFC is allowed; excuses are not.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['notch-it'], + conditions: [ + { + type: 'filter-bandwidth-set', + description: 'IF Filter at 100 kHz', + params: { bandwidthIndex: 5 }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'Rotator Tracking CUBEHOP-1', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'receiver-signal-locked', + description: 'Locked Through the Low Pass', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'margin-log', + nice: ['K0740', 'S0648'], + title: 'Log the Audit', + description: 'You found decibels in three places tonight. Log where they came from.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['narrow-and-catch'], + conditions: [ + { + type: 'status-check', + description: 'Margin Audit Recorded', + params: { + character: Character.RILEY_BROOKS, + question: 'What told you the hash was LOCAL rather than a satellite?', + options: [ + 'No Doppler, no predicted pass, and the strength changed with the yagi bearing, not with time - ground signals hold still.', + 'Far too strong, no telemetry in it, and it peaked well above anything CUBEHOP has ever shown - satellites arrive faint.', + 'Wrong frequency, no listing for it, and 435.36 is 110 kHz above the CUBEHOP downlink - satellites never stray off channel.', + ], + correctIndex: 0, + explanation: + 'Physics again: real birds drift and set; ground noise sits there and swings with your beam. The DF sweep is the same trick you will use on the day something pretends to be a satellite.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: `

Bad news in stereo. One: something nearby is spraying hash all over seventy centimeters - started overnight, my money's on the new grow lights three doors down. Two: tonight's only CUBEHOP window tops out at eighteen degrees. In the TREES.

Good news: this is my favorite kind of night. We're going to find the noise, delete it, and steal that pass anyway. Read the note.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

Margin is arithmetic: signal minus noise minus losses, and whatever's left over is your picture. Tonight everything on the minus side got worse at once.

First, meet the enemy. Get the hash on the waterfall and really look at it.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'hear-the-hash': { + text: `

Ugly, right? Now notice everything it isn't doing. It isn't drifting - no Doppler. It isn't in the Observations list. It doesn't rise or set. That's not a bird, that's a NEIGHBOR.

So let's find which one. Sweep the yagi with MAN AZ and watch the strength. When the boom crosses the source, you'll know.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'df-the-source': { + text: `

Due east and LOUD. That's the Hendersons'. Called it. I'll print them a very friendly pamphlet about RF interference and ballast filters.

Diplomacy takes weeks; the pass is in minutes. Carve them out of the receiver instead - FILTER section, notch, 435.360.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'notch-it': { + text: `

Gone. Well - still on the air, but not in OUR passband, which is all physics ever promised us.

One more dB to find: the IF filter is 200 kHz wide and the noise gate charges by the hertz. Narrow it to 100 and the floor drops with it. Then get on the bird - it's coming in LOW.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'narrow-and-catch': { + text: `

LOCKED at eighteen degrees through the tree line with a jammed band. Do you understand what you just did? Most operators see a low pass on a bad band and go inside for dinner.

You audited the budget and found the decibels. Log it.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'margin-log': { + text: `

No Doppler, no schedule, swings with the beam: ground truth, literally. File that signature away - next time, the thing pretending to be in orbit won't be a grow light.

One scenario left. The tape comes off the microphone. Study for your exam.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/ham-sdr/scenario8.ts b/src/campaigns/ham-sdr/scenario8.ts new file mode 100644 index 00000000..bf3bcf72 --- /dev/null +++ b/src/campaigns/ham-sdr/scenario8.ts @@ -0,0 +1,419 @@ +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { backyardQfhStation, backyardTxStation } from './ground-stations'; +import { cubehop1Satellite, wxsat19Satellite } from './satellites'; + +/** + * ham-sdr Scenario 8 - "Callsign" / license exam, pirate carrier, first TX, + * fake beacon epilogue. The campaign finale: the tape comes off the microphone. + * + * Four acts on the TX-capable yagi rig (E2) plus the weather rig: + * 1. License exam - Riley's quiz chain (band plan, control operator, why ham + * bands cannot encrypt). No RF until it is passed. + * 2. Pirate prelude - an unlicensed carrier is being relayed by CUBEHOP-1's + * V/U transponder during the afternoon pass. Hear the unauthorized user on + * the downlink; understand what makes a transmission authorized. + * 3. First TX - evening pass: set the uplink to 435.900, key the transmitter, + * then tune the receiver to 435.290 and lock your OWN transponded downlink. + * 4. Fake beacon - after LOS, a "WXSAT-19 beacon" appears on the weather rig + * at exactly 137.100 - while no WXSAT pass exists until 23:46. Right + * frequency, impossibly strong, zero Doppler, never decodes: physics is + * your authentication. + * + * Timeline (scenario clock starts 2027-06-26 15:30:00 UTC): + * - T+20:00 pirate carrier on the CUBEHOP uplink (off again ~16:50) + * - CUBEHOP-1: AOS 15:55:47, max el 28.9 deg, LOS 16:07:43 (pirate act) + * - CUBEHOP-1: AOS 17:31:29, max el 25.7 deg, LOS 17:43:24 (first TX) + * - T+136:40 (~17:46:40) fake WXSAT-19 beacon on the air; the real bird's + * next pass is a 2 deg graze at 23:46 - the sky is provably empty + * + * NICE Framework Alignment: + * Primary Codes: + * - S0421: Skill in operating communications equipment + * - S0648: Skill in detecting anomalies + * Supporting Codes: + * - K0740: Knowledge of system performance indicators + * - T0153: Monitor system performance + */ +export const hamSdrScenario8Data: ScenarioData = { + id: 'ham-sdr-scenario8', + url: 'ham-sdr/scenarios/ham-sdr-scenario8', + imageUrl: 'nats/8/card.png', + number: 8, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['ham-sdr-scenario7'], + title: 'Callsign', + subtitle: 'The Tape Comes Off the Microphone', + duration: '25-30 min', + missionType: 'Backyard Session', + description: `Seven scenarios of listening. Tonight you talk back.

First the exam - Riley does not hand anyone a microphone who cannot recite the band plan and the control-operator rule in their sleep. Then a lesson nobody planned: somebody unlicensed is already on the bird, and the transponder relays them just as faithfully as it will relay you. RF is unauthenticated; a license is a promise, not a password.

Then the evening pass: five watts into the crossed yagi, up 435.900, down 435.290, and the strangest signal you will ever chase - your own voice coming back from orbit. Keep the receiver on it through LOS.

And afterwards, one last visitor proves why everything in this campaign mattered.`, + equipment: ['DIY 70cm Crossed Yagi on TV Rotator + 5W Brick Amplifier', 'SDR Transceiver (TRANSMIT unlocked)', 'QFH Weather Rig (137 MHz)'], + settings: { + isSync: true, + groundStations: [backyardTxStation, backyardQfhStation], + satellites: [cubehop1Satellite, wxsat19Satellite], + isExtraSatellitesVisible: false, + scenarioStartDate: '2027-06-26', + scenarioStartWallTime: '15:30:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-3/scenario-8?content-only=true&dark=true', + timeSkip: {}, + interferenceEvents: [ + { + // The pirate: an unlicensed carrier keyed up through CUBEHOP's V/U + // transponder (transponder path: power is received-at-satellite). + // Louder than the bird's own beacon on purpose. Long envelope so the + // afternoon pass is covered regardless of how long the exam takes, + // but OFF well before the 17:31 first-TX pass. + id: 'cq-pirate', + satelliteNoradId: 63002, + frequency: 435.905e6, // inside the 435.885-915 passband -> downlink 435.295 + bandwidth: 12e3, + power: -100, // dBm at the satellite -> ~+32 dBm transponded (beacon is +28) + polarization: 'RHCP', + startTime: 1200, + duration: 3600, + periodSeconds: 3600, + onSeconds: 3600, + }, + { + // The fake beacon: a ground transmitter impersonating WXSAT-19 on + // exactly 137.100 after the evening pass - when the real bird is + // below the horizon until a 2 deg graze at 23:46. Terrestrial path: + // no Doppler, no drift, never decodes, impossibly strong. + id: 'fake-wxsat', + frequency: 137.1e6, + bandwidth: 34e3, // dressed up in APT's clothing + power: 10, // EIRP dBm at ~2 km: arrives ~30 dB hotter than the real bird ever does + polarization: 'RHCP', + startTime: 8200, + duration: 5400, + periodSeconds: 5400, + onSeconds: 5400, + path: 'terrestrial', + emitter: { latitude: 44.498, longitude: -73.21 }, // ~2 km north of the yard + }, + ], + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: "Read Riley's Note", + description: + 'The note covers the 70cm band plan, the uplink/downlink pair CUBEHOP-1 listens on, and the one rule Riley will not bend: nobody transmits without passing her exam first.', + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Read', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'exam-bands', + nice: ['K0645', 'S0421'], + title: 'Exam, Part One: The Band Plan', + description: "Riley's exam, question one. The amateur bands are shared by agreement, not enforcement - know where you are allowed to put five watts before you own a key.", + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'status-check', + description: 'Band Plan Question Passed', + params: { + character: Character.RILEY_BROOKS, + question: 'CUBEHOP-1 listens on 435.900 MHz. Why is it legal for you to transmit there tonight?', + options: [ + '435.900 is in the 70cm satellite sub-band, my license covers it, and the bird is open to amateurs - all three have to be true.', + 'Five watts is under the licensing threshold, my brick amplifier stays below it, and the bird is in range - low power makes it legal.', + 'The receiver already hears the bird there, the transponder passband is 30 kHz wide, and the beacon is public - hearing it makes it legal.', + ], + correctIndex: 0, + explanation: + "Band, license, and the bird's own published plan - legality is the overlap of all three. Nothing about your radio enforces any of them. That is the whole point of tonight.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'exam-rules', + nice: ['K0645'], + title: 'Exam, Part Two: The Rules That Matter', + description: 'Question two: control operators and why you will never encrypt a word on these bands. Pass this and the tape comes off.', + groundStation: 'BKYD-YAGI', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: ['exam-bands'], + conditions: [ + { + type: 'status-check', + description: 'Control-Operator Question Passed', + params: { + character: Character.RILEY_BROOKS, + question: 'Why do amateur rules FORBID encrypting transmissions, when every other lesson in this campaign was about untrusted RF?', + options: [ + 'Amateur radio is self-policing - any operator must be able to identify any transmission, so openness is the enforcement.', + 'Amateur channels are too narrow - encryption adds overhead that a 15 kHz FM channel cannot carry, so the rules ban it.', + 'Amateur bands sit below 1 GHz - encryption needs bandwidth that only exists at microwave, so the rules do not allow it.', + ], + correctIndex: 0, + explanation: + "The bands stay usable because everyone can hear everyone. No secrecy means every operator is a sensor - which is also why a pirate is caught by listeners, not by the satellite. Remember that when you meet tonight's uninvited guest.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'hear-the-pirate', + nice: ['S0648', 'T0153'], + title: 'Somebody Is Already on the Bird', + description: + "Track CUBEHOP-1 through the afternoon pass and look 45 kHz above the beacon: a second carrier, coming down through the transponder at 435.295 - louder than the bird's own beacon. That is an uplink. It is not yours, and it is not licensed.", + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['exam-rules'], + conditions: [ + { + type: 'signal-detected', + description: 'Pirate Downlink Detected near 435.295', + params: { + signalId: 'INTERFERER-cq-pirate', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'pirate-ethics', + nice: ['S0648', 'K0645'], + title: 'What Makes a Transmission Authorized', + description: 'The transponder relayed the pirate exactly as faithfully as it relays anyone. Tell Riley what - if anything - the satellite could have done about it.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['hear-the-pirate'], + conditions: [ + { + type: 'status-check', + description: 'Authorization Understood', + params: { + character: Character.RILEY_BROOKS, + question: "What stopped the transponder from relaying the pirate's carrier?", + options: [ + 'Nothing - a bent pipe amplifies whatever falls in its passband; authorization lives in licenses and listeners on the ground.', + 'A callsign check - the transponder reads the ID on each uplink; the pirate sent a valid-looking one and it let him through.', + 'A command channel - the transponder keys only on its control uplink; the pirate found that frequency and opened the passband.', + ], + correctIndex: 0, + explanation: + 'RF is unauthenticated. Anything in the passband with the right polarization goes through. The bird cannot tell you from a pirate from a fake - every defense you have learned lives on the ground: band plans, physics, and operators paying attention.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'set-uplink', + nice: ['S0421'], + title: 'Dial In the Uplink', + description: + "Your turn. In the TRANSMIT section, set the TX frequency to 435.900 MHz - the center of CUBEHOP-1's uplink passband. The transponder is 30 kHz wide; sloppy tuning falls right off its edge.", + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['pirate-ethics'], + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX on 435.900 MHz (+/-5 kHz)', + params: { + modemNumber: 1, + frequency: 435.9e6, + frequencyTolerance: 5e3, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'first-contact', + nice: ['S0421', 'K0740'], + title: 'Work Yourself Through the Bird', + description: + 'Evening pass. TRACK CUBEHOP-1, key the transmitter, then tune the RECEIVER to 435.290 and catch your own signal coming back down. Five watts, up and over Vermont. AFC is allowed - the downlink still Dopplers even though your uplink holds still.', + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['set-uplink'], + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmitter Keyed (ON AIR)', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'Receiver on Your Downlink (435.290 +/-15 kHz)', + params: { + modemNumber: 1, + frequency: 435.29e6, + frequencyTolerance: 15e3, + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'Locked on Your Own Transponded Signal', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'sdr-console' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'clear-the-channel', + nice: ['S0421'], + title: 'Clear the Channel', + description: "LOS is coming. Unkey the transmitter - the transponder is a shared resource, and dead carriers are how tonight's other operator got his reputation.", + groundStation: 'BKYD-YAGI', + prerequisiteObjectiveIds: ['first-contact'], + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'Transmitter Unkeyed', + params: { modemNumber: 1 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'unmask-the-beacon', + nice: ['S0648', 'T0153'], + title: 'The Last Visitor', + description: + 'Switch to the weather rig. There is a "WXSAT-19 beacon" sitting on 137.100 - and the Observations list says the real bird does not rise again until 23:46, and then only two degrees above the trees. Look at everything this signal is not doing.', + groundStation: 'BKYD-QFH', + prerequisiteObjectiveIds: ['clear-the-channel'], + conditions: [ + { + type: 'signal-detected', + description: 'Impostor Carrier Detected on 137.100', + params: { + signalId: 'INTERFERER-fake-wxsat', + minPower: -90 as dBm, + requiresObservation: true, + observationTab: 'sdr-console', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Impostor Called Out', + params: { + character: Character.RILEY_BROOKS, + question: 'Which signal is the real WXSAT-19, and how do you know?', + options: [ + 'Neither - the real bird is below the horizon; the carrier has no Doppler, no rise or set, no decode, and far too much power.', + 'The 137.100 carrier - it sits on the published beacon frequency; the power, steady tone, and lack of Doppler put it overhead.', + 'Impossible to tell - the payload is not decoding; without reading APT frames there is no way to tie a carrier to a bird.', + ], + correctIndex: 0, + explanation: + "Ground truth wearing a satellite's frequency. Frequency is the ONE thing an impostor gets right for free. Doppler, schedule, decode, and power all have to agree with orbital mechanics - and orbital mechanics does not lie. Physics is your authentication. Class dismissed.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: `

Exam night. I know, I know - you have been flying the rig for weeks. But the microphone is different. Receive-only mistakes cost you a picture; transmit mistakes go to EVERYONE with an antenna, at the speed of light, with your callsign on them.

Read the note, pass my two questions, and the tape comes off. Then we have an afternoon pass to listen to, and an evening pass to TALK to.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + objectives: { + 'review-mission-brief': { + text: `

Band plan first. Where you may transmit is an agreement written down by people who never met you and enforced by nobody - which is exactly why every operator has to actually know it.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'exam-bands': { + text: `

One for one. Second question is the one people get wrong - it is about why these bands are naked on purpose.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'exam-rules': { + text: `

PASSED. Congratulations - as of tonight you are a control operator, which means everything that leaves this yard is yours to answer for.

Afternoon pass first, and... hm. Track the bird and look about forty-five kilohertz above the beacon. We appear to have company.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'hear-the-pirate': { + text: `

Hear him? Louder than the beacon - he is running way more power than he needs, which tells you everything about his operating class. No callsign, not in any club log, keying up over everyone.

And the bird? The bird relays him PERFECTLY. Think about what that means, then tell me.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'pirate-ethics': { + text: `

Exactly. The transponder is a bent pipe - in one side, amplified out the other, no questions asked. The license is a promise WE make, not a lock the satellite checks. He will be found the way pirates always are: by a hundred bored operators with directional antennas and grudges.

Enough about him. Evening pass in a bit - dial in YOUR uplink. 435.900, dead center.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'set-uplink': { + text: `

On frequency. When the bird comes up: TRACK it, key TX, and then - this is the part nobody believes until they do it - tune your own receiver to 435.290 and listen to yourself coming back from six hundred kilometers up.

Your uplink holds still; the DOWNLINK Dopplers. You know what to do about that.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'first-contact': { + text: `

THAT IS YOU. Five watts, a homemade yagi, a hundred-dollar radio - up to orbit and back down over two states. Every operator remembers this exact moment. Welcome to the club, for real.

Now mind your manners: LOS soon. Clear the channel.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + 'clear-the-channel': { + text: `

Clean release. The band forgets a polite operator instantly and remembers a rude one for years.

...Huh. Before you pack up - the weather rig is showing something it should not. WXSAT doesn't rise until nearly midnight. Go look.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'unmask-the-beacon': { + text: `

And THERE it is. Right frequency, wrong everything else: no Doppler, no rise, no decode, and thirty decibels too loud. Someone is broadcasting a costume.

Seven weeks ago you would have believed it. Tonight you checked the physics first. That is the entire course, and you passed it twice in one evening - once on paper, once for real. Seventy-three, operator. The yard is yours.

`, + character: Character.RILEY_BROOKS, + emotion: Emotion.EXCITED, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/campaign-data.ts b/src/campaigns/nats-eu/campaign-data.ts new file mode 100644 index 00000000..557fbb7c --- /dev/null +++ b/src/campaigns/nats-eu/campaign-data.ts @@ -0,0 +1,96 @@ +import type { CampaignData } from '@app/campaigns/campaign-types'; +import { natsEuSandboxData } from './sandbox'; +import { natsEuScenario1Data } from './scenario1'; +import { natsEuScenario2Data } from './scenario2'; +import { natsEuScenario3Data } from './scenario3'; +import { natsEuScenario4Data } from './scenario4'; +import { natsEuScenario5Data } from './scenario5'; +import { natsEuScenario6Data } from './scenario6'; +import { natsEuScenario7Data } from './scenario7'; +import { natsEuScenario8Data } from './scenario8'; +import { natsEuScenario9Data } from './scenario9'; +import { natsEuScenario10Data } from './scenario10'; +import { natsEuScenario11Data } from './scenario11'; +import { natsEuScenario12Data } from './scenario12'; +import { natsEuScenario13Data } from './scenario13'; +import { natsEuScenario14Data } from './scenario14'; +import { natsEuScenario15Data } from './scenario15'; +import { natsEuScenario16Data } from './scenario16'; +import { natsEuScenario17Data } from './scenario17'; +import { natsEuScenario18Data } from './scenario18'; +import { natsEuScenario19Data } from './scenario19'; +import { natsEuScenario20Data } from './scenario20'; +import { natsEuScenario21Data } from './scenario21'; +import { natsEuScenario22Data } from './scenario22'; +import { natsEuScenario23Data } from './scenario23'; +import { natsEuScenario24Data } from './scenario24'; +import { natsEuScenario25Data } from './scenario25'; +import { natsEuScenario26Data } from './scenario26'; +import { natsEuScenario27Data } from './scenario27'; +import { natsEuScenario28Data } from './scenario28'; + +/** + * NATS-EU Campaign (Campaign 2): North Atlantic Teleport Services EU + * + * The player is a NATS Campaign 1 graduate who transfers to GW-01 Galway, + * NATS Europe's LEO downlink site. Scenario 1 is gated on Campaign 1 + * graduation (S8 night-shift solo evaluation), not full C1 completion. + */ +export const natsEuCampaignData: CampaignData = { + id: 'nats-eu', + title: 'North Atlantic Teleport Services EU', + subtitle: 'Commercial Ground Station Operations', + description: `This campaign follows the North Atlantic Teleport Services EU branch across two sites: GW-01 Galway, the company's Low Earth Orbit (LEO) downlink station on the Irish west coast, and SH-02 Shetland, the second dish that turns one station into a network. As a Campaign 1 graduate you'll run the MERIDIAN synthetic-aperture radar constellation's passes from both consoles: sweep the station before AOS, acquire and track a target that is gone in ten minutes, decode and command inside the window, safe the uplink at LOS and plan the next contact across both sites.

Through twenty-four scenarios you'll develop essential skills in Ku-band link budgets, Doppler compensation, ephemeris management, command-link COMSEC, rain fade and reference holdover, all while supporting the operational needs of a commercial LEO constellation and its maritime customers.`, + imageUrl: 'nats-eu/north-atlantic-teleport-services-eu.png', + difficulty: 'intermediate', + // Sum of the per-scenario `duration` ranges (30-40 min each; the S24 capstone is 60). + totalDuration: '965-1000 min', + campaignType: 'LEO Commercial Communications', + headerIdentity: { + name: 'ATLANTIC', + nameAccent: 'OPS', + icon: 'fa-solid fa-earth-europe', + }, + // Same chrome as Campaign 1 on purpose: two facilities of one operator. + chromeVariant: 'standard', + // S1-S16 are content complete and saves are expected to hold, but the campaign + // is still under public test. + releaseStage: 'beta', + scenarios: [ + natsEuSandboxData, + natsEuScenario1Data, + natsEuScenario2Data, + natsEuScenario3Data, + natsEuScenario4Data, + natsEuScenario5Data, + natsEuScenario6Data, + natsEuScenario7Data, + natsEuScenario8Data, + natsEuScenario9Data, + natsEuScenario10Data, + natsEuScenario11Data, + natsEuScenario12Data, + natsEuScenario13Data, + natsEuScenario14Data, + natsEuScenario15Data, + natsEuScenario16Data, + natsEuScenario17Data, + natsEuScenario18Data, + natsEuScenario19Data, + natsEuScenario20Data, + natsEuScenario21Data, + natsEuScenario22Data, + natsEuScenario23Data, + natsEuScenario24Data, + natsEuScenario25Data, + natsEuScenario26Data, + natsEuScenario27Data, + natsEuScenario28Data, + ], + isLocked: false, + // Gated on the Campaign 1 graduation shift, matching the prerequisite on this + // campaign's own first scenario (natsEuScenario1Data) - not on clearing all + // 24 NATS scenarios. + prerequisiteScenarioIds: ['nats-level-8-night-shift'], + isDisabled: false, +}; diff --git a/src/campaigns/nats-eu/drills.ts b/src/campaigns/nats-eu/drills.ts new file mode 100644 index 00000000..c7354f37 --- /dev/null +++ b/src/campaigns/nats-eu/drills.ts @@ -0,0 +1,49 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import type { TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * Shared setup for the nats-eu drills (S25-S28, phase 18 G). + * + * A drill is a short single-purpose scenario at Galway in the days after the + * Gray Zone arc: no pass to fly, no voice clips, no timers. Each one closes a + * curriculum topic the arc left untaught - the threat model as a whole, a + * configuration baseline check, a removable-media event, a vendor session - + * and each is graded the way the arc is: audit-log entries the operator has + * to read, a decision graded on live facts, and a Working Document line. + * + * The MERIDIAN pair is loaded so the board, the timeline and the tracker + * behave as they do in every other nats-eu scenario; the element sets are + * the S17 sets (epoch 12 April), two to three weeks stale by the drill + * dates, which is fine for a scenario that never flies them. + */ + +const SAR1_DRILL_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27102.08333333 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61701 97.2000 228.0000 0010000 90.0000 226.2500 15.61320000123457' as TleLine2, +}; + +const SAR2_DRILL_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27102.08333333 .00001000 00000-0 10000-3 0 9994' as TleLine1, + tle2: '2 61702 98.0000 253.7500 0010000 90.0000 152.0000 15.58070000123456' as TleLine2, +}; + +/** Fresh MERIDIAN-SAR-1/SAR-2 instances for one drill (never share satellite objects between scenarios) */ +export function drillSatellites() { + return [createMeridianSar1(SAR1_DRILL_TLE), createMeridianSar2(SAR2_DRILL_TLE)]; +} + +/** GW-01 as the daybook has it: deep clone, so a drill may stage a drifted setting without touching the shared config */ +export function drillGalway(): GroundStationConfig { + return structuredClone(galwayGroundStation); +} + +export const DRILL_EQUIPMENT = [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO)', + 'Security Console (audit log + access control)', + 'QPSK 3/4 RX Modem', +]; + +export const DRILL_BRIEF = (n: number): string => `https://docs.signalrange.space/campaign-2/scenario-${n}?content-only=true&dark=true`; diff --git a/src/campaigns/nats-eu/ground-stations.ts b/src/campaigns/nats-eu/ground-stations.ts new file mode 100644 index 00000000..6ec00c36 --- /dev/null +++ b/src/campaigns/nats-eu/ground-stations.ts @@ -0,0 +1,277 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { FrequencyBand } from '@app/constants'; +import { type AntennaState } from '@app/equipment/antenna'; +import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; +import { type CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; +import type { Degrees } from 'ootk'; + +/** + * NATS Europe - Galway Ground Station (GW-01) + * + * Ku-band LEO downlink station on the Irish west coast. Frequency plan: + * - LNB LO 13100 MHz (high-side): 11686 MHz video -> 1414 MHz IF, + * 11711 MHz beacon -> 1389 MHz IF (both inside the 950-2150 MHz L-band IF; + * the beacon sits above the video's 11668-11704 MHz occupied band so the + * CW tone is not blocked by the stronger co-channel carrier) + * - BUC LO 12600 MHz (low-side): 1405 MHz IF -> 14005 MHz Ku uplink + * - Fast 4m pedestal (KU_BAND_4M_LEO_TRACKER, 20 deg/s) for LEO tracking + */ +export const galwayGroundStation = { + id: 'GW-01', + name: 'Galway Ground Station', + location: { + latitude: 53.27, + longitude: -9.05, + elevation: 20, + }, + antennas: [ANTENNA_CONFIG_KEYS.KU_BAND_4M_LEO_TRACKER], + antennaConfigKey: ANTENNA_CONFIG_KEYS.KU_BAND_4M_LEO_TRACKER, + antennasState: [ + { + // Powered and parked near the MERIDIAN-SAR-1 AOS azimuth (~5 deg); the + // operator selects the target and enables program-track for the pass + isPowered: true, + azimuth: 5 as Degrees, + elevation: 3 as Degrees, + polarization: 0 as Degrees, + trackingMode: 'manual', + targetAzimuth: 5 as Degrees, + targetElevation: 3 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconFrequencyHz: 11711e6 as Hertz, // MERIDIAN-SAR-1 telemetry beacon + beaconSearchBwHz: 1e6, // Wide enough for +/-273 kHz LEO Doppler + beaconTrackingBwHz: 1e3, + isLocked: false, + } as Partial, + ], + rfFrontEnds: [ + { + omt: { + isPowered: true, + txPolarization: 'H', + rxPolarization: 'V', + effectiveTxPol: 'H', + effectiveRxPol: 'V', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: true, + isMuted: false, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 12600 as MHz, // Ku-band low-side LO + filterHighHz: FrequencyBand.ku.upHigh, + filterLowHz: FrequencyBand.ku.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 23 as dB, + outputPower: -10 as dBm, + saturationPower: 15 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: true, + backOff: 10, + outputPower: 50 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 45, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 44 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 13, + bandwidth: 40 as MHz, + insertionLoss: 2.0, + noiseFloor: -101, + }, + lnb: { + isPowered: true, + loFrequency: 13100 as MHz, // Ku-band high-side LO (IF = LO - RF) + gain: 65 as dB, + lnaNoiseFigure: 0.8, + mixerNoiseFigure: 16.0, + noiseTemperature: 60, // K - stable + noiseTemperatureStabilizationTime: 0, + isExtRefLocked: true, + noiseFloor: -140, + frequencyError: 0, + temperature: 28, + thermalStabilizationTime: 0, + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 10 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, + couplingFactorB: -39, + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 9, + utcAccuracy: 50, + constellation: 'GPS', + lockDuration: 7200, + frequencyAccuracy: 2e-11, + allanDeviation: 1e-11, + phaseNoise: -110, + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 3, + max10MHzOutputs: 5, + output10MHzLevel: 7, + ppsOutputsEnabled: true, + operatingHours: 4380, + selfTestPassed: true, + agingRate: 1e-10, + }, + }, + ], + spectrumAnalyzers: [ + { + referenceLevel: -60 as dBm, + centerFrequency: 1414e6 as Hertz, // MERIDIAN-SAR-1 video downlink IF + span: 60e6 as Hertz, + rbw: 1e6 as Hertz, + minAmplitude: -110 as dBm, + maxAmplitude: -50 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + transmitters: [ + { + activeModem: 1, + modems: [ + { + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: false, + isTransmittingSwitchUp: false, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: 'GW-01-MERIDIAN-CMD', + serverId: 1, + noradId: 61701, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1405e6 as IfFrequency, // -> 14005 MHz Ku telecommand uplink + power: -7 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + }, + }, + ], + }, + ], + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 1414 as MHz, // MERIDIAN-SAR-1 video downlink IF + bandwidth: 36 as MHz, + modulation: 'QPSK', + fec: '3/4', + antenna_id: 1, + }, + ], + }, + ], +} as GroundStationConfig; + +/** + * NATS Europe - Shetland Ground Station (SH-02) + * + * The second EU site, debuting in scenario 5. Deliberately the SAME hardware as + * GW-01 (4m Ku LEO tracker, identical frequency plan) so the lesson the + * two-station scenarios teach is *scheduling*, not new equipment. + * + * Its higher latitude (60.15 N vs Galway's 53.27 N) gives complementary pass + * geometry on sun-synchronous birds - more passes per day, and overlap windows + * with Galway that the operator has to deconflict. + * + * Per-station propagation (phase 16, E1): every antenna sees an orbital + * satellite from its own station (OrbitalSatellite.geometryFor), so SH-02 + * tracks, hears and Doppler-shifts MERIDIAN on Shetland's own horizon, and the + * Pass Schedule tab / timeline deck predict from whichever station is + * selected. The canonical `az`/`el` on the satellite object remain + * Galway-relative (the satellite's configured observer); anything reading + * those directly is showing Galway's sky. Contact windows in + * `settings.contactSchedule` are still authored numbers; give each one a + * `stationId` and the validation harness checks it against that site's pass. + */ +export const shetlandGroundStation = { + // Deep clone, NOT a spread: the config's nested antennasState / rfFrontEnds / + // receivers objects are handed to the equipment constructors and mutated at + // runtime, so a shallow copy would make Shetland and Galway share one + // antenna's live state. + ...structuredClone(galwayGroundStation), + id: 'SH-02', + name: 'Shetland Ground Station', + location: { + latitude: 60.15, + longitude: -1.15, + elevation: 35, + }, +} as GroundStationConfig; diff --git a/src/campaigns/nats-eu/sandbox.ts b/src/campaigns/nats-eu/sandbox.ts new file mode 100644 index 00000000..f879ccfe --- /dev/null +++ b/src/campaigns/nats-eu/sandbox.ts @@ -0,0 +1,192 @@ +import type { ScenarioData } from '@app/ScenarioData'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * NATS Europe Sandbox Mode + * + * Free-practice environment for the Galway LEO station that also serves as the + * live proving ground for the Campaign 2 mechanics. Unlike a scored scenario it + * has no objectives or timer, but every opt-in mechanic block below is enabled, + * so all of the new consoles are instantiated and interactable: + * + * - linkBudget (M1) Link-budget / EIRP planning console + * - commanding (M2/M5) LEO uplink ops + command-link key ops (Doppler, TT&C, key rotation, zeroize) + * - contactSchedule (M3) Multi-station pass scheduling across GW-01 / SH-02 + * - spaceEvents (M4) SAR-2 conjunction-avoidance maneuver + ephemeris reload + * - security (M6) SOC-lite audit log + access control + * - transec (M7) Anti-jam frequency-hopping waveform + * - gnssThreat (M8) GNSS spoofing / timing attack on the station reference + * - weatherEvents (E5) Ku rain fade at Galway (phase 16) + * - hardwareFaultEvents (E3) timed BUC cooling fault at SH-02 and GNSS outage at GW-01 (phase 16) + * - two stations (E1) select SH-02 and the sky, pass schedule and deck are Shetland's (phase 16) + * + * The automated proof (test/campaigns/nats-eu-mechanics.test.ts) loads these + * exact settings and drives each manager to demonstrate every new objective + * condition transitioning to satisfied. + */ +export const natsEuSandboxData: ScenarioData = { + id: 'nats-eu-sandbox', + url: 'nats-eu/sandbox', + imageUrl: 'nats/8/card.png', + number: 0, + isDisabled: false, + difficulty: 'intermediate', + // Unlocked while Campaign 2 is under development: this sandbox is the proving + // ground for the new mechanics/consoles. Re-gate on scenario1 when it ships. + prerequisiteScenarioIds: [], + title: 'Sandbox', + subtitle: 'Free Practice Mode (Campaign 2 Mechanics)', + duration: 'Unlimited', + missionType: 'Sandbox', + description: `Explore the Galway LEO ground station freely without objectives or time limits. This sandbox has every Campaign 2 mechanic switched on: the link-budget planner, TT&C commanding with uplink Doppler and key operations, multi-station pass scheduling, on-orbit maneuvers with ephemeris updates, the SOC-lite security console, TRANSEC anti-jam, and GNSS spoofing recovery. +

Use it to practice the new consoles at your own pace before taking on the scored scenarios.`, + equipment: [ + '4-meter Ku-band LEO Tracking Antenna', + 'Ku-band RF Front End (13.1 GHz LNB / 12.6 GHz BUC)', + 'Pass Schedule Planner', + 'Link Analysis Console', + 'TT&C Command Console', + 'Station Security Console', + 'Spectrum Analyzer', + 'RX/TX Modems with Video Decoder', + ], + settings: { + isSync: true, + // Both sites, so per-station propagation (E1) is exercisable: select SH-02 + // and the sky, the pass schedule and the deck are Shetland's. + groundStations: [galwayGroundStation, shetlandGroundStation], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '14:00:00', + + // Contact timeline deck. A 6 h horizon in the sandbox so every mechanic can + // be exercised against several upcoming windows. + contactTimeline: { + horizonHours: 6, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // M1 - Link-budget / EIRP planning console. + // A correct worksheet for the max-el MERIDIAN-SAR-1 downlink yields ~14 dB C/N. + linkBudget: { + label: 'MERIDIAN-SAR-1 downlink, max-elevation pass', + expectedCNRDb: 14, + toleranceDb: 1.5, + thresholdCNRDb: 8, + requiredMarginDb: 3, + }, + + // M2/M5 - LEO uplink ops + command-link key ops. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61701, + requireValidKey: true, + requireDopplerComp: true, + commands: [ + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'OBC-WDT-RESET', label: 'Reset OBC watchdog' }, + ], + }, + + // M3 - Multi-station pass scheduling across Galway and Shetland. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 2, + contacts: [ + { id: 'SAR1-P1', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 pass 1', priority: 1, windowStartS: 120, windowEndS: 870 }, + { id: 'SAR2-P1', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 pass 1', priority: 1, windowStartS: 1050, windowEndS: 1890 }, + { id: 'SAR1-P2', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 pass 2', priority: 3, windowStartS: 6000, windowEndS: 6600 }, + ], + }, + + // Phase 16 E5 - Ku rain fade at Galway (moderate rain, 12 mm/h at peak: + // ~2 dB at 30 deg, ~4 dB at 10 deg on the 12 GHz downlink). Ramps in over + // 3 min from T+25 and clears by T+45. + weatherEvents: [{ id: 'gw-rain', groundStationId: 'GW-01', type: 'rain', severity: 'moderate', startTime: 1500, duration: 1200, linkMarginDegradation: 3 }], + + // Phase 16 E3 - timed equipment faults. SH-02's BUC loses cooling at T+15 + // for 10 min (mute it and it settles under the 70 degC alarm); GW-01's GNSS + // signal drops at T+40 for 5 min (holdover with the switch still up). + hardwareFaultEvents: [ + { + id: 'sh-buc-cooling', + groundStationId: 'SH-02', + target: 'buc-overtemp', + startTime: 900, + duration: 600, + params: { startTemperatureC: 66 }, + label: 'SH-02 BUC cooling fault', + }, + { id: 'gw-gnss-outage', groundStationId: 'GW-01', target: 'gpsdo-gnss-loss', startTime: 2400, duration: 300 }, + ], + + // M4 - Space-domain events: SAR-2 performs a conjunction-avoidance burn ~9 min + // in; its authored TLE goes stale until the operator loads the update. + spaceEvents: [ + { + id: 'SAR2-CAM', + satelliteNoradId: 61702, + maneuverAtS: 540, + label: 'MERIDIAN-SAR-2 conjunction-avoidance maneuver', + // Post-maneuver element set (same valid TLE format; reloaded at runtime). + newTle: { + tle1: '1 61702U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9997', + tle2: '2 61702 98.1000 30.2000 0010000 90.0000 236.5000 14.60000000123456', + }, + }, + ], + + // M6 - SOC-lite security console: audit log with two injected anomalies among + // routine traffic, plus a contractor account to disable. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-guest', name: 'Contractor (temp)', role: 'Maintenance', status: 'active' }, + ], + events: [ + { id: 'evt-login', timeS: 0, timestampLabel: '14:00 UTC', actor: 'op-charlie', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-cfg', timeS: 0, timestampLabel: '14:02 UTC', actor: 'op-charlie', action: 'Set receiver 1 frequency', category: 'config', severity: 'info' }, + { + id: 'evt-authfail', + timeS: 0, + timestampLabel: '03:14 UTC', + actor: 'op-guest', + action: 'Repeated failed logins (off-hours)', + category: 'auth', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-replay', + timeS: 0, + timestampLabel: '03:16 UTC', + actor: 'unknown', + action: 'Replayed TT&C command frame', + category: 'command', + severity: 'critical', + isAnomaly: true, + }, + ], + }, + + // M7 - TRANSEC anti-jam frequency-hopping waveform. + transec: { + groundStationId: 'GW-01', + hopChannelsHz: [1405e6, 1410e6, 1415e6, 1420e6], + requireKey: true, + }, + + // M8 - GNSS spoofing / timing attack on the station reference. + gnssThreat: { + groundStationIds: ['GW-01'], + spoofStartS: 300, + offsetDriftUsPerS: 5, + }, + }, + objectives: [], +}; diff --git a/src/campaigns/nats-eu/satellites.ts b/src/campaigns/nats-eu/satellites.ts new file mode 100644 index 00000000..8bf4b7da --- /dev/null +++ b/src/campaigns/nats-eu/satellites.ts @@ -0,0 +1,272 @@ +import { OrbitalObserver, OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { TransponderConfig } from '@app/equipment/satellite/satellite'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; + +/** + * NATS Europe satellite roster. + * + * Unlike the GEO TIDEMARK fleet, these are SGP4-propagated LEO satellites: + * az/el/range are computed from the TLE against the simulated scenario clock + * relative to the Galway ground station, with Doppler applied to downlinks. + * + * TLE epochs are authored for the 2027-03-15 14:00:00 UTC scenario start + * (via scripts/author-passes.mjs): + * - MERIDIAN-SAR-1: AOS T+2.0 min, max el 28.0 deg at T+6.7, LOS T+11.5 min + * - MERIDIAN-SAR-2: AOS T+17.5 min, max el 25.0 deg at T+22.2, LOS T+26.9 min + * Both make follow-up passes ~93+ min later for multi-contact planning. + * + * The geometry pairs a low, strong orbit (mean motion 15.6, ~360 km -> ~760 km + * slant range at max el, ample C/N margin) with a modest max elevation + * (~25-28 deg). At the LEO tracker's realistic 20 deg/s slew (see + * KU_BAND_4M_LEO_TRACKER) the pedestal holds the narrow (~0.45 deg) Ku beam on + * the bird within the ephemeris-error floor (~0.1 deg) through the whole pass, + * so C/N peaks at max elevation as expected. A higher or near-zenith pass grows + * a pointing lag (and ultimately an azimuth keyhole, rate -> infinity at the + * zenith) that craters C/N at culmination; that steep-pass case is verified as + * a regression in test/campaigns/nats-eu-rf-validation.test.ts and reserved as + * a dedicated later-scenario lesson (see the campaign design plan). + */ + +/** Galway Ground Station (GW-01) observer used for relative telemetry */ +export const galwayObserver: OrbitalObserver = { + name: 'Galway Ground Station', + lat: 53.27 as Degrees, + lon: -9.05 as Degrees, + alt: 0.02 as Kilometers, +}; + +/** A TLE pair as stored in a scenario file. */ +export interface MeridianTle { + tle1: TleLine1; + tle2: TleLine2; +} + +/** + * Phase 1 (S1-S8) shares one clock and these module-level element sets: + * epoch 2027-03-15 14:00:00 UTC (see the header). + */ +export const MERIDIAN_SAR1_PHASE1_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9996' as TleLine1, + tle2: '2 61701 97.2000 176.0000 0010000 90.0000 8.0000 15.60000000123454' as TleLine2, +}; + +export const MERIDIAN_SAR2_PHASE1_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61702 98.4000 42.0000 0010000 90.0000 240.0000 15.60000000123458' as TleLine2, +}; + +/** + * MERIDIAN-SAR-3 (61703) RF plan. Deliberately distinct from SAR-1/2 so the + * operator has to read the LEOP card: video 11760 MHz (IF 1340), telemetry + * beacon 11785 MHz CW (IF 1315; outside the 11742-11778 video band per the + * Phase A adjacency finding), TT&C uplink 14065 MHz (IF 1465), transponded + * downlink 11810 MHz. Same 360 km / mm 15.6 orbit as its siblings. + */ +export const MERIDIAN_SAR3_VIDEO_RF_HZ = 11760e6 as RfFrequency; +export const MERIDIAN_SAR3_BEACON_RF_HZ = 11785e6 as RfFrequency; +export const MERIDIAN_SAR3_TTC_UPLINK_RF_HZ = 14065e6 as RfFrequency; + +function sar1Options() { + return { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.08 as Degrees, + ephemerisErrorEl: 0.05 as Degrees, + transponderConfigs: [ + { + id: 'TP-CMD', + uplinkCenterFrequency: 14005e6 as RfFrequency, // Ku telecommand uplink + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.255e9 as Hertz, // Transponded downlink: 11750 MHz + polarization: 'H', + beacon: { + // Ku telemetry beacon (CW). Must stay OUTSIDE the video carrier's + // occupied band (11686 +/- 18 MHz): a CW tone inside a ~23 dB + // stronger co-channel carrier is blocked by the antenna's + // adjacency filter (Phase A RF validation finding). + frequency: 11711e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-1-Beacon', + serverId: 1, + noradId: 61701, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + { + id: 'TP-PAYLOAD', + uplinkCenterFrequency: 14100e6 as RfFrequency, // Unused payload uplink slot + bandwidth: 40e6 as Hertz, + frequencyOffset: 2.4e9 as Hertz, + polarization: 'H', + // Direct-transmit SAR imagery downlink (modeled as a transponder beacon). + // 28 dBm EIRP closes the link at C/N >= 8 dB from ~20 deg elevation + // (peak ~14 dB at max el) on the GW-01 4m Ku tracker; the original + // 22 dBm peaked at 8.1 dB with zero margin (Phase A RF validation). + beacon: { + frequency: 11686e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-1-VIDEO', + serverId: 1, + noradId: 61701, + power: 28 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + polarization: 'V', + feed: 'blue-1.mp4', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + }; +} + +function sar2Options() { + return { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.06 as Degrees, + ephemerisErrorEl: 0.07 as Degrees, + transponderConfigs: [ + { + id: 'TP-CMD', + uplinkCenterFrequency: 14035e6 as RfFrequency, + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.255e9 as Hertz, // Transponded downlink: 11780 MHz + polarization: 'H', + beacon: { + frequency: 11703e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-2-Beacon', + serverId: 1, + noradId: 61702, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + { + id: 'TP-PAYLOAD', + uplinkCenterFrequency: 14130e6 as RfFrequency, + bandwidth: 40e6 as Hertz, + frequencyOffset: 2.4e9 as Hertz, + polarization: 'H', + // 28 dBm EIRP for the same link-closure reason as SAR-1 (SAR-2 flies + // ~100 km higher, so it peaked at only 6.8 dB C/N at 22 dBm). + beacon: { + frequency: 11730e6 as RfFrequency, + signalId: 'MERIDIAN-SAR-2-VIDEO', + serverId: 1, + noradId: 61702, + power: 28 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + polarization: 'V', + feed: 'blue-2.mp4', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + }; +} + +function sar3Options() { + return { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.07 as Degrees, + ephemerisErrorEl: 0.06 as Degrees, + transponderConfigs: [ + { + id: 'TP-CMD', + uplinkCenterFrequency: MERIDIAN_SAR3_TTC_UPLINK_RF_HZ, + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.255e9 as Hertz, // Transponded downlink: 11810 MHz + polarization: 'H', + beacon: { + frequency: MERIDIAN_SAR3_BEACON_RF_HZ, + signalId: 'MERIDIAN-SAR-3-Beacon', + serverId: 1, + noradId: 61703, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + { + id: 'TP-PAYLOAD', + uplinkCenterFrequency: 14160e6 as RfFrequency, + bandwidth: 40e6 as Hertz, + frequencyOffset: 2.4e9 as Hertz, + polarization: 'H', + beacon: { + frequency: MERIDIAN_SAR3_VIDEO_RF_HZ, + signalId: 'MERIDIAN-SAR-3-VIDEO', + serverId: 1, + noradId: 61703, + power: 28 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + polarization: 'V', + feed: 'blue-1.mp4', // SAR-1's clip until a SAR-3 clip is cut (public/videos) + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + }; +} + +/** + * Scenario-local satellite factories (Phase 2 onward). + * + * Every call returns a FRESH OrbitalSatellite carrying the shared transponder + * plan and the element set the caller passes in. Scenarios from S9 on each have + * their own epoch and their own authored TLEs, and `reloadTle` (space events) + * mutates the instance, so sharing one module-level bird across scenarios would + * leak one scenario's elements into the next (the C3 shared-instance lesson). + */ +export function createMeridianSar1(tle: MeridianTle): OrbitalSatellite { + return new OrbitalSatellite('MERIDIAN-SAR-1', 61701, [], [], { ...tle, observer: galwayObserver }, sar1Options()); +} + +export function createMeridianSar2(tle: MeridianTle): OrbitalSatellite { + return new OrbitalSatellite('MERIDIAN-SAR-2', 61702, [], [], { ...tle, observer: galwayObserver }, sar2Options()); +} + +export function createMeridianSar3(tle: MeridianTle): OrbitalSatellite { + return new OrbitalSatellite('MERIDIAN-SAR-3', 61703, [], [], { ...tle, observer: galwayObserver }, sar3Options()); +} + +/** Phase 1 shared instances (S1-S8 and the sandbox). Do not use from S9 on. */ +export const meridianSar1Satellite = createMeridianSar1(MERIDIAN_SAR1_PHASE1_TLE); +export const meridianSar2Satellite = createMeridianSar2(MERIDIAN_SAR2_PHASE1_TLE); diff --git a/src/campaigns/nats-eu/scenario1.ts b/src/campaigns/nats-eu/scenario1.ts new file mode 100644 index 00000000..152d6d65 --- /dev/null +++ b/src/campaigns/nats-eu/scenario1.ts @@ -0,0 +1,797 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * NATS Europe - Scenario 1: "First Light Over Galway" + * + * The player's first shift at the Galway LEO downlink station, and the site's + * first end-to-end contact. Charlie Brooks is site lead and teaches only what + * is new about LEO and Ku; everything Campaign 1 taught is assumed. + * + * Phase 16 rewrite: the whole shift, not just the pass. Five beats around each + * contact (plan section 3): a station sweep and receiver set-up before AOS, + * the acquisition, the in-pass work, and the retune between contacts, then the + * shift log. Every threshold is on a flown pass (nats-eu-rf-validation.test.ts). + * + * Clock: sim starts 2027-03-15 13:48:00 UTC; the brief freezes it until read. + * Pass timeline (Galway, 5 deg mask as the Pass Schedule tab shows it): + * - MERIDIAN-SAR-1: AOS 14:03:10 az 000, max el 28.0 at 14:06:45 (761 km), + * LOS 14:10:18 az 224. Horizon crossings 14:01:59 / 14:11:28. + * C/N >= 8 dB roughly 14:05:00 .. 14:08:30, peak ~11 dB at culmination. + * - MERIDIAN-SAR-2: AOS 14:18:42 az 130, max el 25.0 at 14:22:10 (828 km), + * LOS 14:25:40 az 000. C/N >= 8 dB roughly 14:20:30 .. 14:24:00. + * Doppler on the 11.7 GHz downlink: +274 kHz at the horizon rising, through 0 + * at culmination, to -271 kHz at LOS. With the high-side LNB LO the IF + * spectrum is inverted, so the beacon appears BELOW 1389 MHz while the bird + * approaches and ABOVE it after culmination. + * + * Staged state (scenario-local clone of GW-01): the acceptance crew left the + * tracker stowed south (az 180) and RX modem 1 on their 1400 MHz BPSK 1/2 test + * carrier. The operator has fifteen minutes to make the station ready. + * + * NICE Framework Alignment: + * Primary Codes: + * - S0421: Skill in operating network equipment + * - T0153: Monitor network capacity and performance + * - K1032: Knowledge of satellite-based communication systems and software + * + * Supporting Codes: + * - K0645: Knowledge of standard operating procedures (SOPs) + * - K0740: Knowledge of system performance indicators + * - K0741: Knowledge of system availability measures + * - K0773: Knowledge of telecommunications principles and practices + * - T0431: Check system hardware availability, functionality, integrity + * - T1580: Report service status to stakeholders + */ + +/** + * GW-01 as the acceptance crew left it. Deep clone: nested state is handed to + * the equipment constructors and mutated at runtime. + */ +const galwayFirstLight: GroundStationConfig = structuredClone(galwayGroundStation); +galwayFirstLight.antennasState![0] = { + ...galwayFirstLight.antennasState![0], + azimuth: 180 as Degrees, + elevation: 3 as Degrees, + targetAzimuth: 180 as Degrees, + targetElevation: 3 as Degrees, +}; +galwayFirstLight.receivers![0].modems![0] = { + ...galwayFirstLight.receivers![0].modems![0], + frequency: 1400 as MHz, + bandwidth: 20 as MHz, + modulation: 'BPSK', + fec: '1/2', +}; + +export const natsEuScenario1Data: ScenarioData = { + id: 'nats-eu-scenario1', + url: 'nats-eu/scenarios/nats-eu-scenario1', + imageUrl: 'nats/8/card.png', + number: 1, + isDisabled: false, + difficulty: 'intermediate', + // Campaign 2 assumes a qualified Campaign 1 operator: gate on the S8 + // night-shift graduation, not full Campaign 1 completion (design plan §2). + prerequisiteScenarioIds: ['nats-level-8-night-shift'], + title: 'First Light Over Galway', + subtitle: 'LEO Pass Operations', + duration: '35-40 min', + missionType: 'LEO Downlink', + description: `Welcome to Galway, Ireland - NATS Europe's newest LEO downlink site. Charlie Brooks has transferred from Vermont to stand up GW-01, a Ku-band station tasked with capturing SAR imagery from the MERIDIAN constellation. +

Unlike the geostationary TIDEMARK fleet you trained on, MERIDIAN satellites scream overhead in minutes. The acceptance crew left the station stowed; you have a quarter of an hour to sweep it, tune the receiver, put the analyzer on the beacon and park the tracker on the rise azimuth. Then ride the pass with program-track, decode the imagery downlink before the bird drops below the horizon, and turn the station round for a second contact seven minutes later.`, + equipment: [ + '4-meter Ku-band LEO Tracking Antenna', + 'Ku-band RF Front End (13.1 GHz LNB / 12.6 GHz BUC)', + 'Pass Schedule Planner', + 'Spectrum Analyzer', + 'RX/TX Modems with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayFirstLight], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:48:00', + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-1?content-only=true&dark=true', + + // Contact timeline deck. The whole scenario is "make ready, wait for AOS, + // work the pass" - seeing when the next window opens is the point. + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // A fast operator can skip the dead air between "ready" and AOS. The skip + // is blocked while a timed objective runs and stops 2 min before the pass. + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'GW-01 Shift Log', + description: 'Galway commissioning shift, 2027-03-15. One line per contact: time, bird, result, peak C/N and the elevation it happened at.', + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the Shift Brief', + description: 'Open the shift brief and acknowledge you are ready to make the station ready for the first MERIDIAN contact.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Brief Opened', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Ready to Make Ready', + params: { + character: Character.SYSTEM, + question: 'Have you reviewed the shift brief? MERIDIAN-SAR-1 rises at 14:03 and the station is not ready.', + options: ['Yes, brief reviewed. Starting the pre-pass sweep.'], + correctIndex: 0, + explanation: 'Shift clock started at 13:48. Fifteen minutes to AOS.', + pointPenalty: 0, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: PRE-PASS - STATION SWEEP AND RECEIVER SET-UP + // ============================================================ + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Clear the board before anything else: confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at turnover?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear', + 'GPSDO in holdover - reference alarm, timing at risk', + 'LNB reference unlocked - hardware alarm, RX chain down', + ], + correctIndex: 0, + explanation: + 'One line on the board and it is not a fault: the receive AGC sits at its 10 dB rail because the tracker is stowed and there is nothing in the beam. It clears when the beacon arrives. The acceptance crew left the hardware healthy; what they did not leave is a station configured for this pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover. Every LO in the rack keys off it, and at Ku a reference error is a frequency error you can see.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'downconversion-plan', + nice: ['K0773', 'S0421'], + title: 'Confirm the Ku Downconversion Plan', + description: 'LNB LO at 13100 MHz. Work out where the 11686 MHz SAR-1 imagery downlink comes out at IF before you tune anything to it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'lnb-lo-set', + description: 'LNB LO 13100 MHz', + params: { loFrequency: 13100 as MHz, loFrequencyTolerance: 0, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'IF Arithmetic', + params: { + character: Character.SYSTEM, + question: 'LNB LO 13100 MHz, imagery downlink 11686 MHz. Where does the carrier sit at IF?', + options: ['1414 MHz', '24786 MHz', '11686 MHz', '1370 MHz'], + correctIndex: 0, + explanation: + 'High-side LO: IF = LO - RF = 1414 MHz. Vermont was low-side, IF = RF - LO. Same arithmetic, opposite sign, and one consequence you will see in ten minutes: the spectrum is inverted, so Doppler walks the wrong way on the analyzer.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'tune-receiver', + nice: ['S0421', 'K1032'], + title: 'Tune the Receiver for SAR-1', + description: 'The acceptance crew left modem 1 on their 1400 MHz test carrier. Retune it for the imagery downlink: 1414 MHz, 36 MHz, QPSK, rate 3/4.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['downconversion-plan'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'rx-modem-bandwidth-set', + description: 'RX 36 MHz', + params: { modemNumber: 1, bandwidth: 36e6, bandwidthTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'rx-modem-modulation-set', + description: 'QPSK', + params: { modemNumber: 1, modulation: 'QPSK' }, + mustMaintain: true, + }, + { + type: 'rx-modem-fec-set', + description: 'FEC 3/4', + params: { modemNumber: 1, fec: '3/4' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'analyzer-on-beacon', + nice: ['S0421', 'K0773'], + title: 'Analyzer on the SAR-1 Beacon', + description: + 'Centre the spectrum analyzer on the SAR-1 telemetry beacon at 1389 MHz IF with a 2 MHz span. The beacon is a CW line, so it is the one signal that shows you Doppler.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-receiver'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'speca-center-frequency', + description: 'Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Why the Span', + params: { + character: Character.SYSTEM, + question: 'SAR-1 closes at over 7 km/s. Why not a 200 kHz span around the beacon?', + options: [ + 'Doppler walks the beacon about 550 kHz end to end and off a narrow span', + 'The analyzer cannot resolve a CW line at any span below 1 MHz', + 'The beacon is 36 MHz wide and needs the full span to fit on screen', + 'A narrow span raises the displayed noise floor and hides the line', + ], + correctIndex: 0, + explanation: 'At 11.7 GHz the range rate gives about +/- 270 kHz. Two megahertz keeps the whole walk on screen: watch it slide through the centre at culmination.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'review-pass-schedule', + nice: ['K1032', 'S0421'], + title: 'Review the Contact Schedule', + description: 'Open the Pass Schedule tab and read off the AOS time and rise azimuth for MERIDIAN-SAR-1. The tab works the 5 degree mask, not the geometric horizon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['analyzer-on-beacon'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'AOS Read', + params: { + character: Character.SYSTEM, + question: 'When does MERIDIAN-SAR-1 clear the 5 degree mask, and from where?', + options: ['14:03, azimuth 000', '14:07, azimuth 294', '14:10, azimuth 224', '14:18, azimuth 130'], + correctIndex: 0, + explanation: + 'Rises due north at 14:03, culminates at 28 degrees in the west-north-west at 14:06:45, sets south-west at 14:10. Under nine minutes above the mask. 14:18 on azimuth 130 is SAR-2.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'The tracker is stowed south. Slew it to the rise azimuth, 000 at 5 degrees, so the pedestal is waiting when the bird clears the mask.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-pass-schedule'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 000 / El 5', + params: { azimuth: 0, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: MERIDIAN-SAR-1 PASS + // ============================================================ + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: + 'Set the antenna to program-track on MERIDIAN-SAR-1 and confirm the beacon on RX analysis once the bird is up. The 4 m pedestal slews at 20 deg/s - plenty for a 28 degree pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'lock-the-downlink', + nice: ['T0153', 'K0740'], + title: 'Lock the Imagery Downlink', + description: 'Lock the QPSK 3/4 carrier at 1414 MHz and hold C/N above 8 dB through the high-elevation segment. That is the acceptance criterion for the site.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'read-the-culmination', + nice: ['K0740', 'K0773'], + title: 'Read the Pass at Culmination', + description: 'Around 14:06:45 the bird is at 28 degrees and 761 km. Confirm C/N of 9 dB or better at the top of the pass, and read what the beacon did on the analyzer.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['lock-the-downlink'], + conditions: [ + { + type: 'receiver-snr-threshold', + description: 'C/N >= 9 dB at Culmination', + params: { modemNumber: 1, minCNRatio: 9, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Doppler Read', + params: { + character: Character.SYSTEM, + question: 'The beacon started a quarter of a megahertz BELOW 1389 MHz and crossed to above it at culmination. Why below first?', + options: [ + 'Approach raises the RF and the high-side LO inverts it, so IF drops', + 'The beacon transmitter drifts low while the spacecraft is still cold', + 'The LNB LO pulled low under load, so the whole IF band sat low', + 'The analyzer reference level was set too high, so the line read low', + ], + correctIndex: 0, + explanation: + 'IF = LO - RF. Positive Doppler on approach makes the RF higher and the IF lower; after culmination the sign flips. On a low-side plan it would walk the other way. Peak C/N is where the range is shortest, not where the elevation is highest by accident: both happen at culmination.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 5: BETWEEN CONTACTS + // ============================================================ + { + id: 'retune-for-sar2', + nice: ['S0421', 'K0773'], + title: 'Retune for SAR-2', + description: + 'SAR-1 sets at 14:10. MERIDIAN-SAR-2 rises at 14:18 from azimuth 130: retune modem 1 to its 1370 MHz imagery carrier and put the analyzer on its 1397 MHz beacon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-culmination'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1397 MHz', + params: { centerFrequency: 1397e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'SAR-2 Plan', + params: { + character: Character.SYSTEM, + question: 'SAR-2 imagery is at 11730 MHz and its beacon at 11703 MHz. Where are they at IF, and when is the next window?', + options: [ + 'Imagery 1370 MHz, beacon 1397 MHz; AOS 14:18 azimuth 130', + 'Imagery 1414 MHz, beacon 1389 MHz; AOS 14:18 azimuth 130', + 'Imagery 1430 MHz, beacon 1403 MHz; AOS 14:18 azimuth 130', + 'Imagery 1370 MHz, beacon 1397 MHz; AOS 15:50 azimuth 199', + ], + correctIndex: 0, + explanation: + '13100 - 11730 = 1370 and 13100 - 11703 = 1397. Different bird, different carrier, same arithmetic. The 14:18 window opens seven minutes after SAR-1 sets; 15:50 is the pass after that.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: MERIDIAN-SAR-2 PASS + // ============================================================ + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032', 'T0153'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'Retarget the tracker to MERIDIAN-SAR-2 before 14:18 and confirm its imagery carrier on RX analysis once it clears the mask.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retune-for-sar2'], + conditions: [ + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink Detected', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar2', + nice: ['T0153', 'K0740'], + title: 'Decode the Second Contact', + description: 'Lock the 1370 MHz carrier and hold C/N above 8 dB through the high-elevation segment of the SAR-2 pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4: SHIFT LOG + // ============================================================ + { + id: 'log-first-light', + nice: ['T1580', 'K0645'], + title: 'Log First Light', + description: 'Two contacts down. Write the acceptance result into the shift log the way Rotterdam wants it: a number the next shift can compare against.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Shift Log Entry', + params: { + character: Character.SYSTEM, + question: 'What goes in the log for the 14:03 SAR-1 contact?', + options: [ + 'Decoded; C/N held above 8 dB, peak ~11 dB at 28 degrees, no anomalies', + 'Decoded; C/N not measured, peak elevation 28 degrees, no anomalies', + 'Missed; stale ephemeris, no C/N recorded, anomaly logged', + 'Deferred; next pass 23:00, no C/N recorded, no anomalies', + ], + correctIndex: 0, + explanation: 'Peak C/N and the elevation it happened at. Predicted was 11 dB; anything more than 3 dB under that goes to Rotterdam as a flag. Site is accepted.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '14:03Z MERIDIAN-SAR-1 GW-01: decoded, C/N >= 8 dB through culmination, peak ~11 dB at 28 deg. 14:18Z MERIDIAN-SAR-2: decoded, C/N >= 8 dB. Site first light achieved. No anomalies.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:47] +

+

+ "Good to have you across the water. Acceptance crew signed the hardware off last night and left it stowed - tracker south, modem on their test carrier. First MERIDIAN window opens at 14:03 and that is the site's first light, so I would like the station ready before it, not during it. Sweep it, tune it, park it on the rise. I am in the front office if you need me. You will not." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'reference-check': { + text: ` +

+ Reference first, same as Vermont. The only thing that changes up here is what hangs off it: at Ku a part-per-billion on the 10 MHz is a hundred-odd hertz on the LO, and you will be chasing a carrier that is already moving. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'downconversion-plan': { + text: ` +

+ High-side LO on this LNB. IF is LO minus RF, so the whole spectrum is upside down compared with Vermont. Get the arithmetic straight now; it bites later. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'tune-receiver': { + text: ` +

+ The acceptance people ran BPSK half-rate at 1400 for their loopback and left it there. Imagery is 36 megahertz of QPSK three-quarters at 1414. Modem does not know that until you tell it. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'analyzer-on-beacon': { + text: ` +

+ Put the analyzer on the beacon, not the video. Two megahertz wide. The video carrier is too fat to show you anything; the beacon is a single line and it is going to walk, and I want you to see which way. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'review-pass-schedule': { + text: ` +

+ Scheduler works the five degree mask. Below that you are looking through fifty kilometres of wet air at a bird you cannot use. Read the AOS off the tab, not the horizon. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'preposition-for-aos': { + text: ` +

+ Park it on the rise azimuth. Twenty degrees a second sounds like a lot until you are a hundred and eighty degrees off when the bird comes up. Manual is fine for this; program-track once it is there. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'acquire-sar1': { + text: ` +

+ Program-track, target SAR-1, and let the pedestal fly it. If you try to follow this by hand you will lose it inside the first minute. Beacon on the analyzer is your proof you are on the right bird. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'lock-the-downlink': { + text: ` +

+ Lock. That is first light for the site - write the time down somewhere. Eight dB is the acceptance line; hold it through the top of the pass. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'read-the-culmination': { + text: ` +

+ Look at the beacon now. It came up below centre, it is crossing, it will leave above. That is the inverted spectrum doing exactly what the arithmetic said. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'retune-for-sar2': { + text: ` +

+ Gone at 14:10. That is a pass: nine minutes above the mask and nobody asked your permission. Second bird in seven. Different carrier, same LO - you know what to do with that. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'acquire-sar2': { + text: ` +

+ Retarget the tracker before it rises, not after. The pedestal will sit on the horizon at azimuth 130 waiting; that is what you want. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'decode-sar2': { + text: ` +

+ Two for two on the site's first day. Rotterdam will pretend they expected nothing less. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'log-first-light': { + text: ` +

+ Write it down properly. Peak C/N and the elevation it came at - a number the next shift can hold you to. Then go and find some coffee; the next one is in ninety minutes and it is a graze. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario10.ts b/src/campaigns/nats-eu/scenario10.ts new file mode 100644 index 00000000..c2ecd837 --- /dev/null +++ b/src/campaigns/nats-eu/scenario10.ts @@ -0,0 +1,819 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dB, dBm, IfFrequency } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2 } from './satellites'; + +/** + * nats-eu Scenario 10 - "Priority Tasking" / Urgent Collect, Low Pass + * + * No new mechanics. The lesson is geometry: everything the operator has done + * on 28 to 40 deg passes now has to be done on a pass that peaks at 18 deg, + * where the link budget closes with about two decibels to spare and the uplink + * needs 6 dB more EIRP to reach the bird at long range. Erik's vessel of + * interest is off the Faroes and this is the only MERIDIAN-SAR-1 window that + * covers it today: the collect is tasked by command on the pass and the + * imagery is pulled down on the same pass. + * + * Phase 16 rewrite: the whole tasking, not just the pass. Seventeen minutes of + * pre-pass work (sweep, reference, worksheet, the EIRP plan as steps - BUC + * gain restored, HPA back-off set, output power proven once the amplifier is + * up - the uplink modem retuned, the tracker pre-positioned), the pass, then + * the chain safed in order, the back-off restored, the log and the customer. + * + * Scenario-local element sets, TLE epoch 2027-03-18 15:30:00 UTC; the clock + * starts 15:18:00 (moved 12 min earlier in phase 16 so the pre-pass beats + * fit). Authored with scripts/author-passes.mjs, observer Galway: + * - MERIDIAN-SAR-1 (61701): AOS T+16.98 min (15:34:59Z, az 27), max el 17.8 deg + * at T+21.52 (15:39:31Z, slant range 1040 km), LOS T+26.05 min (15:44:03Z, + * az 159), 9.1 min, southbound. First pass after scenario start. + * - MERIDIAN-SAR-2 (61702): AOS 16:24:59Z, max el 25.0 deg at 16:29:43Z, + * LOS 16:34:26Z. Present in the sky, not worked. + * + * Link-budget worksheet (published in the budget objective): + * - slant range at max elevation 1040 km -> FSPL 174.1 dB at 11686 MHz + * (S2 worked 761 km / 171.4 dB: 3.7 dB more path loss here) + * - EIRP 28 dBm, GW-01 4m Ku gain 51.8 dBi, Tsys 88 K, BW 36 MHz, misc 1.2 dB + * (the extra 0.2 dB over S2/S8 is the longer atmospheric path at 18 deg) + * - correct worksheet -> C/N 8.09 dB (expectedCNRDb 8.1, tolerance 1.0) + * - threshold 6 dB (QPSK 3/4 demod) + 1 dB required margin + * + * Uplink: HPA back-off 10 -> 4 dB is +6 dB EIRP. Below 3 dB the amplifier is + * overdriven (`isOverdriven`, IMD alarm). The HPA output is drive + gain - + * back-off, so the BUC has to be delivering its rated drive first: the + * evening shift left the BUC gain wound down to 18 dB after a maintenance + * run (staged state), and the EIRP plan starts by restoring 23 dB. At 4 dB + * back-off the HPA delivers about 30 dBm (1 W) into the feed on this chain + * (`hpa-output-power-set` 0.5 W is the proof once the amplifier is up); at + * 10 dB it is a quarter of that. + * + * Commanding window is the SAR-1 pass with 20 s guard bands: + * AOS 1019 s + 20 -> windowStartS 1039; LOS 1563 s - 20 -> windowEndS 1543. + * Commands ACK on window + Doppler comp + key only, so the EIRP requirement + * is enforced by the equipment conditions, not by the ACK. + * + * SAFETY NOTE (as S3): enabling the HPA with no drive from the BUC trips the + * HPA_NOISE_AMPLIFICATION invariant and fails the mission. The tasking + * objective keys the modem before the HPA and the description says so. + * + * Staged state (scenario-local clone): BUC gain 18 dB; TX modem 1 on the + * 1435 MHz SAR-2 telecommand IF from Tuesday's rotation. + * + * NICE Framework Alignment: + * Primary Codes: + * - T0080: Test and evaluate system performance against requirements + * - T1567: Conduct satellite command and control operations + * - K0740: Knowledge of system performance indicators + * + * Supporting Codes: + * - S0675: Skill in configuring RF transmit equipment within safe limits + * - K0064: Knowledge of performance tuning tools and techniques + * - T0153: Monitor network capacity and performance + * - T0431: Check system hardware availability, functionality, integrity + * - S0421: Skill in operating network equipment + * - K0773: Knowledge of telecommunications principles and practices + * - K1032: Knowledge of satellite-based communication systems + * - K0645: Knowledge of standard operating procedures + * - S0478: Skill in communicating technical results to customers + * - T1580: Report mission outcome to the requesting organization + */ + +/** SAR-1 element set for this epoch: the 18 deg southbound pass at 15:35. */ +const meridianSar1LowPass = createMeridianSar1({ + tle1: '1 61701U 27015A 27077.64583333 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61701 97.2000 226.2500 0010000 90.0000 0.2500 15.60000000123456' as TleLine2, +}); + +/** SAR-2 element set for this epoch: a 25 deg pass at 16:25, not worked. */ +const meridianSar2Parked = createMeridianSar2({ + tle1: '1 61702U 27015A 27077.64583333 .00001000 00000-0 10000-3 0 9994' as TleLine1, + tle2: '2 61702 98.4000 233.7500 0010000 90.0000 164.5000 15.60000000123452' as TleLine2, +}); + +/** + * GW-01 as the afternoon finds it. Deep clone, never a spread: the nested + * state is handed to the equipment constructors and mutated at runtime. + * BUC gain wound down to 18 dB after the morning maintenance run; TX modem + * still on SAR-2's 1435 MHz command IF from Tuesday's rotation. + */ +const galwayTasking: GroundStationConfig = structuredClone(galwayGroundStation); +galwayTasking.rfFrontEnds[0].buc = { ...galwayTasking.rfFrontEnds[0].buc, gain: 18 as dB }; +galwayTasking.transmitters![0].modems![0].ifSignal.frequency = 1435e6 as IfFrequency; + +export const natsEuScenario10Data: ScenarioData = { + id: 'nats-eu-scenario10', + url: 'nats-eu/scenarios/nats-eu-scenario10', + imageUrl: 'nats/10/card.png', + number: 10, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario9'], + title: 'Priority Tasking', + subtitle: 'Urgent Collect, Low Pass', + duration: '30 min', + missionType: 'Tasking', + description: `15:18 local. Erik Halvorsen at Nordic Maritime Watch has a vessel of interest off the Faroes and needs SAR imagery of it today. The only MERIDIAN-SAR-1 window that covers the box peaks at 18 degrees over Galway, in seventeen minutes.

The collect has to be tasked by command early in that pass, and the imagery has to come down on the same pass. Anneke at constellation ops has confirmed the bird is available.

The link budget says the margin at 18 degrees is about two decibels. The uplink needs more EIRP than you have ever run from GW-01, the amplifier has a limit, and the evening shift left the chain in a state you will have to read before you trust it. One pass. Everything else is arithmetic, done before the horizon.`, + equipment: ['4m Ku-Band LEO Tracking Antenna', 'Ku-Band BUC (12600 MHz LO) + HPA', 'Link Analysis / TT&C Commanding consoles', 'QPSK 3/4 Transmit and Receive Modems'], + settings: { + isSync: true, + groundStations: [galwayTasking], + satellites: [meridianSar1LowPass, meridianSar2Parked], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-18', + scenarioStartWallTime: '15:18:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-10?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // A fast operator can skip the dead air between "ready" and AOS. The skip + // is blocked while a timed objective runs and stops 2 min before the pass. + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'GW-01 Tasking Log', + description: 'Urgent tasking NMW-URG-2027-0318, 2027-03-18. What was commanded, what the chain was set to, what came down.', + }, + + // M1 - the low-pass budget. expectedCNRDb is what a CORRECT worksheet + // produces from the numbers published in the budget objective. + // Measured under real program-track (nats-eu-phase-c-validation): peak + // 8.11 dB at max elevation, ~150 s at or above 7 dB, ~40 s at or above 8 dB. + linkBudget: { + label: 'Low-elevation collect: SAR-1 downlink at 18 deg', + expectedCNRDb: 8.1, + toleranceDb: 1.0, + thresholdCNRDb: 6, + requiredMarginDb: 1, + }, + + // M2 - the tasking command. Window is the SAR-1 pass with 20 s guard + // bands: AOS T+16.98 (1019 s) .. LOS T+26.05 (1563 s). + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61701, + windowStartS: 1039, + windowEndS: 1543, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'SAR-TASK-URGENT', label: 'Task urgent SAR collect' }, + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0740'], + title: 'Read the Tasking', + description: 'Open the tasking brief and confirm the target box, the pass, and what an 18 degree pass does to the numbers you are used to.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Tasking Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Low-Pass Geometry Understood', + params: { + character: Character.SYSTEM, + question: 'This pass peaks at 18 degrees. Against the 28 degree passes you have worked, what changes in the link budget?', + options: [ + 'Slant range at max elevation is about 1040 km instead of 761 km: nearly 3 dB more path loss, so the margin shrinks to about 2 dB.', + 'Nothing in the budget changes at 18 degrees: a lower pass is shorter, so the window closes sooner but the numbers hold.', + 'The satellite EIRP drops at low elevation: the bird has to be commanded to full power first, or the margin is gone.', + 'The receive antenna gain falls off at low elevation: the dish has to be re-peaked at AOS, or the margin is gone.', + ], + correctIndex: 0, + explanation: + 'Nearly 3 dB of extra path loss plus more atmosphere, and the margin over threshold shrinks to about two decibels. Path loss goes as the square of the range, and at 18 degrees the signal crosses several times more atmosphere than it does near the zenith. The receiver will still lock, but only just, and only near max elevation. Seventeen minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: SWEEP, REFERENCE, BUDGET, EIRP PLAN, UPLINK, PARK + // ============================================================ + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Clear the board before the tasking: confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 when the tasking comes in?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the tasking', + 'HPA over-temperature - hardware alarm, transmit chain at risk', + 'GPSDO in holdover - reference alarm, timing at risk for the pass', + ], + correctIndex: 0, + explanation: + 'The AGC rail is empty sky, not a fault, and nothing else is tripped; that does not mean the chain is set the way the tasking needs it. The maintenance run this morning left its fingerprints on the BUC and the modem.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover, BUC on the external reference. The uplink frequency the bird hears is only as good as this.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'budget-the-low-pass', + nice: ['T0080', 'S0015', 'K0740'], + title: 'Budget the Low Pass', + description: + 'Fill the Link Analysis worksheet for MERIDIAN-SAR-1 at maximum elevation and press Compute. Worksheet numbers: satellite EIRP 28 dBm; slant range at max elevation 1040 km (free-space path loss 174.1 dB at 11686 MHz); GW-01 receive gain 51.8 dBi; system noise temperature 88 K; occupied bandwidth 36 MHz; miscellaneous losses 1.2 dB.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Link Analysis Tab Open', + params: { tab: 'link-budget' }, + mustMaintain: false, + }, + { + type: 'link-budget-computed', + description: 'Predicted C/N Matches Truth', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Atmospheric Loss Read', + params: { + character: Character.SYSTEM, + question: 'The card carries 1.2 dB of miscellaneous losses where the acceptance worksheet used 1.0. Where does the extra 0.2 dB come from?', + options: [ + 'The atmospheric path at 18 degrees is about three times longer than at the zenith, so gaseous absorption at Ku adds a couple of tenths', + 'Cable loss increases when the antenna points near the horizon, so the feed run to the LNB adds a couple of tenths at 18 degrees', + 'The LNB noise figure is worse at low elevation, so the colder sky no longer compensates and a couple of tenths come off the G/T', + 'It is a rounding allowance with no physical meaning, so the card carries a couple of tenths to absorb error in the worksheet', + ], + correctIndex: 0, + explanation: + 'Clear-sky gaseous loss at 12 GHz is a few hundredths of a dB straight up and scales roughly with one over the sine of the elevation. At 18 degrees that is a few tenths, and on a pass with two decibels of margin a few tenths belong on the card.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'restore-the-buc-gain', + nice: ['S0675', 'K0064', 'T0431'], + title: 'Restore the BUC Gain', + description: + 'The morning maintenance run left the BUC gain at 18 dB. The station standard is 23 dB, and the HPA back-off numbers on the card assume it. Set 23 dB and confirm the BUC stays out of compression.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['budget-the-low-pass'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'buc-gain-set', + description: 'BUC Gain 23 dB', + params: { gain: 23, gainTolerance: 0.5 }, + mustMaintain: true, + }, + { + type: 'buc-not-saturated', + description: 'BUC Out of Compression', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Chain Arithmetic Read', + params: { + character: Character.SYSTEM, + question: 'The HPA output is its input plus gain minus back-off. What would 4 dB of back-off have delivered with the BUC still at 18 dB?', + options: [ + 'The EIRP of a 9 dB back-off on a correct chain: 5 dB short of the plan, with every indicator green', + 'The same EIRP as the plan: the HPA compensates for the drive level, with every indicator green', + 'An overdriven HPA at the planned EIRP: less drive means more gain, with the IMD alarm tripped', + 'Nothing on the air: the BUC would refuse to unmute below 20 dB, with a drive alarm on the chain', + ], + correctIndex: 0, + explanation: + 'EIRP is a sum, and the back-off is only one term. The card\'s "4 dB" assumes the BUC delivers its rated drive. Read the chain from the modem outward before you set the number at the end of it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'raise-the-eirp', + nice: ['S0675', 'K0064', 'K0740'], + title: 'Raise Uplink EIRP Without Overdriving', + description: + 'The command has to reach the bird at long range: take the HPA back-off from 10 dB to 4 dB for 6 dB more EIRP. Below 3 dB the amplifier is overdriven and the IMD alarm trips.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['restore-the-buc-gain'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-back-off-set', + description: 'HPA Back-Off at 4 dB', + params: { backOff: 4, backOffTolerance: 0.5 }, + mustMaintain: true, + }, + { + type: 'hpa-not-overdriven', + description: 'HPA Linear (No IMD Alarm)', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'IMD Limit Read', + params: { + character: Character.SYSTEM, + question: 'The IMD readout worsens by about 2 dB for every dB of back-off you give up, and the alarm trips below 3 dB. What is the cost of ignoring it for one pass?', + options: [ + 'Intermodulation products spill into the neighbouring channels: a splattered band, which Rotterdam says costs more than a missed collect', + 'Nothing on a single pass: the alarm is a maintenance reminder, and one collect at 3 dB of back-off is inside the HPA rating', + 'The HPA shuts itself down on the alarm: the command is lost mid-pass, and the chain has to be brought back up from cold', + 'The bird rejects the command: a distorted carrier fails the uplink CRC, and the ACK never comes back before LOS', + ], + correctIndex: 0, + explanation: + 'An amplifier driven past its linear region makes new frequencies out of the ones it was given. Those go up the same dish at the same EIRP, into channels that belong to somebody else. 4 dB is the number: 6 dB more EIRP, 1 dB of linearity to spare.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'tune-the-uplink', + nice: ['S0421', 'K0773', 'T1567'], + title: 'Tune the Uplink', + description: + "TX modem 1 is on SAR-2's 1435 MHz command IF from Tuesday. Retune it to 1405 MHz (14005 MHz through the 12600 MHz BUC LO) for SAR-1, and engage uplink Doppler compensation on the TT&C console now, not at AOS.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['raise-the-eirp'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX 1405 MHz', + params: { modemNumber: 1, frequency: 1405e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 15:34:59 from azimuth 027, north-northeast. Park the tracker on the rise azimuth at 5 degrees so the slew is done before she is over the horizon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-the-uplink'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 027 / El 5', + params: { azimuth: 27, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: THE PASS + // ============================================================ + { + id: 'acquire-low', + nice: ['S0421', 'K1032'], + title: 'Acquire at the Horizon', + description: 'AOS 15:35. Program-track MERIDIAN-SAR-1 and confirm the beacon on RX analysis as it clears the horizon. Chain cold until the beacon is in.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'task-the-collect', + nice: ['T1567', 'K0773', 'S0675'], + title: 'Task the Collect', + description: + 'Modem on air FIRST, then the HPA; with the amplifier up, the TX chain should show at least half a watt into the feed at 4 dB back-off. Then send SAR-TASK-URGENT early in the pass. The imaging block needs lead time to arm before the bird is over the box, and the window closes at 15:43:43.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-low'], + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Enabled', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-output-power-set', + description: 'HPA Output >= 0.5 W', + params: { minOutputPower: 0.5, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'command-acknowledged', + description: 'SAR-TASK-URGENT Acknowledged', + params: { commandId: 'SAR-TASK-URGENT' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'pull-the-imagery', + nice: ['T0153', 'K0740', 'T0080'], + title: 'Pull the Imagery With the Margin You Have', + description: + 'Lock the 1414 MHz imagery downlink and hold C/N above the 6 dB threshold for 30 seconds, then commit the link in Link Analysis at max elevation (15:39:31) with at least 1 dB over threshold. Commit early and the margin is not there.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['task-the-collect'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Held Above 6 dB for 30 s', + params: { modemNumber: 1, minCNRatio: 6, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + maintainDuration: 30, + }, + { + type: 'link-margin-met', + description: 'Measured Margin >= 1 dB', + params: { minMarginDb: 1 }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Short Window Read', + params: { + character: Character.SYSTEM, + question: 'On the 28 degree passes 8 dB held for four minutes. Today it holds for about forty seconds either side of 15:39:31. Why is the window so short?', + options: [ + 'On a low pass the bird never gets close: range falls from 2000 km to 1040 km and climbs straight back, so the C/N curve is a spike', + 'The Doppler rate is highest on a low pass: the modem loses lock as the shift crosses zero, so the window is only the crossing', + 'The imaging block is short: the bird transmits the collect for only forty seconds, so the carrier is only there for that long', + 'The HPA at 4 dB back-off desensitises the receiver: the uplink leaks into the LNB everywhere except at closest approach', + ], + correctIndex: 0, + explanation: + 'Elevation is range. Near the zenith a pass spends minutes within a few percent of its closest range; at 18 degrees it is close for seconds. The number the card asks for exists at culmination and nowhere else on the curve.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + + // ============================================================ + // BEAT 4: SAFE, RESTORE, LOG, REPORT + // ============================================================ + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645', 'S0675'], + title: 'Safe the Uplink', + description: 'LOS 15:44:03. Chain down in the mirror order: HPA output off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['pull-the-imagery'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'restore-the-back-off', + nice: ['S0675', 'K0645', 'K0064'], + title: 'Restore the Back-Off', + description: 'Put the HPA back-off back to the station standard, 10 dB, before anyone else uses this chain.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-back-off-set', + description: 'HPA Back-Off at 10 dB', + params: { backOff: 10, backOffTolerance: 0.5 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Standard Restored', + params: { + character: Character.SYSTEM, + question: 'Why put the back-off back to 10 dB now, when the next tasking might need 4 again?', + options: [ + '4 dB was the EIRP for one 18 degree geometry; on a normal pass it is 6 dB over the coordinated level, a risk to the neighbours', + 'The HPA cannot run at 4 dB for more than one pass; on a normal pass it overheats, a hardware risk for the transmit chain', + 'The back-off resets itself when the HPA is disabled; on a normal pass the chain comes up at 10 dB whatever the card says', + 'The IMD alarm only clears at 10 dB; on a normal pass the readout at 4 dB trips it, an alarm the next operator has to explain', + ], + correctIndex: 0, + explanation: + 'Exceptions are set for a pass and cleared after it. A chain left at 4 dB is a station that is not the one on the card for the next operator. The station standard is what everything else - the card, the coordination filing, the next shift - assumes. Leave the chain the way the paperwork says it is.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'log-the-tasking', + nice: ['T1580', 'K0645', 'S0842'], + title: 'Log the Tasking', + description: 'Write the tasking into the log: what was commanded, what the chain was set to and for how long, what came down.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['restore-the-back-off'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Tasking Logged', + params: { + character: Character.SYSTEM, + question: 'What does the tasking log need beyond "collect tasked, imagery received"?', + options: [ + 'The command and its ACK time, the EIRP exception (4 dB back-off, restored after), the measured C/N against the 8.1 dB prediction', + "The customer's name and the target box, the tasking reference (as received, as accepted), the frame count against the request", + 'Just the ACK and its time, the frames received (count and quality), the note that the frames prove the rest of the chain', + 'The worksheet inputs from the card, the predicted C/N (8.1 dB, from the card), the note that the measured figure matched it', + ], + correctIndex: 0, + explanation: + "A log line is the next person's evidence, and the C/N goes in with the geometry it was measured at. The EIRP exception in particular: without it, a 4 dB back-off found on the chain next week is a mystery, and with it, it is a finding.", + pointPenalty: 5, + documentSection: 'Tasking', + documentLine: + '15:35Z MERIDIAN-SAR-1 GW-01, urgent collect NMW-URG-2027-0318: SAR-TASK-URGENT ACK inside the window under Doppler comp and a valid key. EIRP exception: BUC gain restored 18 -> 23 dB, HPA back-off 4 dB for the pass, restored to 10 dB after LOS. Imagery decoded at 18 deg, link committed >= 1 dB over the 6 dB threshold; peak C/N ~8.1 dB against 8.1 predicted.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'report-to-customer', + nice: ['S0478', 'T1580'], + title: 'Report to Erik', + description: 'The frames are in. Tell the customer what he got and what this geometry means for the next request.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-tasking'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Customer Report Sent', + params: { + character: Character.SYSTEM, + question: 'The collect decoded at about 8 dB C/N, two decibels over threshold. What do you tell the customer?', + options: [ + 'Captured and usable, and that this geometry is the floor: anything lower needs the Shetland site or the next orbit.', + 'Captured and repeatable, and that this geometry is routine: GW-01 can take the same box on any pass he likes.', + 'Captured but degraded, and that this geometry is the cause: the link was marginal, so the imagery lost detail.', + 'Captured and filed, and that this geometry is a station matter: the technical details are in the tasking log.', + ], + correctIndex: 0, + explanation: + 'Two decibels over threshold is a clean decode, not a degraded one; the frames are as good as any. What the customer needs to know is that 18 degrees is about the lowest pass this station will close, so the next box further north is a Shetland job or a wait for a better orbit.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Erik Halvorsen, Nordic Maritime Watch, 15:17] +

+

+ "Vessel of interest went dark off the Faroes overnight. I have a box, 62.5 N 7.5 W, forty kilometres square. Your SAR-1 pass at 15:35 is the only one that covers it before it moves. One pass, tasking and download. Can you do it?" +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + objectives: { + 'budget-the-low-pass': { + text: ` +

+ Anneke here. SAR-1 is yours for the pass, imaging block is free and the command link is keyed. It is a low one though, you will need more uplink power than usual to reach her at that range. Your EIRP, your call. Just keep the HPA linear; I would rather lose the collect than have you splatter the band. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'task-the-collect': { + text: ` +

+ Task received on board, imaging block armed. She will shoot the box at closest approach and start the dump straight after. The downlink is all yours now, hold it through max elevation. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'pull-the-imagery': { + text: ` +

+ Frames in. The box is covered and the target is in it. Thank you. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'safe-the-uplink': { + text: ` +

+ Chain down on my side too, thank you. For the record: the IMD readout never moved off the green. That is the number I will quote when somebody asks whether Galway can be trusted with a low pass. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'report-to-customer': { + text: ` +

+ Understood on the geometry. If the next box is further north I will ask for Shetland or wait for the better orbit. Good work today, Galway. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario11.ts b/src/campaigns/nats-eu/scenario11.ts new file mode 100644 index 00000000..7e58fbf7 --- /dev/null +++ b/src/campaigns/nats-eu/scenario11.ts @@ -0,0 +1,890 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, createMeridianSar3, MERIDIAN_SAR3_BEACON_RF_HZ, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 11 - "LEOP: Launch Day" / SAR-3 First Acquisition (arc 1/2) + * + * M4 in its LEOP form. MERIDIAN-SAR-3 separated 84 minutes before the shift + * starts and GW-01 is the first commercial station on its ground track. The + * bird boots on the launch provider's coarse injection estimate + * (`spaceEvents[].initialTle`, re-applied on every load so replays keep the + * puzzle); the refined set from the first ranging arc arrives as `newTle` 45 s + * after the brief closes (`maneuverAtS` runs on the mission clock, which + * `freezesScenarioTimer` pauses during the brief). Phase 16 rewrite: the + * readiness review is a station sweep (board, reference, ACU beacon plan, + * analyzer framed for a beacon search, uplink confirmed cold), the elements + * are loaded and the refined pass read back, then the acquisition is done as + * analyzer work - wide span to find the tone, narrow RBW to read it, the + * Doppler sign as proof it is the right bird - and the state of health goes + * into the LEOP log one item at a time. No commanding on this pass: the flight + * rules say beacon then SOH, nothing else, and there is no `commanding` block + * to tempt anyone. + * + * Clock starts 2027-03-22 08:50:00 UTC (moved 10 min earlier in phase 16 so + * the TRR fits). All three birds are scenario-local (satellites.ts + * factories); SAR-3 is the first use of `createMeridianSar3`. + * + * Pass timeline (scripts/author-passes.mjs, verify-only, epoch 08:50:00Z): + * + * MERIDIAN-SAR-3 refined (newTle): + * AOS T+18.97 (09:08:58Z, az 143), max el 31.6 deg at T+23.74 (09:13:44Z, + * 696 km slant range), LOS T+28.55 (09:18:33Z, az 353), 9.6 min, + * northbound. Second pass 10:40:49Z (17.8 deg). + * MERIDIAN-SAR-3 coarse injection set (initialTle): + * AOS T+18.50 (09:08:30Z, az 142), max el 30.3 deg at T+23.27 (719 km), + * LOS T+28.04 (az 354). The injection estimate predicts the bird 28 s + * EARLY; the refined set moves AOS ~30 s later and the ground track + * ~0.3 deg of RAAN east. + * MERIDIAN-SAR-1: AOS 09:47:59Z, max el 25.1, LOS 09:57:27Z (present, not worked) + * MERIDIAN-SAR-2: AOS 10:06:01Z, max el 25.1, LOS 10:15:22Z (present, not worked) + * + * Ephemeris design: `initialTle` is `newTle` with RAAN +0.3 deg and mean + * anomaly +2.0 deg; line 2 checksum recomputed (...123457). Since phase 16 + * the station's prediction and the spacecraft's orbit are separate + * (`OrbitalSatellite.predictionSatellite`): at `maneuverAtS` the bird moves to + * the refined orbit while program-track and the Pass Schedule keep the + * injection set until Load Updated Ephemeris. Two degrees of mean anomaly is + * 250 km along-track, so a pass flown on the injection set would not see the + * beacon at all. The checklist orders the load before AOS; the physics now + * agrees with it. + * + * Beacon frequency: GW-01 boots with the ACU beacon field on SAR-1's 11711 MHz. + * The TRR asks for SAR-3's 11785 MHz. Typing it on the ACU tab or picking + * SAR-3 as the program-track target (which copies the target's beacon + * frequency into the field) both satisfy it; either is the operator + * configuring the station for a bird it has never seen. + * + * SAR-3 RF plan (distinct from SAR-1/2 so the operator has to read the LEOP + * card): video 11760 MHz (IF 1340, QPSK 3/4, 36 MHz, 28 dBm), telemetry beacon + * 11785 MHz CW (IF 1315), TT&C uplink 14065 MHz (IF 1465), transponded + * downlink 11810 MHz. Same 360 km / mm 15.6 orbit, so the S1 RF envelope holds. + * The RX modem stays on SAR-1's 1414 MHz: the payload is not powered until + * commissioning (S12), and no decode is called for. + * + * Staged state (scenario-local clone): BUC muted. LEOP flight rule: the + * uplink is cold and stays cold. + * + * NICE Framework Alignment: + * Primary Codes: + * - T0513: Supervise or manage protective or corrective measures when an incident is identified + * - K1032: Knowledge of satellite-based communication systems + * - T1138: Perform system reconfiguration + * + * Supporting Codes: + * - K0645: Knowledge of mission-relevant capabilities + * - S0630: Skill in performing test readiness reviews + * - S0421: Skill in operating network equipment + * - T0431: Check system hardware availability, functionality, integrity + * - K0740: Knowledge of system performance indicators + * - K0773: Knowledge of telecommunications principles and practices + * - T1611: Report system status + * - S0842: Skill in recording operational events + */ + +/** MERIDIAN-SAR-3 (61703) refined element set: the first ranging arc (newTle). */ +const SAR3_S11_REFINED_TLE: MeridianTle = { + tle1: '1 61703U 27031A 27081.37500000 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 61703 97.6000 328.0000 0010000 90.0000 272.0000 15.60000000123452' as TleLine2, +}; + +/** + * MERIDIAN-SAR-3 (61703) coarse injection estimate (initialTle): the refined + * set with RAAN +0.3 deg and mean anomaly +2.0 deg, checksum recomputed. + */ +const SAR3_S11_INJECTION_TLE: MeridianTle = { + tle1: '1 61703U 27031A 27081.37500000 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 61703 97.6000 328.3000 0010000 90.0000 274.0000 15.60000000123457' as TleLine2, +}; + +/** MERIDIAN-SAR-1 (61701) element set at the S11 epoch (next pass 09:48). */ +const SAR1_S11_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27081.37500000 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 61701 97.2000 139.7500 0010000 90.0000 191.7500 15.60000000123450' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) element set at the S11 epoch (next pass 10:06). */ +const SAR2_S11_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27081.37500000 .00001000 00000-0 10000-3 0 9999' as TleLine1, + tle2: '2 61702 98.4000 120.7500 0010000 90.0000 118.0000 15.60000000123451' as TleLine2, +}; + +const meridianSar1S11 = createMeridianSar1(SAR1_S11_TLE); +const meridianSar2S11 = createMeridianSar2(SAR2_S11_TLE); +const meridianSar3S11 = createMeridianSar3(SAR3_S11_REFINED_TLE); + +/** GW-01 under LEOP flight rules: BUC muted, uplink cold. Deep clone, never spread. */ +const galwayLeop: GroundStationConfig = structuredClone(galwayGroundStation); +galwayLeop.rfFrontEnds[0].buc = { ...galwayLeop.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario11Data: ScenarioData = { + id: 'nats-eu-scenario11', + url: 'nats-eu/scenarios/nats-eu-scenario11', + imageUrl: 'nats/11/card.png', + number: 11, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario10'], + title: 'LEOP: Launch Day', + subtitle: 'SAR-3 First Acquisition', + duration: '30 min', + missionType: 'Launch and Early Orbit', + description: `08:50 local. MERIDIAN-SAR-3 separated from the upper stage 84 minutes ago and nobody on the ground has heard from her yet.

Rotterdam's only element set is the launch provider's injection estimate, quoted with a 20 s along-track uncertainty. A refined set from the first ranging arc is due on your console any minute. GW-01 is the first commercial station on her ground track, AOS in about nineteen minutes.

Anneke is LEOP lead. She wants a test readiness review, the refined elements loaded before AOS, the beacon found and read on the analyzer, and a state-of-health call built one line at a time. Nothing goes up on this pass.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Pass Schedule Console (ephemeris status)', + 'Ku-Band RF Front End (LNB LO 13100 MHz)', + 'Spectrum Analyzer (1315 MHz beacon IF)', + ], + settings: { + isSync: true, + groundStations: [galwayLeop], + satellites: [meridianSar1S11, meridianSar2S11, meridianSar3S11], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-22', + scenarioStartWallTime: '08:50:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-11?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // A fast operator can skip the dead air between "ready" and AOS. The skip + // is blocked while a timed objective runs and stops 2 min before the pass. + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'SAR-3 LEOP Log', + description: 'First-acquisition record for Rotterdam. Sections: Readiness, State of Health, Close-out.', + }, + + // M4 - the injection estimate and the refined set. maneuverAtS is mission + // clock seconds, so the refined set reaches the console 45 s after the + // brief closes; the ephemeris panel shows STALE and the operator loads it. + spaceEvents: [ + { + id: 'SAR3-INJ', + satelliteNoradId: 61703, + maneuverAtS: 45, + label: 'SAR-3 refined elements (first ranging arc)', + initialTle: SAR3_S11_INJECTION_TLE, + newTle: SAR3_S11_REFINED_TLE, + }, + ], + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K1032'], + title: 'Read the LEOP Card', + description: "Open the LEOP card. It has the SAR-3 frequency plan, the injection set's stated uncertainty, and the flight rules for this pass.", + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'LEOP Card Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Acquisition Risk Understood', + params: { + character: Character.SYSTEM, + question: 'The injection set has a stated 1-sigma along-track error of 20 s. Your beam is 0.45 deg wide. What does that mean for acquisition?', + options: [ + 'At AOS the bird can be a few degrees from prediction. Refined elements before the pass, or a beacon search wider than the beam, are needed.', + 'At AOS the bird is inside the pass window. 20 s is well inside the pass duration, so program-track will find her somewhere in it.', + 'The beacon frequency is uncertain by the same amount. A wider receiver bandwidth on the modem, or a wider analyzer span, is needed.', + 'Step-track will pull the pedestal onto her. Once she is within a beamwidth the ACU takes over, so no change to the elements is needed.', + ], + correctIndex: 0, + explanation: + 'A LEO bird moves about 7.5 km every second; 20 s along-track at 700 km range is several beamwidths of pointing error. Either the elements get better before AOS, or the search does. LEOP clock started.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: THE READINESS REVIEW AS A SWEEP + // ============================================================ + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'A new spacecraft starts with the board read. Confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 as the LEOP card opens?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for LEOP', + 'GPSDO in holdover - reference alarm, timing at risk for the pass', + 'Antenna drive fault - hardware alarm, pedestal down for the pass', + ], + correctIndex: 0, + explanation: + 'The AGC rail is empty sky, not a fault. On a new spacecraft every anomaly will be blamed on the bird first; the board is how you prove it was not the station.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740', 'S0630'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover. Rotterdam wants the reference state on the readiness line, and the pass will show you why.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Reference as Evidence', + params: { + character: Character.SYSTEM, + question: 'Why does the LEOP card want the GW-01 reference status recorded before the pass, on a receive-only contact?', + options: [ + 'The Doppler curve is SOH evidence: a reference error at the station looks exactly like a frequency error on the bird', + 'The uplink needs it: if the flight rules change mid-pass, a command goes up on the reference the card has recorded', + 'It is a formality: the reference does not affect a receive-only contact, but the card has a line for it either way', + 'The pass planner needs it: the AOS time-tag comes from the GPSDO, so the log has to show which clock it came from', + ], + correctIndex: 0, + explanation: + 'The reference has to be on record before the measurement counts. A beacon 20 kHz off frequency on a new bird is either her transmitter or your LNB LO. With the GPSDO disciplined and logged, it is hers, and Rotterdam can act on it. Without the log line, nobody can say.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'test-readiness', + nice: ['S0630', 'T0513'], + title: 'Test Readiness Review', + description: + "Configure the station for a bird it has never seen: set the ACU beacon frequency to SAR-3's telemetry beacon (11785 MHz), then close the TRR checklist into the log.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-beacon-frequency-set', + description: 'ACU Beacon Frequency 11785 MHz', + params: { beaconFrequency: MERIDIAN_SAR3_BEACON_RF_HZ }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'TRR Checklist Closed', + params: { + character: Character.SYSTEM, + question: 'TRR for a first acquisition. Which line set below is the one that goes in the LEOP log?', + options: [ + 'Beacon 11785 MHz on the ACU, reference disciplined, injection set loaded and flagged as provisional, uplink cold.', + 'Beacon 11711 MHz on the ACU, video IF 1414 MHz on the receiver, injection set loaded and flagged as provisional, uplink cold.', + 'Beacon 11785 MHz on the ACU, reference disciplined, TT&C uplink 14065 MHz keyed and ready to command, injection set loaded.', + 'Beacon 11785 MHz on the ACU, reference in holdover, refined set loaded and flagged as current, uplink cold.', + ], + correctIndex: 0, + explanation: + 'SAR-3 has her own frequency plan; 11711 and 1414 are SAR-1. The uplink stays cold: no command goes up before SOH is confirmed. The refined set is not here yet, and the log says so.', + pointPenalty: 5, + documentSection: 'Readiness', + documentLine: 'TRR complete 08:5x: ACU beacon 11785 MHz (SAR-3), GPSDO locked / no holdover, injection set loaded and flagged provisional, uplink cold.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'analyzer-on-the-beacon', + nice: ['S0421', 'K0773'], + title: 'Analyzer on the Beacon', + description: + 'Frame the search. Centre the analyzer on the predicted beacon IF, 1315 MHz, with a 2 MHz span: wide enough to hold +/- 270 kHz of Doppler and a transmitter that has never been measured.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['test-readiness'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1315 MHz', + params: { centerFrequency: 1315e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Search Span Read', + params: { + character: Character.SYSTEM, + question: 'The analyzer boots at 60 MHz span, 1 MHz RBW, centred on 1414. Why not search for the beacon there?', + options: [ + 'At 60 MHz span a CW tone is one pixel over a 1 MHz noise bin, and 1315 is not even on screen; 2 MHz across 1315 shows the whole Doppler walk', + 'The analyzer cannot display anything below 1400 MHz at that span, and 1315 is off the low edge; 2 MHz across 1315 is the only way to reach it', + 'It would work at 60 MHz, and 1315 is inside the screen with 1 MHz RBW; 2 MHz across 1315 is a Rotterdam preference for the log', + 'The video carrier at 1340 would hide the beacon at that span, and 1315 sits under its skirt; 2 MHz across 1315 keeps 1340 off the screen', + ], + correctIndex: 0, + explanation: + 'Span decides what you can see, RBW decides how well. At 2 MHz the tone stands clear of the floor. A first acquisition is a search: wide enough for the uncertainty, narrow enough that a 0 dBm CW tone is unmistakable. 2 MHz is both.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'uplink-cold-check', + nice: ['T0431', 'K0645', 'S0630'], + title: 'Uplink Cold Check', + description: + 'Flight rule: nothing goes up. Confirm the transmit chain is cold - HPA output disabled, BUC muted, modem off air - and that it will stay that way for the whole pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['analyzer-on-the-beacon'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Flight Rule Read', + params: { + character: Character.SYSTEM, + question: 'The link comes up clean and the uplink chain is right there. Which state satisfies "cold" for the flight rule?', + options: [ + 'Modem off air, BUC muted, HPA output disabled; all powered and on the reference, so the chain comes up in order on Wednesday', + 'BUC and HPA powered down, modem off air; nothing on the reference, so the chain gets a full warm-up and re-lock on Wednesday', + 'HPA enabled but the modem off, BUC muted; no carrier can go up, so the chain is ready the moment the Wednesday command arrives', + 'Doppler compensation off on the TT&C console, modem off air; no uplink can be tuned, so nothing can go up before Wednesday', + ], + correctIndex: 0, + explanation: 'Cold is a defined state, not an absence. Every element idle, every element ready, and nothing that could radiate if somebody leaned on a switch.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'load-refined-elements', + nice: ['T1138', 'K1032'], + title: 'Load the Refined Elements', + description: "Rotterdam's ranging solution replaces the injection estimate. When the ephemeris panel flags SAR-3 as STALE, load the refined set before AOS.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['uplink-cold-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: false, + }, + { + type: 'ephemeris-updated', + description: 'Refined Elements Loaded', + params: { eventId: 'SAR3-INJ' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Origin Over Age', + params: { + character: Character.SYSTEM, + question: 'The injection set is 84 minutes old and about 30 s wrong along-track. The refined set is twenty minutes old. Which matters more, the age or the origin?', + options: [ + 'Origin: one is a launch-provider estimate from separation, the other is a solution fitted to ranging; age grows the error of either', + 'Age: the younger set is always the better set, whatever produced it; a solution fitted an hour ago has had an hour of drag on it', + 'Neither: on a 0.45 degree beam the two sets are equivalent; 30 s along-track at 700 km is still inside the search the ACU runs', + 'Age: an element set older than an hour cannot be used for program-track; the injection set is out of date whatever its origin', + ], + correctIndex: 0, + explanation: + 'A TLE is a fit to observations. The injection set is a fit to none; it is what the rocket said it did. Along-track error grows with age for both, which is why the refined set gets replaced again after this pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'read-the-refined-pass', + nice: ['K1032', 'S0421'], + title: 'Read the Refined Pass', + description: 'With the refined set loaded, read the pass off the schedule: AOS time and azimuth, peak, LOS. Those are the numbers the SOH is measured against.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['load-refined-elements'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Refined Pass Read', + params: { + character: Character.SYSTEM, + question: 'On the refined set, when and where does SAR-3 rise, and how high does she go?', + options: [ + 'AOS 09:08:59 at azimuth 143, 31.6 degrees at 09:13:44 and 696 km, LOS 09:18:33 toward 353', + 'AOS 09:08:30 at azimuth 142, 30.3 degrees at 09:13:16 and 719 km, LOS 09:18:02 toward 354', + 'AOS 09:08:59 at azimuth 143, 25.1 degrees at 09:13:44 and 696 km, LOS 09:18:33 toward 353', + 'AOS 09:08:59 at azimuth 353, 31.6 degrees at 09:13:44 and 696 km, LOS 09:18:33 toward 143', + ], + correctIndex: 0, + explanation: + 'Thirty seconds later and a degree higher than the injection estimate said. Northbound, rising in the south-east. 25.1 degrees is the 09:48 pass, and that is SAR-1, who is not on the card.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: FIRST ACQUISITION AS ANALYZER WORK + // ============================================================ + { + id: 'first-acquisition', + nice: ['T0513', 'S0421', 'K1032'], + title: 'First Acquisition', + description: 'AOS 09:08:59 from azimuth 143. Program-track MERIDIAN-SAR-3 on the refined set and find the beacon on the analyzer at 1315 MHz IF.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-refined-pass'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-3', + params: { noradId: 61703 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-3 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-3-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'narrow-the-analyzer', + nice: ['S0421', 'K0773', 'K1032'], + title: 'Narrow the Analyzer', + description: 'Tone found. Drop the resolution bandwidth to 10 kHz so the CW line is a line, and read which way it is walking.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['first-acquisition'], + conditions: [ + { + type: 'speca-rbw-set', + description: 'RBW 10 kHz', + params: { rbw: 10e3, frequencyTolerance: 1e3 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Doppler Sign Read', + params: { + character: Character.SYSTEM, + question: 'The tone sits below 1315 MHz at IF and is sliding upward toward it. Is that SAR-3 approaching, and how do you know?', + options: [ + 'Yes: the high-side LNB LO inverts the spectrum, so an approaching bird sits BELOW nominal at IF, and the upward slide is the range rate falling', + 'No: an approaching bird is always above nominal, whatever the LO, so a tone below 1315 sliding upward is something receding', + 'Cannot tell: the analyzer shows a tone and a direction, but only the ACU knows the range rate, so the sign has to come from there', + 'Yes: a tone below nominal means a transmitter running slow, which is normal for a cold spacecraft, and the slide is her warming up', + ], + correctIndex: 0, + explanation: + 'IF = LO - RF. Approach raises the RF and lowers the IF; as she climbs the shift shrinks and the tone rises through 1315 at culmination, then keeps rising as she recedes. A tone on the wrong side sliding the wrong way is not your bird.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'acu-beacon-lock', + nice: ['T0513', 'S0421', 'S0842'], + title: 'ACU Beacon Lock', + description: 'Confirm the ACU has beacon lock on 11785 MHz and record it. The pedestal seeing her in the beam centre is the first line of the state of health.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['narrow-the-analyzer'], + conditions: [ + { + type: 'antenna-beacon-locked', + description: 'ACU Beacon Lock', + params: { requiresObservation: true, observationTab: 'acu-control' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Lock Recorded', + params: { + character: Character.SYSTEM, + question: 'The ACU reports beacon LOCK under program-track. What does that add to the SOH that the tone on the analyzer did not?', + options: [ + "The pedestal's own receiver sees the beacon at the centre of the beam: pointing, elements and transmitter all agree at once", + 'Nothing new: lock is a display of the same signal the analyzer shows, so pointing and elements are no better proven than before', + 'That the beacon is modulated with telemetry: the ACU tracks the subcarrier, so the transmitter is proven to be doing more than CW', + 'That the injection set was good enough after all: the pedestal found her on program-track, so the refined set was not needed', + ], + correctIndex: 0, + explanation: + 'A tone on the analyzer alone could be a sidelobe. Three independent things agreeing is what "acquired" means. Rotterdam logs the lock time; it is the first timestamp in the spacecraft\'s commercial life.', + pointPenalty: 5, + documentSection: 'State of Health', + documentLine: 'SOH 1: ACU beacon lock on 11785 MHz under program-track on the refined set; pointing, elements and transmitter agree.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'beacon-level', + nice: ['T1611', 'S0842', 'K0740'], + title: 'Beacon Level', + description: + 'Read the beacon level on RX analysis near culmination (09:13:44, 696 km) and record it against the prediction. A CW beacon should clear -65 dBm at the analyzer input up here.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acu-beacon-lock'], + conditions: [ + { + type: 'signal-detected', + description: 'SAR-3 Beacon >= -65 dBm at RX IF', + params: { + signalId: 'MERIDIAN-SAR-3-Beacon', + minPower: -65 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Level Recorded', + params: { + character: Character.SYSTEM, + question: 'Rotterdam wants the beacon level as a number, not "present". Why?', + options: [ + "A level against the prediction is the transmitter's health: 10 dB low with the right Doppler is a sick transmitter on a good orbit", + 'A level against the contract is the billing basis: the customer pays for a pass that met the committed margin, not one merely present', + 'A level against the noise floor sets the ACU search bandwidth: the next pass is searched only as wide as the tone needs to stand clear', + 'A level with a marker is what the analyzer can record: without a number the trace cannot be saved, and a saved trace is what the log needs', + ], + correctIndex: 0, + explanation: + "Today's number is the baseline every later pass is compared with. The first measurement of anything on a new spacecraft is the one every trend starts from. Take it near culmination, where the geometry is best known, and write the elevation next to it.", + pointPenalty: 5, + documentSection: 'State of Health', + documentLine: 'SOH 2: beacon level at culmination (31.6 deg, 696 km) above -65 dBm at RX IF, within prediction; CW, on frequency after Doppler.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'state-of-health', + nice: ['T1611', 'S0842'], + title: 'Initial State of Health', + description: 'Close the SOH: beacon present, tracking, Doppler profile matching the refined set. Make the call for Rotterdam.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['beacon-level'], + conditions: [ + { + type: 'status-check', + description: 'SOH Recorded', + params: { + character: Character.SYSTEM, + question: 'Beacon present and on level, antenna tracking, Doppler profile matches the refined set. What is the SOH call?', + options: [ + 'Spacecraft transmitting on the expected frequency and orbit; no anomaly; recommend proceeding to command checkout next pass.', + 'Spacecraft transmitting on the expected frequency and orbit; healthy; recommend commanding the payload on now while the link is up.', + 'Spacecraft transmitting but the video not decoded; inconclusive; recommend holding commissioning until the imagery is seen.', + 'Spacecraft transmitting on the expected frequency and orbit; healthy; recommend Shetland repeat the acquisition before commissioning.', + ], + correctIndex: 0, + explanation: + 'A CW beacon on frequency with the right Doppler curve tells you the transmitter, the reference, and the orbit are all as expected. If the elements had been loaded after AOS the call would carry a qualifier, because the first minute of track would have been on a set you knew was stale.', + pointPenalty: 5, + documentSection: 'State of Health', + documentLine: 'SOH 3: beacon acquired, orbit matches refined set, Doppler profile nominal, no anomaly. Recommend command checkout next pass.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4: CLOSE OUT + // ============================================================ + { + id: 'secure-after-los', + nice: ['T0431', 'K0645', 'S0842'], + title: 'Secure After LOS', + description: 'LOS 09:18:33. Confirm the uplink stayed cold for the whole pass and close the LEOP log for this contact.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['state-of-health'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Still Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Still Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Contact Closed', + params: { + character: Character.SYSTEM, + question: "SAR-3's next Galway pass is 10:40. What happens to the element sets between now and then?", + options: [ + 'The injection set is superseded and stays that way; Rotterdam fits a new solution to this pass and pushes it before 10:40', + 'Nothing changes; the refined set is good for the rest of the day, and the panel stays CURRENT until Rotterdam says otherwise', + 'The injection set is reloaded; the 10:40 pass is flown on it so the two sets can be compared, and the panel reads STALE until then', + 'The station propagates its own set; the beacon Doppler from this pass is fitted on the console, and the panel reads CURRENT on it', + ], + correctIndex: 0, + explanation: + 'The panel will read STALE again when the new set arrives. Every pass is more ranging. Along-track error grows with age on every set, so a bird a day old gets a set an hour old. Load what Rotterdam sends, every time, before AOS.', + pointPenalty: 5, + documentSection: 'Close-out', + documentLine: + '09:19Z contact closed: uplink cold throughout (HPA disabled, BUC muted, modem off air). Refined set loaded before AOS. Next Galway pass 10:40:49Z; await post-pass elements.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'leop-handover', + nice: ['T0513', 'K0645'], + title: 'LEOP Handover', + description: 'Tell Anneke what she has.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['secure-after-los'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Handover Given', + params: { + character: Character.SYSTEM, + question: 'You had the bird, the link was clean and the uplink chain was right there. Why did nothing go up?', + options: [ + 'The flight rules sequence LEOP: no command until SOH is confirmed and tracking is shown. A rejected command on a new bird is unexplainable.', + 'The TT&C key had not been loaded: no command can be authenticated without it. On a pass with the key in place it would have been fine.', + 'The pass was too short: the chain takes minutes to bring up in order. On a longer pass with time to warm up it would have been fine.', + 'The BUC was muted: there was no time to bring the chain up in order. On a pass with the chain already warm it would have been fine.', + ], + correctIndex: 0, + explanation: + 'On a new spacecraft every first is evidence. If a command fails before tracking and SOH are on record, nobody can say whether the bird, the link, or the station was the cause. Commissioning is next pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 08:47] +

+

+ "Separation confirmed at 07:26. SAR-3 is on her own and Galway is the first commercial site on her track, AOS about 09:09. All I have is the launch provider's injection set; the first ranging arc is being solved now and I will push the refined elements to your console as soon as it closes. Readiness review, elements, beacon, then state of health. Nothing else." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'test-readiness': { + text: ` +

+ TRR noted, thank you. The refined set from the first ranging arc is on its way to your console now; load it the moment the panel flags it. The injection numbers had her thirty seconds out, which on a 0.45 degree beam is not a small thing. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'uplink-cold-check': { + text: ` +

+ [Text message from Anneke Visser at 09:02] +

+

+ "Cold chain logged on my side as well. I know it feels like a formality. On Wednesday, when we send the first command, the fact that nothing went up today is the reason a rejected command will mean something." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'first-acquisition': { + text: ` +

+ That's her. First contact from a commercial site, on frequency, on the refined orbit. Read her for me - level, Doppler, lock - one line at a time; I have the whole room waiting on it. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'acu-beacon-lock': { + text: ` +

+ Lock time logged. The room just noticed. Carry on. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'leop-handover': { + text: ` +

+ Logged. Beacon acquired, level and Doppler on record, orbit confirmed, no anomaly, and nobody sent anything they could not explain afterwards. That is exactly what launch day is supposed to look like. Commissioning is Wednesday; I will send the acceptance test plan tonight. Well done, Galway. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario12.ts b/src/campaigns/nats-eu/scenario12.ts new file mode 100644 index 00000000..1dc31893 --- /dev/null +++ b/src/campaigns/nats-eu/scenario12.ts @@ -0,0 +1,1060 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, Hertz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, createMeridianSar3, MERIDIAN_SAR3_BEACON_RF_HZ, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 12 - "LEOP: Commissioning" / SAR-3 Acceptance Tests (arc 2/2) + * + * Second half of the SAR-3 LEOP arc. Two days after first acquisition (S11) + * the bus is healthy and Rotterdam has released the payload acceptance test + * plan. Phase 16 rewrite: the acceptance test card IS the objective list. + * Readiness (board, reference, prediction, crypto both ways, uplink tuned, + * analyzer framed, tracker parked), the acquisition, the chain brought up in + * invariant order inside the window, PLD-ON then PLD-TEST-PATTERN, the + * uplink secured before the decode, the first imagery decoded and committed + * against the prediction, the chain down, the card signed, the customer told + * what acceptance transfers. No new mechanics; M1 (link budget), M2/M5 + * (commanding with Doppler comp and a valid key) and the Working Document + * in the order the card says. + * + * Scenario clock starts 2027-03-24 09:48:00 UTC (moved 12 min earlier in + * phase 16 so the readiness beats fit); the birds are built here with the + * satellites.ts factories on element sets authored for the 10:00 epoch. + * + * Pass timeline (scripts/author-passes.mjs, verify-only, epoch 09:48:00Z): + * + * MERIDIAN-SAR-3 #1: AOS T+18.02 (10:06:00Z, az 140), max el 27.9 deg at T+22.75 + * (10:10:45Z, 764 km slant range), LOS T+27.48 (10:15:29Z, + * az 355), 9.5 min, northbound. The worked pass; + * commanding window 10:06:22 .. 10:15:08. + * MERIDIAN-SAR-1 #1: AOS 10:50:01Z, max el 25.2 deg, LOS 10:59:30Z (present, not worked) + * MERIDIAN-SAR-2 #1: AOS 11:10:01Z, max el 24.7 deg, LOS 11:19:28Z (present, not worked) + * + * SAR-3 frequency plan (satellites.ts): beacon 11785 MHz -> IF 1315, video + * 11760 MHz -> IF 1340 (LNB LO 13100 high-side), TT&C uplink 14065 MHz -> IF + * 1465 (BUC LO 12600 low-side). GW-01 starts this shift with the ACU beacon + * field still on SAR-3 from Monday, but the RX modem on SAR-1's 1414 MHz video + * IF and the TX modem on SAR-1's 1405 MHz command IF from the morning's + * tasking passes, the analyzer at its 1414 / 60 MHz default, the tracker + * parked where the morning left it, and the BUC muted. Retuning both modems, + * framing the analyzer and parking on the AOS azimuth are the readiness work. + * + * Link-budget numbers (worksheet per S2, at SAR-3's frequency and this pass's + * geometry): + * - slant range at max elevation 764 km -> FSPL 171.5 dB at 11760 MHz + * - EIRP 28 dBm, GW-01 4m Ku gain 51.8 dBi, Tsys 88 K, BW 36 MHz, misc 1 dB + * - correct worksheet -> C/N 10.89 dB (expectedCNRDb 10.9, tolerance 1.0) + * - threshold 6 dB (QPSK 3/4 demod) + 2 dB required margin -> commit near + * max elevation, roughly 10:09 .. 10:12:30 + * Decode with the uplink SECURED. With the HPA up, SAR-3's TP-CMD returns + * the station's own 14065 MHz carrier at 11810 MHz (co-pol, in the receive + * band, about -38 dBm at the antenna); the AGC drops the total RX gain under + * the internal-noise floor crossover and the video reads ~3 dB instead of ~11. + * The decode objective therefore follows an HPA-disabled objective (found by + * the S12 Playwright spec; S10 survives the same mechanism with ~2 dB + * headroom). Live peak under real program-track, measured by the Phase 2 + * harness (test/campaigns/nats-eu-phase-c-validation.test.ts): 10.94 dB at + * culmination, against the 10.9 dB worksheet. + * + * NICE Framework Alignment: + * Primary Codes: + * - T1092: Execute test and evaluation events against an approved test plan + * - T1611: Record and document test results + * - S0842: Skill in conducting system acceptance testing + * + * Supporting Codes: + * - T0080: Test and evaluate system performance against requirements + * - S0015: Skill in conducting test events + * - T1567: Configure system hardware, software, peripheral equipment + * - K0773: Knowledge of telecommunications principles and practices + * - T0153: Monitor system performance + * - T0431: Check system hardware availability, functionality, integrity + * - K0874: Knowledge of cryptographic key management + * - T1506: Prepare and deliver technical reports to stakeholders + * - S0478: Skill in providing customer support + * - S0421: Skill in operating communications equipment + * - S0675: Skill in configuring RF transmit equipment within safe limits + * - K1032: Knowledge of satellite-based communication systems + * - K0645: Knowledge of standard operating procedures + */ + +/** MERIDIAN-SAR-3 (61703) element set at the S12 epoch: the worked pass. */ +const SAR3_S12_TLE: MeridianTle = { + tle1: '1 61703U 27031A 27083.41666667 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61703 97.6000 345.5000 0010000 90.0000 284.0000 15.60000000123459' as TleLine2, +}; + +/** MERIDIAN-SAR-1 (61701) element set at the S12 epoch. Present, not worked. */ +const SAR1_S12_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27083.41666667 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61701 97.2000 157.2500 0010000 90.0000 183.7500 15.60000000123456' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) element set at the S12 epoch. Present, not worked. */ +const SAR2_S12_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27083.41666667 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 61702 98.4000 341.7500 0010000 90.0000 31.5000 15.60000000123455' as TleLine2, +}; + +const meridianSar1S12 = createMeridianSar1(SAR1_S12_TLE); +const meridianSar2S12 = createMeridianSar2(SAR2_S12_TLE); +const meridianSar3S12 = createMeridianSar3(SAR3_S12_TLE); + +/** + * GW-01 as the commissioning shift finds it. Deep clone, never a spread: the + * nested antennasState / rfFrontEnds / transmitters / receivers objects are + * handed to the equipment constructors and mutated at runtime. + * + * Carried over from Monday: the ACU beacon field on 11785 MHz. Everything + * else is where the morning's SAR-1 tasking left it (modems on 1414 / 1405, + * analyzer on 1414 / 60 MHz, tracker parked az 5), and the BUC is muted. + */ +const galwayCommissioning: GroundStationConfig = structuredClone(galwayGroundStation); +galwayCommissioning.antennasState![0] = { + ...galwayCommissioning.antennasState![0], + beaconFrequencyHz: MERIDIAN_SAR3_BEACON_RF_HZ as number as Hertz, +}; +galwayCommissioning.rfFrontEnds[0].buc = { ...galwayCommissioning.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario12Data: ScenarioData = { + id: 'nats-eu-scenario12', + url: 'nats-eu/scenarios/nats-eu-scenario12', + imageUrl: 'nats/12/card.png', + number: 12, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario11'], + title: 'LEOP: Commissioning', + subtitle: 'SAR-3 Acceptance Tests (arc 2/2)', + duration: '35 min', + missionType: 'Commissioning', + description: `09:48 local. Two days after first acquisition, MERIDIAN-SAR-3's bus is healthy and Anneke Visser has released the payload acceptance test plan: readiness, power the payload, command the test pattern, decode the first imagery, record the results on the card.

Erik Halvorsen at Nordic Maritime Watch is copied on the card, because Nordic Maritime Watch takes delivery of SAR-3 capacity the moment it is signed.

One pass, AOS 10:06, nine and a half minutes. Eighteen minutes to make the station ready for it and predict the number. Then do the card in order: chain up, power, pattern, secure, decode, chain down, sign.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO, 12600 MHz BUC LO) + HPA', + 'TT&C Commanding Console / Link Analysis Console', + 'QPSK 3/4 RX Modem with Video Decoder', + 'SAR-3 Payload Acceptance Test Card (Working Document)', + ], + settings: { + isSync: true, + groundStations: [galwayCommissioning], + satellites: [meridianSar1S12, meridianSar2S12, meridianSar3S12], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-24', + scenarioStartWallTime: '09:48:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-12?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // A fast operator can skip the dead air between "ready" and AOS. The skip + // is blocked while a timed objective runs and stops 2 min before the pass. + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + // M1 - the acceptance prediction. expectedCNRDb is what a CORRECT worksheet + // yields from the numbers published in the prediction objective; + // requiredMarginDb is measured against the live receiver at Commit Link. + // Measured under real program-track (nats-eu-phase-c-validation): peak + // 10.94 dB at culmination, 240 s at or above 7 dB, 146 s at or above 9 dB. + linkBudget: { + label: 'SAR-3 acceptance: video downlink at max elevation', + expectedCNRDb: 10.9, + toleranceDb: 1.0, + thresholdCNRDb: 6, + requiredMarginDb: 2, + }, + + // M2/M5 - payload command checkout. Window is the SAR-3 pass, AOS T+18.02 + // (1081 s) + 20 s .. LOS T+27.48 (1649 s) - 20 s. Doppler comp and a valid + // key are both required for an ACK. PLD-ON and PLD-TEST-PATTERN are the + // card; REC-PLAYBACK and PLD-SAFE are on the console because a real TT&C + // console does not hide the commands the card does not call for. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61703, + windowStartS: 1102, + windowEndS: 1628, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'PLD-ON', label: 'Payload power on' }, + { id: 'PLD-TEST-PATTERN', label: 'Transmit payload test pattern' }, + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + + // The test card. Each documentLine status-check appends one line. + workingDocument: { + title: 'SAR-3 Payload Acceptance Test Card', + description: 'MERIDIAN-SAR-3 (61703) payload acceptance, GW-01, 2027-03-24. Sections: Readiness, Command, Payload, Verdict.', + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'T0080'], + title: 'Read the Test Plan', + description: 'Open the shift brief and read the acceptance test plan: readiness, chain up, power, pattern, secure, decode, record. The order is the test.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Test Plan Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Test Order Understood', + params: { + character: Character.SYSTEM, + question: 'The test plan runs power, pattern, decode, record, in that order. Why does the order matter when the console will accept the commands in any order?', + options: [ + 'Each step is the evidence for the next: the pattern proves the payload took power, the decode proves the pattern is on the air.', + 'It is the order the commands are listed on the TT&C console: the card copies the console so the two records match.', + 'It does not matter as long as every step is finished before LOS: the card records results, not the sequence.', + 'The payload rejects PLD-TEST-PATTERN unless PLD-ON was the previous command: the order is enforced on the bird.', + ], + correctIndex: 0, + explanation: + 'A command ACK proves the spacecraft received the command, not that the command did anything. The decode is what proves PLD-ON worked, the record captures what was measured, and the card is only as good as the order it was filled in. Out of order, an ACK or a line on the card proves nothing. Eighteen minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: READINESS + // ============================================================ + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Acceptance starts with the board read. Confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 as the test card opens?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the card', + 'HPA over-temperature - hardware alarm left by the morning passes', + 'Command key expired - crypto alarm, nothing goes up until reloaded', + ], + correctIndex: 0, + explanation: + 'The AGC rail is empty sky, not a fault, and it clears when the test carrier arrives. An acceptance number measured on a station with an open alarm is not an acceptance number; the card would carry the alarm as a qualifier.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover, BUC on the external reference. The uplink carrier and the Doppler compensation both key off it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'predict-acceptance', + nice: ['T0080', 'S0015'], + title: 'Predict the Acceptance C/N', + description: + 'Fill the Link Analysis worksheet for MERIDIAN-SAR-3 at maximum elevation and press Compute. Survey numbers: satellite EIRP 28 dBm; slant range at max elevation 764 km (free-space path loss 171.5 dB at 11760 MHz); GW-01 receive gain 51.8 dBi; system noise temperature 88 K; occupied bandwidth 36 MHz; miscellaneous losses 1 dB. The card will quote this number back at you.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'link-budget-computed', + description: 'Predicted C/N Matches Acceptance Truth', + params: {}, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'crypto-both-ways', + nice: ['K0874', 'T0431', 'S0842'], + title: 'Crypto Both Ways', + description: + 'The card wants both directions on record before anything is sent: TX encryption active with a Valid command key on the TX chain, RX decryption active with a Valid traffic key on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['predict-acceptance'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-crypto-status', + description: 'TX Encryption Active', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'tx-key-status', + description: 'TX Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-crypto-status', + description: 'RX Decryption Active', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Keys Recorded', + params: { + character: Character.SYSTEM, + question: 'Command key Valid, traffic key Valid. Which half of the test needs which key?', + options: [ + 'Command key for PLD-ON and the pattern going up; traffic key for the pattern coming down - both must be Valid', + 'Command key only, for both halves; the test pattern comes down unencrypted - a new payload has no traffic key yet', + 'Traffic key only, for both halves; the TT&C console carries its own key - it is always Valid on the console', + 'Neither key, for either half; acceptance is done in crypto bypass - the numbers have to be clean', + ], + correctIndex: 0, + explanation: + 'Two keys, two directions, and both have to be Valid before either half of the test means anything. A pattern that will not decrypt looks exactly like a payload that did not power on, and the card cannot tell them apart afterwards. Record both Valid before AOS so the decode can only mean one thing.', + pointPenalty: 5, + documentSection: 'Readiness', + documentLine: + 'Readiness: GPSDO locked, BUC on reference. TX encryption ACTIVE / command key Valid; RX decryption ACTIVE / traffic key Valid. Predicted C/N 10.9 dB at 27.9 deg.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'tune-the-uplink', + nice: ['S0421', 'K0773', 'T1567'], + title: 'Tune the Uplink', + description: + "TX modem 1 is on SAR-1's 1405 MHz command IF from the morning. Retune it for SAR-3's 14065 MHz TT&C uplink through the 12600 MHz BUC LO, and engage uplink Doppler compensation on the TT&C console before the window opens at 10:06:22.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['crypto-both-ways'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX 1465 MHz', + params: { modemNumber: 1, frequency: 1465e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Uplink IF Arithmetic', + params: { + character: Character.SYSTEM, + question: 'BUC LO 12600 MHz, low-side. SAR-3 TT&C uplink 14065 MHz. Where does the modem go?', + options: ['1465 MHz (RF minus LO, low-side)', '1405 MHz (leave it, same modem IF)', '1315 MHz (LO minus RF, high-side)', '26665 MHz (LO plus RF, low-side)'], + correctIndex: 0, + explanation: + 'Low-side on the uplink: RF = IF + LO, so IF = 14065 - 12600 = 1465. 1405 is SAR-1 and 1315 is the beacon IF. The receive side is high-side (IF = LO - RF); the two sides of this rack do their arithmetic with opposite signs, and the same 60 MHz mistake is possible on both.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'analyzer-on-sar3', + nice: ['S0421', 'K0773'], + title: 'Analyzer on the SAR-3 Plan', + description: + 'Frame the whole SAR-3 downlink plan on the analyzer: centre 1330 MHz at the 60 MHz span, so the 1315 MHz beacon and the 1322 to 1358 MHz test-pattern band are both on screen.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-the-uplink'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1330 MHz', + params: { centerFrequency: 1330e6, centerFrequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Screen Read', + params: { + character: Character.SYSTEM, + question: 'Centre 1330 MHz, span 60 MHz: 1300 to 1360 on screen. What is where, and what is just off the edge?', + options: [ + 'Beacon at 1315, test pattern 1322 to 1358; the 1290 transponded return of your own uplink off the left edge', + 'Beacon at 1315, test pattern 1322 to 1358; SAR-1 imagery at 1414 just off the right edge of the span', + 'Test pattern 1322 to 1358 only; the beacon at 1315 sits outside the video band and off the left edge', + 'Beacon at 1315, test pattern 1322 to 1358; the 1465 TT&C uplink mirrored by the high-side LO off the right edge', + ], + correctIndex: 0, + explanation: + 'Know the neighbours. The CW beacon and the 36 MHz pattern are both on screen. That 1290 MHz return is the station hearing itself through the bird, and it is about to matter more than anything else on this screen.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 10:06:00 from azimuth 140. Park the tracker on the rise azimuth at 5 degrees so the slew is done before she is over the horizon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['analyzer-on-sar3'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 140 / El 5', + params: { azimuth: 140, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 2: ACQUIRE, CHAIN COLD + // ============================================================ + { + id: 'acquire-sar3', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-3', + description: 'AOS 10:06. Program-track MERIDIAN-SAR-3 and confirm the 11785 MHz beacon (IF 1315 MHz) on RX analysis. Chain cold until the beacon is in.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-3', + params: { noradId: 61703 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-3 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-3-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 3: CHAIN UP IN ORDER, COMMAND, SECURE, DECODE + // ============================================================ + { + id: 'carrier-into-muted-buc', + nice: ['S0675', 'S0421', 'K0645'], + title: 'Carrier Into the Muted BUC', + description: 'Window open at 10:06:22. Chain up in order, first step: TX modem on air into the BUC while the BUC is still muted.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar3'], + timeLimitSeconds: 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Still Muted', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'unmute-the-buc', + nice: ['S0675', 'S0421', 'K0645'], + title: 'Unmute the BUC', + description: 'Second step: unmute the BUC. The carrier now reaches the HPA input, which is still disabled.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['carrier-into-muted-buc'], + timeLimitSeconds: 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Order Read', + params: { + character: Character.SYSTEM, + question: 'Modem into a muted BUC, then unmute, then HPA. Why that order and not the reverse?', + options: [ + 'Every stage sees a signal before it sees gain: an HPA enabled with no drive amplifies the noise floor into the feed', + 'The BUC cannot unmute while the HPA is enabled: the interlock on the rack holds the mute until the HPA drops', + 'It is convention on a Ku rack: either order works, the card just wants the three steps logged as separate lines', + 'The command key is validated before the carrier radiates: the modem has to be on air for the key check to run', + ], + correctIndex: 0, + explanation: + 'HPA_NOISE_AMPLIFICATION is the invariant this order protects; a BUC unmuted with the modem off does the same thing at lower level. On the way down it is the mirror image: HPA off first, then mute, then the carrier.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'enable-the-hpa', + nice: ['S0675', 'S0421', 'K0740'], + title: 'Enable the HPA', + description: 'Third step: HPA output enabled, and confirm it is linear. The uplink is on the air.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['unmute-the-buc'], + timeLimitSeconds: 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'hpa-not-overdriven', + description: 'HPA Linear', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'payload-power-on', + nice: ['T1092', 'T1567'], + title: 'Payload Power On', + description: 'Send PLD-ON and confirm the acknowledgement inside the window (10:06:22 to 10:15:08).', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['enable-the-hpa'], + conditions: [ + { + type: 'command-acknowledged', + description: 'PLD-ON Acknowledged', + params: { commandId: 'PLD-ON' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'payload-test-pattern', + nice: ['T1092', 'T1567', 'S0842'], + title: 'Payload Test Pattern', + description: + 'PLD-ON is acknowledged. Send PLD-TEST-PATTERN, confirm its ACK, and record the command checkout on the card. The console does not enforce the order; the card does.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['payload-power-on'], + conditions: [ + { + type: 'command-acknowledged', + description: 'PLD-TEST-PATTERN Acknowledged', + params: { commandId: 'PLD-TEST-PATTERN' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Command Checkout Recorded', + params: { + character: Character.SYSTEM, + question: + 'Command checkout entry. PLD-ON and PLD-TEST-PATTERN were both acknowledged inside the window. What does the card record as the checkout result, and what does the ACK pair actually prove?', + options: [ + 'PLD-ON then PLD-TEST-PATTERN, both ACKed; the ACKs prove receipt in order, the decode proves they worked.', + 'Two ACKs, so the payload is verified; the decode that follows is a customer courtesy, not part of the checkout.', + 'Pattern ACK alone, so the checkout passed; PLD-ON is implied by the pattern and needs no line of its own.', + 'Two ACKs and the HPA linear, so the uplink is accepted; the payload is accepted when Rotterdam sees telemetry.', + ], + correctIndex: 0, + explanation: + 'An acknowledgement is receipt, not effect. A pattern commanded to an unpowered payload ACKs just the same, which is why the checkout result is only complete once the decode is on the card.', + pointPenalty: 5, + documentSection: 'Command', + documentLine: + 'Command checkout: chain up modem-BUC-HPA in order; PLD-ON then PLD-TEST-PATTERN, both ACKed inside the 10:06:22 .. 10:15:08 window with uplink Doppler comp and a valid key. ACK = receipt, not effect; effect proven by the decode.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'call-the-return', + nice: ['S0648', 'K0926', 'K0773'], + title: 'Call the Return', + description: + 'A strong carrier has been sitting at 1290 MHz IF since the HPA came up, 50 dB over the pattern. Look at the TX chain and the RX key status, then call it: fault, interference, intrusion, or something of your own.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['payload-test-pattern'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'hpa-observed', + type: 'hpa-enabled', + description: 'HPA state read on TX Chain', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + id: 'key-observed', + type: 'rx-key-status', + description: 'RX key status read on RX Analysis', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Fault, interference, intrusion, or own uplink', + params: { + character: Character.ANNEKE_VISSER, + prompt: 'That carrier at 1290 has been there since your HPA came up and it is 50 dB over my pattern. What is it, and what are you doing about it?', + evidence: ['hpa-observed', 'key-observed'], + decisionOptions: [ + { + label: 'Receive-chain fault - open a ticket and hold the decode', + correctWhen: { fact: 'equipment-fault-active', is: true }, + feedback: 'Nothing on the RX chain is broken; the AGC is doing exactly what an AGC does with a strong in-band signal.', + }, + { + label: 'External interference - characterise it and report', + correctWhen: { + all: [ + { fact: 'interference-active', is: true }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Interference report opened on the 1290 MHz carrier' }, + feedback: 'It appeared the moment your own amplifier came up and sits exactly where your uplink transponds to. Look at what you changed.', + }, + { + label: 'Intrusion on the link - escalate and hold commanding', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { log: 'Escalated to CSIRT: suspected intrusion on the SAR-3 link' }, + feedback: 'Your key reads Valid and every command has ACKed. There is no sign anyone but you is on this link.', + }, + { + label: 'Own uplink transponded back - secure the HPA before the decode', + correctWhen: { + all: [ + { fact: 'interference-active', is: false }, + { fact: 'equipment-fault-active', is: false }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Self-interference identified: 14065 MHz uplink returned at 11810 MHz; securing the HPA before the decode' }, + }, + ], + explanation: + 'The station is hearing itself through the bird: co-polar, in the receive band, 50 dB up. No fault, no outsider, no intrusion - the evidence for all three is negative and the timing points straight at your own amplifier. Take the strong signal away and the number comes back.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'secure-for-decode', + nice: ['S0675', 'K0773', 'T0153'], + title: 'Secure the Uplink for the Decode', + description: + 'Both ACKs are in. Disable the HPA before you touch the receiver: the amplifier has nothing left to do until the next command, and while it is up the decode cannot be trusted.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-return'], + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled (uplink secured before the decode)', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Transponded Return Read', + params: { + character: Character.SYSTEM, + question: 'With the HPA up, the 1340 MHz test pattern reads about 3 dB of C/N instead of the 11 the worksheet predicts. What is happening?', + options: [ + "SAR-3's command transponder returns your own 14065 MHz carrier at 11810 MHz in band; the AGC pulls the chain down", + 'The HPA heats the LNB through the feed and raises the noise temperature; Tsys climbs past the 88 K on the worksheet', + 'Uplink Doppler compensation is dragging the receiver off frequency; the modem is locked on the skirt of the pattern', + 'The payload cannot transmit the pattern while it is receiving commands; the ACK traffic on 14065 MHz starves the video', + ], + correctIndex: 0, + explanation: + 'The station is hearing itself through the bird: co-polar, inside the receive band, some 50 dB above the pattern, and the video goes down with the AGC. Nothing on the RX chain is broken; it is doing exactly what an AGC does with a strong in-band signal. Take the strong signal away and the number comes back.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'first-video', + nice: ['T1092', 'T0153', 'S0842'], + title: 'First Imagery Decode', + description: + 'Retune modem 1 from 1414 MHz to 1340 MHz (11760 MHz RF), lock the test pattern, hold C/N above 8 dB, and press Commit Link near maximum elevation (10:10:45) with at least 2 dB of margin over the 6 dB threshold. Then put the number on the card beside the prediction.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['secure-for-decode'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1340 MHz', + params: { modemNumber: 1, frequency: 1340e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on 1340 MHz Test Pattern', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N >= 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'link-margin-met', + description: 'Measured Margin >= 2 dB Over Threshold', + params: { minMarginDb: 2 }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Measured C/N Recorded', + params: { + character: Character.SYSTEM, + question: + 'Payload entry. You predicted 10.9 dB at maximum elevation, the modem locked the pattern on 1340 MHz, and the link committed with at least 2 dB of margin. What goes on the card?', + options: [ + 'The measured C/N beside the 10.9 dB prediction and the difference; agreement within a decibel is the acceptance evidence.', + 'PASS beside the 6 dB threshold and the margin; clearing the threshold is the acceptance evidence, the number is not.', + 'The peak C/N only, without the prediction; the worksheet was a planning aid and has no place on a test record.', + 'The committed margin beside the 6 dB threshold; that is what the modem used, so it is the acceptance evidence.', + ], + correctIndex: 0, + explanation: + 'The margin alone says the link worked today, not that the payload performs as designed. A measurement that matches the model is what lets Rotterdam and the customer predict every pass after this one. A measurement with no prediction beside it is a good day, not an acceptance.', + pointPenalty: 5, + documentSection: 'Payload', + documentLine: + 'Payload: test pattern locked on IF 1340 MHz (11760 MHz RF) with the uplink secured. Predicted C/N 10.9 dB at max el (764 km, FSPL 171.5 dB at 11760 MHz); measured within 1 dB of prediction; committed with >= 2 dB margin over the 6 dB QPSK 3/4 threshold.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + + // ============================================================ + // BEAT 4: CHAIN DOWN, SIGN, DELIVER + // ============================================================ + { + id: 'chain-down-after-los', + nice: ['S0675', 'S0421', 'K0645'], + title: 'Chain Down After LOS', + description: 'LOS 10:15:29. The HPA is already off; finish the mirror order: mute the BUC, then take the carrier off the air.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['first-video'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'sign-the-card', + nice: ['T1611', 'T1506', 'S0842'], + title: 'Sign the Card', + description: 'The verdict, with its residual risk. Whatever is in the Verdict section when Anneke countersigns is the contract.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-down-after-los'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Verdict Recorded', + params: { + character: Character.SYSTEM, + question: + 'Verdict entry. Command checkout complete, first imagery decoded at the predicted performance. What is the verdict, and what residual risk does the card carry?', + options: [ + 'ACCEPTED at the tested performance. Residual risk: one pass, 28 deg max elevation, one site; PLD-SAFE not exercised.', + 'ACCEPTED with no residual risk. The payload works: one pass, 28 deg max elevation, and the rest is routine operations.', + 'PROVISIONAL at the tested performance. Residual risk: Shetland has not repeated the test on its own pass.', + 'ACCEPTED at the tested performance. Residual risk: the decode was taken with the uplink secured rather than live.', + ], + correctIndex: 0, + explanation: + 'Acceptance is a statement about what was tested, no more. Low-elevation passes and Shetland are unproven. Writing down what was not tested is what makes the card usable when the first low pass comes in under budget. Decoding with the uplink secured is the procedure, not a risk.', + pointPenalty: 5, + documentSection: 'Verdict', + documentLine: + 'Verdict: SAR-3 payload ACCEPTED at tested performance (one pass, 27.9 deg max el, GW-01 only). Residual risk: low-elevation and SH-02 performance not measured; PLD-SAFE not exercised. Chain secured HPA-BUC-modem after LOS.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'deliver-to-customer', + nice: ['S0478', 'T1506'], + title: 'Deliver to the Customer', + description: 'Erik is copied on the signed card and wants to task the bird. Confirm what acceptance transfers to Nordic Maritime Watch.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['sign-the-card'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Delivery Confirmed', + params: { + character: Character.SYSTEM, + question: 'The card is signed and Erik is asking when he can task SAR-3. What does acceptance transfer to the customer?', + options: [ + 'SAR-3 enters the tasking pool at the tested performance; anything below that number is now a service ticket.', + 'Nothing yet; acceptance is an internal record, and the customer gets the bird after a further shakedown period.', + 'Unlimited tasking; acceptance means the payload is proven under every condition it will ever fly in.', + 'Tasking on Galway passes above 28 degrees only, until Shetland has run its own card on its own pass.', + ], + correctIndex: 0, + explanation: + 'The tested performance is now the contract number. Erik can task from the next pass, and the first time a pass comes in under it, the card is what turns a complaint into a ticket with a number on it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 09:40] +

+

+ "Test plan attached. Bus has been clean for two days, so today is the payload: readiness on the card, chain up in order, power it, command the test pattern, secure the uplink, decode it, record it. One pass, AOS 10:06, do it in the order on the card. Erik is copied and he will ask the moment it is signed." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'enable-the-hpa': { + text: ` +

+ Carrier on my display at 14065, clean, linear. Window is open. PLD-ON when you are ready; wait for the ACK before the pattern. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'payload-test-pattern': { + text: ` +

+ Both acknowledgements on my screen. Payload on, pattern transmitting. Before you take the decode - what is that carrier at 1290 on your analyzer? It has been there since your amplifier came up. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'call-the-return': { + text: ` +

+ Yes. That is you, through my transponder. Nobody else is on this link and nothing of yours is broken. Secure the amplifier and take the decode; it has done its job for today. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'first-video': { + text: ` +

+ First frames from SAR-3. Log it - every number on that card is the one the customer contract quotes back at us. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'sign-the-card': { + text: ` +

+ Copied on the card. Four sections and one of them says accepted. When can I task it? +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'deliver-to-customer': { + text: ` +

+ Card signed, Erik has his answer, SAR-3 is in the pool. LEOP closed after two passes from Galway. Thank you - Rotterdam out. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario13.ts b/src/campaigns/nats-eu/scenario13.ts new file mode 100644 index 00000000..e59a4cc3 --- /dev/null +++ b/src/campaigns/nats-eu/scenario13.ts @@ -0,0 +1,1011 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, createMeridianSar3, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 13 - "The Numbers Don't Lie" / Pass-Performance Trending + * + * Two numbers on the week's log disagree with expectation, and the causes + * look nothing alike in the data. Act 1 is the explained anomaly: an 89 deg + * MERIDIAN-SAR-2 pass whose link collapses at culmination and comes back on + * the descending leg (the azimuth keyhole - physics, not a fault). Act 2 is + * the real fault: MERIDIAN-SAR-1 at the exact survey geometry decodes four + * decibels under the survey budget, and the operator rules out ephemeris and + * pointing before reading the LNB (150 K against a 60 K survey value). Act 3 + * proves Shetland is clean, moves the customer's priority collect there and + * flies it FROM SH-02 so the comparison is measured, not asserted. + * + * Clock starts 2027-03-29 13:00:00 UTC. Passes (0 deg horizon, per station, + * scripts/author-passes.mjs): + * + * GW-01 MERIDIAN-SAR-2: AOS T+12.00 (13:12:00Z, az 163), max el 89.5 deg at + * T+16.94 (13:16:56Z, 391 km), LOS T+21.93 (13:21:56Z, + * az 345), 9.9 min, northbound. The zenith pass, on the + * 360 km orbit so both legs are strong and the crater + * is unmistakable (the 560 km bird only reached 8 dB). + * SH-02 MERIDIAN-SAR-2: AOS T+13.66 (13:13:39Z), max el 27.3 deg at T+18.43, + * LOS T+23.22 (Fiona's). + * SH-02 MERIDIAN-SAR-1: AOS T+28.07 (13:28:04Z), max el 23.7 deg at T+32.73, + * LOS T+37.39 (Fiona's). + * GW-01 MERIDIAN-SAR-1: AOS T+30.03 (13:30:01Z, az 005), max el 28.0 deg at + * T+34.76 (13:34:46Z, 762 km), LOS T+39.50 (13:39:30Z, + * az 219), 9.5 min, southbound. The S2 survey geometry. + * GW-01 MERIDIAN-SAR-3: AOS T+51.90, max el 9.6 deg, LOS T+59.69 (a graze). + * SH-02 MERIDIAN-SAR-3: AOS T+52.00 (13:52:00Z, az 139), max el 31.9 deg at + * T+56.77 (13:56:46Z, 694 km), LOS T+61.59 (14:01:35Z, + * az 350), 9.6 min, northbound. The collect, from SH-02. + * + * The keyhole as the engine renders it: the pedestal's program-track lock and + * the receiver's carrier are lost for several seconds through the top (the + * azimuth swing of ~180 deg at 20 deg/s) and come back on the descending leg. + * The crater objective therefore holds receiver lock for 100 s: activated on + * the rising leg it cannot finish before the top, the dropout resets it, and + * it completes on the descending leg. (A `maxCNRatio` reading is not used: the + * 1 s satellite position throttle puts one low-C/N frame in every second of a + * LEO pass, so an unheld "below X dB" condition latches anywhere, and the + * keyhole is a carrier loss, which `cnHoldSeconds` cannot read either.) + * + * Staged state (scenario-local clone): GW-01's LNB has a degraded LNA (noise + * figure 1.8 dB, noise temperature ~150 K settled) - the station is wrong, the + * survey budget is right. Everything else on both sites is the shared default + * (RX modems on 1414 MHz). All thresholds flown in + * test/campaigns/nats-eu-phase-c-validation.test.ts, the SAR-3 collect from + * Shetland, the degraded shortfall and the keyhole crater asserted there. + * + * NICE Framework Alignment: + * Primary Codes: + * - T0349: Collect and analyze performance data to identify trends + * - S0646: Skill in analyzing system performance data + * - S0892: Skill in troubleshooting and diagnosing technical issues + * - K0064: Knowledge of performance tuning tools and techniques + * + * Supporting Codes: + * - K0740: Knowledge of system performance indicators + * - K1032: Knowledge of satellite-based communication systems + * - K0739: Knowledge of antenna and pointing systems + * - T0531: Troubleshoot system hardware and software + * - T0431: Check system hardware availability, functionality, integrity + * - K0689: Knowledge of network systems management + * - S0675: Skill in maintaining equipment + * - T1606: Report findings and recommendations + */ + +/** MERIDIAN-SAR-1 (61701) at the S13 epoch: the S2 survey geometry over Galway. */ +const SAR1_S13_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27088.54166667 .00001000 00000-0 10000-3 0 9991' as TleLine1, + tle2: '2 61701 97.2000 181.7500 0010000 90.0000 258.7500 15.60000000123451' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S13 epoch: the 89 deg zenith pass over Galway. */ +const SAR2_S13_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27088.54166667 .00001000 00000-0 10000-3 0 9992' as TleLine1, + tle2: '2 61702 98.4000 28.2500 0010000 90.0000 258.0000 15.60000000123458' as TleLine2, +}; + +/** MERIDIAN-SAR-3 (61703) at the S13 epoch: the 32 deg collect over Shetland. */ +const SAR3_S13_TLE: MeridianTle = { + tle1: '1 61703U 27015A 27088.54166667 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61703 97.2000 58.0000 0010000 90.0000 111.5000 15.60000000123455' as TleLine2, +}; + +const meridianSar1S13 = createMeridianSar1(SAR1_S13_TLE); +const meridianSar2S13 = createMeridianSar2(SAR2_S13_TLE); +const meridianSar3S13 = createMeridianSar3(SAR3_S13_TLE); + +/** + * GW-01 with a degraded LNB front end. The LNA noise figure has drifted from + * 0.8 to 1.8 dB, so the Friis noise temperature settles near 150 K instead + * of 60 K. The LNA is the first stage, so its noise temperature is very + * nearly the whole system temperature: 10 log(150 / 60) = 4.0 dB of C/N at + * every elevation (measured 10.9 -> 7.0 dB at the survey geometry in the + * harness). `noiseTemperature` is staged at the settled value so the display + * does not start on a warm-up ramp. Deep clone, never spread. + */ +const galwayDegraded: GroundStationConfig = structuredClone(galwayGroundStation); +galwayDegraded.rfFrontEnds[0].lnb = { + ...galwayDegraded.rfFrontEnds[0].lnb, + lnaNoiseFigure: 1.8, + noiseTemperature: 149, +}; + +export const natsEuScenario13Data: ScenarioData = { + id: 'nats-eu-scenario13', + url: 'nats-eu/scenarios/nats-eu-scenario13', + imageUrl: 'nats/13/card.png', + number: 13, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario12'], + title: "The Numbers Don't Lie", + subtitle: 'Pass-Performance Trending', + duration: '40 min', + missionType: 'Trend Analysis', + description: `13:00 local. The week's pass log has two things wrong with it.

Every MERIDIAN-SAR-1 pass at Galway since Thursday has decoded two to four decibels under budget, and Shetland's have not moved. And the planners have handed you an 89 degree SAR-2 pass at 13:12, the best geometry all month, that Anneke expects to fall over at the top.

Fly the steep pass and explain what it does. Measure SAR-1 at the survey geometry against the survey budget, rule out the easy causes, and find the real one. Then prove Shetland is clean, move Erik's 13:52 collect there, and fly it from the Shetland console so the report carries a measurement from both sites.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'Link Analysis Console', + 'Contact Plan Console', + 'Ku-Band RF Front End (GPSDO / LNB / BUC / HPA)', + 'RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayDegraded, shetlandGroundStation], + satellites: [meridianSar1S13, meridianSar2S13, meridianSar3S13], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-29', + scenarioStartWallTime: '13:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-13?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // Twelve minutes of empty sky between the SAR-1 survey pass (LOS 13:39) and + // the Shetland collect (AOS 13:52). The skip stops 2 min before the next + // pass at either site and is blocked while a timed objective runs. + timeSkip: { + leadTimeS: 120, + minSkipS: 300, + horizonHours: 2, + }, + + workingDocument: { + title: 'Weekly Pass-Performance Report', + description: 'Week 13, 2027. One line per anomaly with its cause, and the recommendation to Rotterdam and to maintenance.', + }, + + // M1 - the survey budget (S2 numbers). expectedCNRDb is what a CORRECT + // worksheet yields from the published survey values; the point of this + // scenario is that the live station will NOT deliver requiredMarginDb. + linkBudget: { + label: 'GW-01 SAR-1 downlink at max elevation (survey values)', + expectedCNRDb: 11.0, + toleranceDb: 1.0, + thresholdCNRDb: 6, + requiredMarginDb: 3, + }, + + // M3 - the board. Only the priority-1 collect has to be covered for the + // plan to validate; the passes already flown are on the board for the + // record. Windows are 0 deg horizon crossings, per station. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 1, + contacts: [ + { id: 'T-SAR2-GW', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Galway, 89 deg zenith pass)', priority: 2, windowStartS: 720, windowEndS: 1320, stationId: 'GW-01' }, + { id: 'T-SAR2-SH', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Shetland, 27 deg, F. MacLeod)', priority: 3, windowStartS: 820, windowEndS: 1390, stationId: 'SH-02' }, + { id: 'T-SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 24 deg, F. MacLeod)', priority: 2, windowStartS: 1680, windowEndS: 2240, stationId: 'SH-02' }, + { + id: 'T-SAR1-GW', + satelliteNoradId: 61701, + label: 'MERIDIAN-SAR-1 (Galway, 28 deg survey geometry)', + priority: 2, + windowStartS: 1800, + windowEndS: 2370, + stationId: 'GW-01', + }, + { id: 'T-SAR3-GW', satelliteNoradId: 61703, label: 'MERIDIAN-SAR-3 (Galway, 10 deg graze)', priority: 3, windowStartS: 3110, windowEndS: 3580, stationId: 'GW-01' }, + { + id: 'T-SAR3-SH', + satelliteNoradId: 61703, + label: 'MERIDIAN-SAR-3 (Shetland, 32 deg) - NMW priority collect', + priority: 1, + windowStartS: 3120, + windowEndS: 3700, + stationId: 'SH-02', + }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'T0349'], + title: "Read the Week's Log", + description: 'Open the brief: the pass log for the week, the survey budget, and the two numbers that do not agree with it.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Week Log Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Two Anomalies Read', + params: { + character: Character.SYSTEM, + question: + 'Two anomalies on the log: a link that collapsed at the top of an 89 degree pass, and four days of SAR-1 decodes 2-4 dB under budget at Galway only. Which one is a fault?', + options: [ + 'You do not know yet: a one-off on the steepest pass of the month and a four-day drift on one site each need their own test', + 'Both: two anomalies on one site inside one week is a station problem, and the pedestal and the LNB share a ticket', + 'The keyhole pass: the link failed at the top, so something broke, while a 2-4 dB drift is a soft number', + 'Neither: two to four dB is inside normal pass-to-pass variation, and a single lock drop on a steep pass is noise', + ], + correctIndex: 0, + explanation: + 'Trend data separates a one-off from a drift; they are different kinds of number, and neither gets a cause before its test. The steep pass is tested by flying one and watching where it fails; the drift is tested by measuring the survey geometry against the survey budget. Twelve minutes to the first pass. Shift clock started.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: PRE-PASS FOR THE ZENITH PASS + // ============================================================ + { + id: 'galway-pre-pass-sweep', + nice: ['T0431', 'K0740'], + title: 'GW-01 Pre-Pass Sweep', + description: + 'Board, reference and downconversion before the 13:12 pass: nothing on the board but the RX AGC rail, GPSDO locked, LNB on the 13100 MHz LO. Note what the LNB panel says while you are there.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: false, + }, + { + type: 'gpsdo-locked', + description: 'GW-01 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'lnb-lo-set', + description: 'GW-01 LNB LO 13100 MHz', + params: { loFrequency: 13100 as MHz, loFrequencyTolerance: 0, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'tune-for-the-zenith-pass', + nice: ['S0421', 'K1032', 'K0739'], + title: 'Tune for the Zenith Pass', + description: + 'Modem 1 from 1414 MHz to the 1370 MHz SAR-2 imagery carrier; analyzer centred on 1370 MHz with a 2 MHz span. Then read the pass geometry and say what the pedestal will do at 89 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-pre-pass-sweep'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'GW-01 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Keyhole Predicted', + params: { + character: Character.SYSTEM, + question: 'SAR-2 rises at 13:12 from azimuth 163 and sets at 345, passing 89.5 degrees at 13:17. What does the pedestal have to do at the top, and can it?', + options: [ + 'Swing almost 180 degrees of azimuth in the seconds the bird takes to cross the zenith; at 20 deg/s it cannot', + 'Nothing unusual: elevation goes up to 89.5 and comes back down; azimuth barely moves, so the pedestal holds', + 'Switch to step-track through the top; step-track does not need azimuth, so the pedestal holds the beam', + 'Reverse the elevation axis over the top; the ACU does it automatically, so the pedestal holds the beam', + ], + correctIndex: 0, + explanation: + 'An elevation-over-azimuth pedestal has a keyhole at the zenith: the azimuth rate it needs goes to infinity through the top, and the 0.45 degree beam falls off the spacecraft near culmination. 73 degrees was a few degrees a second and it held. 89 degrees is not.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-sar2', + nice: ['S0421', 'K0739'], + title: 'Pre-position for SAR-2', + description: 'Park the tracker on the AOS azimuth, 163 degrees at 5 degrees elevation, so the slew is done before the bird rises.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-for-the-zenith-pass'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Az 163 / El 5', + params: { azimuth: 163, elevation: 5, tolerance: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: THE ZENITH PASS + // ============================================================ + { + id: 'fly-the-zenith-pass', + nice: ['T0153', 'K0740', 'K1032'], + title: 'Fly the Zenith Pass', + description: 'AOS 13:12. Program-track MERIDIAN-SAR-2, lock the 1370 MHz carrier on the ascending leg and read C/N above 8 dB on RX analysis. Keep watching as it climbs.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-sar2'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB (ascending)', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'ride-through-the-keyhole', + nice: ['S0892', 'K0739', 'K0740'], + title: 'Ride Through the Keyhole', + description: + 'Stay on RX analysis through culmination at 13:17. The pedestal loses the bird for several seconds at the top and the receiver drops lock with it; do not chase it. Hold lock for 100 s once it comes back on the descending leg, and name what did it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['fly-the-zenith-pass'], + conditions: [ + { + type: 'tab-active', + description: 'RX Analysis Watched Through the Top', + params: { tab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Lock Held 100 s (descending leg)', + params: { modemNumber: 1 }, + mustMaintain: true, + maintainDuration: 100, + }, + { + type: 'status-check', + description: 'Crater Explained', + params: { + character: Character.SYSTEM, + question: 'Best geometry of the month, 391 km at the top, and the lock dropped exactly there. What killed the link?', + options: [ + 'The azimuth keyhole: the pedestal cannot slew azimuth fast enough through the zenith, and the beam falls off the bird', + 'The LNB: the same fault as the SAR-1 shortfall, and at 391 km the hot front end finally ran out of margin', + 'Doppler: the rate of change through the zenith exceeded the demodulator loop bandwidth, and the modem lost carrier', + 'The spacecraft: it turned its antenna away at nadir, and the beam came back only once it had rolled off the top', + ], + correctIndex: 0, + explanation: + 'Program-track never lost the elements; the pedestal lost the race, and the link is gone until it catches up on the descending leg. Physics, not a fault. Every el-over-az tracker has this hole; the only mitigations are accepting the outage, an X/Y pedestal, or a tilted axis. Wait for it on the way down.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'record-anomaly-1', + nice: ['S0892', 'K1032', 'T0349'], + title: 'Record the First Anomaly', + description: 'Write the zenith pass into the report as what it is: an operations note, not a fault.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['ride-through-the-keyhole'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Anomaly 1 Recorded', + params: { + character: Character.SYSTEM, + question: 'What goes in the report for the 89 degree pass, and to whom?', + options: [ + 'An operations note to the planners: zenith keyhole, pedestal rate limit; passes above about 80 degrees carry an outage', + 'A maintenance ticket for the pedestal: azimuth drive rate limit, lock lost at culmination; the drive needs a look', + 'A request to Rotterdam: orbit too steep over Galway, lock lost at the top; lower it so it stops going overhead', + 'Nothing to anyone: the link recovered on the descending leg, no data lost; there is nothing here to report', + ], + correctIndex: 0, + explanation: + 'A number that disagrees with expectation has a cause, and this cause has no fix on the rack. Not a fault: the usable segment is on the two legs, not at the top. The planners can plan around it; maintenance cannot repair geometry.', + pointPenalty: 5, + documentSection: 'Anomaly 1', + documentLine: + '13:12Z MERIDIAN-SAR-2 GW-01, 89.5 deg: lock lost at culmination for several seconds, recovered descending leg. Cause: zenith keyhole (el-over-az pedestal azimuth rate limit). Not a fault. Ops note: passes above ~80 deg carry a culmination outage.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4 -> BEAT 1: RETUNE FOR THE SURVEY PASS + // ============================================================ + { + id: 'retune-for-the-survey-pass', + nice: ['S0421', 'K0773'], + title: 'Retune for the Survey Pass', + description: + 'LOS 13:22. Modem 1 back to the 1414 MHz SAR-1 carrier, analyzer centred on the 1389 MHz SAR-1 beacon. The tracker stays where the zenith pass left it; program-track slews to the 13:30 rise at azimuth 5.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['record-anomaly-1'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'compute-the-survey-budget', + nice: ['T0349', 'S0646', 'K0740'], + title: 'Compute the Survey Budget', + description: + 'The 13:30 pass is the acceptance geometry to the kilometre: 762 km at 28 degrees. Enter the survey worksheet in Link Analysis: EIRP 28 dBm, free-space path loss 171.4 dB at 11686 MHz, receive gain 51.8 dBi, system noise temperature 88 K, occupied bandwidth 36 MHz, miscellaneous losses 1 dB.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retune-for-the-survey-pass'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'link-budget-computed', + description: 'Survey Prediction Computed', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Prediction vs History', + params: { + character: Character.SYSTEM, + question: 'The survey worksheet says 11.0 dB at this geometry. The log says Galway has decoded near 7 at it for four days. Which number goes on the worksheet?', + options: [ + 'The survey number: the prediction is what a healthy station does at this geometry, and the gap is the finding', + 'The logged 7 dB: a prediction should match what the station actually does, and the worksheet is a record of it', + 'The average of the two: 9 dB splits the difference between the survey and the log, and the gap is halved', + 'Whichever makes Commit Link pass: the console needs 3 dB of margin, and the worksheet is there to give it one', + ], + correctIndex: 0, + explanation: + 'The measurement is what this station does today; neither number may be bent toward the other. A budget that is tuned to the symptom hides the symptom. 11.0 dB predicted; measure it in four minutes.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEATS 2-3: MEASURE SAR-1 AT THE SURVEY GEOMETRY + // ============================================================ + { + id: 'measure-sar1-at-galway', + nice: ['T0349', 'S0646', 'K0740'], + title: 'Measure SAR-1 at Galway', + description: + 'AOS 13:30 from azimuth 5. Program-track MERIDIAN-SAR-1, confirm the beacon, lock the 1414 MHz carrier and read the C/N at maximum elevation (13:34:46, 28 degrees). Predicted 11 dB. Then say what Commit Link will do with it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['compute-the-survey-budget'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon on Boresight', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 4 dB', + params: { modemNumber: 1, minCNRatio: 4, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Shortfall Read', + params: { + character: Character.SYSTEM, + question: 'Predicted 11.0 dB. Measured about 7 at the top of the pass, on a clean lock. What does the Link Analysis console do if you press Commit Link?', + options: [ + 'Refuses the commit: 7 dB is 1 dB over the 6 dB threshold and the survey asks for 3 dB of margin', + 'Accepts the commit: the modem is locked and decoding at 7 dB, which is what a link is', + 'Accepts the commit once the worksheet is corrected to 7 dB, so the prediction and the measurement agree', + 'Refuses the commit: the prediction was entered wrong, since 11 dB was never going to appear on this station', + ], + correctIndex: 0, + explanation: + 'Neither number is wrong; the station is not the station the survey measured. A 4 dB shortfall at the exact survey geometry on a clean lock is a station finding, not a pass finding. Three candidate causes: element-set age, pointing, receive chain. Rule out the first two.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'rule-out-the-ephemeris', + nice: ['S0892', 'T0531', 'K0739'], + title: 'Rule Out the Ephemeris', + description: + 'Open the Pass Schedule and read the ephemeris panel: element-set age, pending updates. The beacon sat on boresight and program-track never dropped. Say what is left.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['measure-sar1-at-galway'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule / Ephemeris Panel Open', + params: { tab: 'pass-schedule' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Two Causes Ruled Out', + params: { + character: Character.SYSTEM, + question: + 'No ephemeris events pending, elements current; the beacon peaked on boresight and the pedestal stayed program-track locked through the pass. Which candidate is left?', + options: [ + 'The receive chain: stale elements and a pointing fault both show on the beacon and the tracker, and both were clean', + 'The ephemeris: a 4 dB loss is exactly what 300 m of along-track error costs, and current elements can still be off', + 'Pointing: 0.1 degree of boresight error on a 0.45 degree beam is 4 dB, and program-track can hold a wrong offset', + 'The spacecraft: its transmitter has aged since the survey, and a 4 dB loss on the same bird would look the same', + ], + correctIndex: 0, + explanation: + 'Stale elements show as a pointing miss that program-track cannot hold, and a pointing fault shows as a beacon off boresight; both were clean, so the loss is between the feed and the demodulator. Shetland decoded the same bird at budget on Thursday and Friday. A spacecraft does not degrade for one site. Read the LNB.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-galway-lnb', + nice: ['S0892', 'T0531', 'K0064'], + title: 'Read the Galway LNB', + description: 'RX analysis, LNB panel: noise temperature against the 60 K survey value. Do the arithmetic and write the second anomaly into the report with its cause.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rule-out-the-ephemeris'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'RX Analysis Open', + params: { tab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Anomaly 2 Recorded', + params: { + character: Character.SYSTEM, + question: 'GW-01 LNB noise temperature reads about 150 K against a 60 K survey value. What does that do to the link?', + options: [ + 'The LNA is the first stage, so its noise temperature is nearly the whole system temperature: 10 log(150 / 60) is 4.0 dB', + 'Nothing measurable, since noise temperature is a receiver spec and not a link term: the 88 K on the worksheet covers it', + 'About 0.4 dB, since 90 K is small next to the 290 K of the sky: 10 log(380 / 290) is nowhere near the shortfall', + 'It raises the C/N, since the LNB is running hotter and therefore has more gain: 10 log(150 / 60) is 4.0 dB up', + ], + correctIndex: 0, + explanation: + 'C/N is carrier over k T B, and Friis puts the first stage in charge of T. Four decibels is the whole shortfall: the feed or the LNA has degraded. Power-cycling the LNB starts the noise temperature at twice nominal and settles it back to 150 K: worse, then the same. This is a maintenance ticket.', + pointPenalty: 5, + documentSection: 'Anomaly 2', + documentLine: + '13:30Z MERIDIAN-SAR-1 GW-01, 28 deg / 762 km: predicted 11.0 dB (survey), measured ~7 dB. Shortfall 4.0 dB = LNB noise temperature 150 K vs 60 K survey (10 log(150/60)). Feed/LNA degradation. Ephemeris and pointing ruled out.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 5: SHETLAND, THE REALLOCATION, THE COLLECT + // ============================================================ + { + id: 'prove-shetland-is-clean', + nice: ['T0431', 'K0689', 'S0675'], + title: 'Prove Shetland Is Clean', + description: "Select SH-02. Fiona's LNB noise temperature must be inside the 100 K spec and her reference locked before the priority collect moves there.", + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['read-the-galway-lnb'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'lnb-noise-performance', + description: 'SH-02 LNB Noise Temperature Within Spec', + params: { maxNoiseTemperature: 100, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'gpsdo-locked', + description: 'SH-02 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'reallocate-the-collect', + nice: ['K0689', 'T0129', 'S0646'], + title: 'Reallocate the Collect', + description: "Erik's 13:52 SAR-3 collect is on the board for both sites. Put it on SH-02 and validate the plan.", + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['prove-shetland-is-clean'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-3 Collect on SH-02', + params: { contactId: 'T-SAR3-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Standing Rule Set', + params: { + character: Character.SYSTEM, + question: 'The collect goes to Shetland. What is the standing rule for Galway until the LNB is replaced?', + options: [ + 'Derate GW-01 by 4 dB in every budget and put priority collects on SH-02: a 3 dB margin at Galway is now a miss', + 'Take GW-01 off the board entirely until maintenance signs it off: a station with a 4 dB shortfall flies nothing', + 'Nothing changes at GW-01 and the collects stay where they are: 7 dB still decodes, so the plan is still good', + 'Raise the delivery standard to 11 dB in every budget and keep the collects: the shortfall is then visible on the plan', + ], + correctIndex: 0, + explanation: + 'A plan that does not know about the derate will book collects the station cannot deliver. A derated site is still a site. It flies routine passes at 7 dB; it does not carry a priority collect on a 3 dB margin it no longer has.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'arm-shetland-for-sar3', + nice: ['S0421', 'K0773', 'K1032'], + title: 'Arm Shetland for SAR-3', + description: + 'SAR-3 is on its own plan: video at 1340 MHz IF, beacon at 1315 MHz. Modem 1 to 1340 MHz, analyzer centred on 1315 MHz with a 2 MHz span, tracker parked on the 13:52 AOS azimuth: 139 degrees at 5 degrees elevation.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['reallocate-the-collect'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1340 MHz', + params: { modemNumber: 1, frequency: 1340e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'SH-02 Analyzer Centre 1315 MHz', + params: { centerFrequency: 1315e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'SH-02 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'antenna-position', + description: 'SH-02 Az 139 / El 5', + params: { azimuth: 139, elevation: 5, tolerance: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'fly-the-collect-from-shetland', + nice: ['T0153', 'K0740', 'K1032'], + title: 'Fly the Collect from Shetland', + description: + 'AOS 13:52 from azimuth 139. Program-track MERIDIAN-SAR-3 on the SH-02 pedestal, lock the 1340 MHz carrier and hold C/N above 8 dB through culmination (13:56:46, 32 degrees, 694 km). Read the number against the geometry.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['arm-shetland-for-sar3'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-3', + params: { noradId: 61703 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on SAR-3', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Shetland Baseline Read', + params: { + character: Character.SYSTEM, + question: 'Shetland peaks near 11.5 dB at 32 degrees and 694 km with a 60 K LNB. Galway gave 7 at 28 degrees and 762 km. Is Shetland the better station?', + options: [ + 'No: 20 log(762 / 694) is 0.8 dB of geometry, and the rest of the gap is the Galway LNB', + 'Yes: 4.5 dB better on the same bird class is a better station, whatever the geometry says', + 'Unknown: SAR-3 and SAR-1 are different spacecraft, and 762 km against 694 km is not the same pass', + 'No: Shetland is reading high because its 60 K LNB is cold, and the number will settle once it warms', + ], + correctIndex: 0, + explanation: + 'Shetland is a healthy station at its geometry; Galway is a degraded one at its own. A site is only underperforming when its number is worse than its geometry. Two measurements, two sites, one bird class: that is a trend report, not an opinion.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 4: THE REPORT + // ============================================================ + { + id: 'file-the-trend-report', + nice: ['S0892', 'T1606', 'S0646'], + title: 'File the Trend Report', + description: 'Close the report: one recommendation to Rotterdam, one maintenance action, and the standing rule for the planners.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['fly-the-collect-from-shetland'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Recommendation Filed', + params: { + character: Character.SYSTEM, + question: 'What goes to Rotterdam, and what goes to maintenance?', + options: [ + 'Rotterdam: the keyhole is an operations note and GW-01 is derated 4 dB until repaired. Maintenance: LNB ticket for GW-01', + 'Rotterdam: two faults at Galway, site unavailable until both are cleared. Maintenance: pedestal drive and LNB tickets', + 'Rotterdam: nothing until maintenance confirms the LNB reading. Maintenance: the LNB ticket, with the 150 K measurement', + 'Rotterdam: the survey budget is wrong and needs re-issuing at 7 dB. Maintenance: nothing, the station is within its number', + ], + correctIndex: 0, + explanation: + 'Two anomalies, two audiences, two kinds of action: plan passes above 80 degrees with a culmination outage, priority collects on SH-02 until the LNB is replaced, and the 150 K measurement attached to the ticket. The report is what turns a bad pass next week into a comparison instead of an argument.', + pointPenalty: 5, + documentSection: 'Recommendation', + documentLine: + 'Recommendation: (1) Ops note - passes above ~80 deg carry a culmination outage, not a fault. (2) GW-01 derated 4 dB until LNB replaced; priority collects to SH-02 (13:52Z SAR-3 collect flown from SH-02, peak C/N ~11.5 dB at 32 deg). (3) Maintenance ticket: GW-01 LNB, noise temperature 150 K vs 60 K survey.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 12:48] +

+

+ "Your week's log makes no sense and I would like it to. Galway SAR-1 is two to four dB under budget since Thursday; Shetland on the same bird is not. And the planners have given you the 89 degree SAR-2 at 13:12, which I expect to fall over at the top, and I want to know why before they book another one. Two numbers, two causes. Report by 14:00 please." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + objectives: { + 'record-anomaly-1': { + text: ` +

+ So the best pass is the worst pass. Noted for the planners: anything over eighty is a culmination outage, not a ticket. Now the one that worries me - the SAR-1 number. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'read-the-galway-lnb': { + text: ` +

+ [Text message from Fiona MacLeod at 13:41] +

+

+ "Mine's reading 60 K and change, same as the day it was fitted. Yours is cooking. Don't power-cycle it, that just starts the number higher. Send Erik's collect up here - I'll be on the pedestal with a torch from ten to, so the console's yours." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'reallocate-the-collect': { + text: ` +

+ [Text message from Fiona MacLeod at 13:45] +

+

+ "Seen it on the board. SAR-3 is on its own numbers, mind: 1340 for the video, 1315 for the beacon. Thirty-two degrees from the south-east. It's a good one." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'fly-the-collect-from-shetland': { + text: ` +

+ [Text message from Fiona MacLeod at 13:59] +

+

+ "Watched the dish from the pedestal. Never wobbled, and I'd guess eleven and a bit at the top from the way the alarm panel stayed dark. That's your comparison. Same bird class, two sites, one of them cooking." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'file-the-trend-report': { + text: ` +

+ Two anomalies, two causes, nothing alike in the data. The keyhole goes to the planners and the LNB goes to maintenance with your number on it. That is the report I wanted. Thank you, Galway. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario14.ts b/src/campaigns/nats-eu/scenario14.ts new file mode 100644 index 00000000..b57f5028 --- /dev/null +++ b/src/campaigns/nats-eu/scenario14.ts @@ -0,0 +1,931 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 14 - "Atlantic Low" / Weather Reallocation Judgment + * + * A deep low crosses Ireland. Real Ku rain fade (phase 16, E5) sits on Galway + * for the customer's priority-1 MERIDIAN-SAR-2 collect at 11:12: 30 mm/h on + * a 19 degree pass is about 7 dB of path attenuation at 11.7 GHz, so the + * collect cannot close from GW-01 and is reallocated to SH-02, which has sky, + * and flown FROM the Shetland console. Galway is armed anyway so the operator + * can watch the fade eat the same pass live. Behind the rain the front turns + * to sleet (ice on the feed, the heater's problem) for the priority-2 SAR-1 + * window at 11:45; that one is a judgment call - ride it out at Galway on + * measured margin with the heater on, or hand it over too. + * + * Clock starts 2027-04-02 11:00:00 UTC. Passes (0 deg horizon, per station, + * scripts/author-passes.mjs): + * + * SH-02 MERIDIAN-SAR-2: AOS T+10.71 (11:10:42Z, az 023), max el 34.1 deg at + * T+15.58 (11:15:35Z, 659 km), LOS T+20.42 (11:20:25Z, + * az 178), 9.7 min, southbound. The collect, from SH-02. + * GW-01 MERIDIAN-SAR-2: AOS T+12.90 (11:12:53Z, az 028), max el 19.1 deg at + * T+17.50 (11:17:30Z, 993 km), LOS T+22.05 (11:22:03Z, + * az 163), 9.2 min, southbound. In the rain: a fade, not + * a decode. + * GW-01 MERIDIAN-SAR-1: AOS T+44.98 (11:44:59Z, az 142), max el 29.6 deg at + * T+49.72 (11:49:43Z, 732 km), LOS T+54.51 (11:54:31Z, + * az 355), 9.5 min, northbound. The ride-out. + * SH-02 MERIDIAN-SAR-1: AOS T+46.08, max el 75.1 deg, LOS T+56.03 (Fiona's + * fallback, never needed). + * + * Weather at GW-01: rain, severe (30 mm/h), 11:01 to 11:26 with 3.75 min + * ramps; then hail (sleet), severe, 11:26 to 12:11. Ice on the feed builds to + * 10 dB with a 12 min time constant while the heater is off and melts at + * 1 dB/min once it is on: heater on before the sleet keeps the 11:45 pass at + * its clear-sky ~11 dB; heater never on puts ~8.6 dB of ice on it at + * culmination and there is no lock. Both curves asserted in + * test/campaigns/nats-eu-phase-c-validation.test.ts, along with the rain fade + * on the Galway SAR-2 pass and the dry Shetland decode. + * + * Staged state: both sites the shared default (RX modems on 1414 MHz, BUCs + * unmuted, heaters off). + * + * NICE Framework Alignment: + * Primary Codes: + * - K0721: Knowledge of environmental factors affecting communications + * - S0807: Skill in making operational decisions under uncertainty + * - S0575: Skill in documenting operational procedures + * + * Supporting Codes: + * - S0421: Skill in operating network equipment + * - T0431: Check system hardware availability, functionality, integrity + * - T0129: Coordinate and manage system operations schedules + * - T0153: Monitor network capacity and performance + * - K0740: Knowledge of system performance indicators + * - S0478: Skill in communicating with customers + * - K0689: Knowledge of network systems management + * - K0645: Knowledge of standard operating procedures + */ + +/** MERIDIAN-SAR-1 (61701) at the S14 epoch: the 30 deg ride-out over Galway. */ +const SAR1_S14_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27092.45833333 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61701 97.2000 18.0000 0010000 90.0000 131.7500 15.60000000123458' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S14 epoch: 34 deg over Shetland, 19 deg over Galway. */ +const SAR2_S14_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27092.45833333 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 61702 98.4000 173.0000 0010000 90.0000 329.2500 15.60000000123458' as TleLine2, +}; + +const meridianSar1S14 = createMeridianSar1(SAR1_S14_TLE); +const meridianSar2S14 = createMeridianSar2(SAR2_S14_TLE); + +/** Both sites as the night shift left them. Deep clones, never spread. */ +const galwayInTheRain: GroundStationConfig = structuredClone(galwayGroundStation); +const shetlandWithSky: GroundStationConfig = structuredClone(shetlandGroundStation); + +export const natsEuScenario14Data: ScenarioData = { + id: 'nats-eu-scenario14', + url: 'nats-eu/scenarios/nats-eu-scenario14', + imageUrl: 'nats/14/card.png', + number: 14, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario13'], + title: 'Atlantic Low', + subtitle: 'Weather Reallocation Judgment', + duration: '40 min', + missionType: 'Weather Operations', + description: `11:00 local. A deep low is crossing Ireland: heavy rain on Galway now, turning to sleet by half past, clearing by early afternoon. Shetland has sky until two.

Erik's priority-1 MERIDIAN-SAR-2 collect rises at 11:12 into the worst of it, on a 19 degree pass with no margin to lose. A priority-2 SAR-1 window at 11:45 sits behind the front, in the sleet.

Read the fade, move the collect to Shetland and fly it from there while Galway watches its own pass drown. Then decide whether the 11:45 pass is a ride-out or a hand-over, protect the feed before the sleet arrives, and write down the rule you used.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker (feed heater)', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'Contact Plan Console', + 'Ku-Band RF Front End (GPSDO / LNB / BUC / HPA)', + 'RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayInTheRain, shetlandWithSky], + satellites: [meridianSar1S14, meridianSar2S14], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-02', + scenarioStartWallTime: '11:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-14?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // Twenty minutes between the collect (LOS 11:22) and the ride-out (AOS + // 11:45). The skip stops 2 min before the next pass at either site and is + // blocked while a timed objective runs; mission-elapsed advances with it, + // so the sleet and the ice curve stay aligned with the sky. + timeSkip: { + leadTimeS: 120, + minSkipS: 300, + horizonHours: 2, + }, + + workingDocument: { + title: 'GW-01 Weather Log', + description: '2027-04-02, Atlantic low. One line per contact with the weather decision that was made, and the standing rule.', + }, + + // E5 - real Ku rain fade at Galway for the collect, then sleet (ice on the + // feed) for the ride-out. Rain ramps in and out over 15 % of the event; + // ice builds while the heater is off and melts at 1 dB/min once it is on. + weatherEvents: [ + { id: 'gw-atlantic-rain', groundStationId: 'GW-01', type: 'rain', severity: 'severe', startTime: 60, duration: 1500, linkMarginDegradation: 7, rainRateMmPerHour: 30 }, + { id: 'gw-atlantic-sleet', groundStationId: 'GW-01', type: 'hail', severity: 'severe', startTime: 1560, duration: 2700, linkMarginDegradation: 10 }, + ], + + // M3 - both windows on both sites. Every P1/P2 contact has to be assigned + // for the plan to validate; the objectives force the collect onto SH-02 + // and the ride-out onto GW-01. Windows are 0 deg horizon crossings. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 2, + contacts: [ + { + id: 'W-SAR2-SH', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Shetland, 34 deg) - NMW priority collect', + priority: 1, + windowStartS: 640, + windowEndS: 1230, + stationId: 'SH-02', + }, + { + id: 'W-SAR2-GW', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Galway, 19 deg) - NMW priority collect', + priority: 1, + windowStartS: 770, + windowEndS: 1320, + stationId: 'GW-01', + }, + { id: 'W-SAR1-GW', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Galway, 30 deg)', priority: 2, windowStartS: 2700, windowEndS: 3270, stationId: 'GW-01' }, + { id: 'W-SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 75 deg)', priority: 2, windowStartS: 2760, windowEndS: 3360, stationId: 'SH-02' }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0721'], + title: 'Read the Weather Brief', + description: 'Open the brief: the front, its timing over both sites, the two windows and what each is worth.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Weather Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Fade Magnitude Read', + params: { + character: Character.SYSTEM, + question: 'A 30 mm/h cell at 11.7 GHz on a 19 degree pass. Order of magnitude of the fade?', + options: [ + 'Several dB - about 7 on this geometry, against a link that peaks near 8.5 dB clear; the collect will not close from Galway', + 'A fraction of a dB - as it was at C-band in Vermont, against a link that peaks near 8.5 dB clear; the collect closes', + 'About 1 dB - rain only matters above 20 GHz, against a link that peaks near 8.5 dB clear; the collect closes with margin', + 'Total - rain blocks Ku entirely at 30 mm/h, whatever the link peaks at clear; the collect will not close from anywhere', + ], + correctIndex: 0, + explanation: + 'And that is before the rain also warms the sky. Rain attenuation rises steeply with frequency: a tenth of a dB at 4 GHz becomes whole decibels at 12 GHz, and a low pass drags the beam through more of the rain. This is the single most Ku-specific number this campaign teaches. Twelve minutes to the collect. Shift clock started.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: READ THE FADE, COLD CHAIN, THE DECISION + // ============================================================ + { + id: 'read-the-fade-on-the-board', + nice: ['K0721', 'T0153', 'K0740'], + title: 'Read the Fade on the Board', + description: 'Dashboard: the RAIN FADE alarm on GW-01 is climbing with the rain rate. Read it, and say what the feed heater can do about it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Heater vs Rain', + params: { + character: Character.SYSTEM, + question: 'RAIN FADE on GW-01, rising. Does the feed heater help?', + options: [ + 'No. Rain attenuation is in the air along the whole slant path; the heater melts ice on the feed horn', + 'Yes. A warm feed sheds water as fast as it arrives; the heater recovers most of the fade on the horn', + 'Yes. The heater raises the LNB temperature and its gain with it; the extra gain buys back the fade', + 'Partly. The heater dries the feed side of the path; it recovers the uplink but not the downlink', + ], + correctIndex: 0, + explanation: + 'The heater is the answer to the sleet behind the front, not to the rain in it. Two different losses with two different fixes. Rain: reallocate or ride through on margin. Ice: heater, early. Knowing which one you are looking at is the shift.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'galway-cold-chain', + nice: ['T0431', 'K0740', 'S0421'], + title: 'GW-01 Cold Chain', + description: 'Receive-only day at Galway: GPSDO locked, BUC muted, HPA output disabled. Nothing goes up through a rain cell.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-fade-on-the-board'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GW-01 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'GW-01 BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'GW-01 HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reallocate-the-collect', + nice: ['K0721', 'K0689', 'T0129'], + title: 'Reallocate the Collect', + description: 'The P1 SAR-2 collect cannot be worked from Galway in this cell. Give the Shetland window to SH-02, assign the rest, and keep the plan DECONFLICTED.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-cold-chain'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-2 Collect on SH-02', + params: { contactId: 'W-SAR2-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Reallocation Justified', + params: { + character: Character.SYSTEM, + question: 'Why does the P1 collect move, when the P2 window at 11:45 might not?', + options: [ + 'A P1 collect gets the site that can close it: 34 degrees of dry sky at Shetland against 19 degrees under 7 dB of rain', + 'Shetland is the primary site for SAR-2: 34 degrees there against 19 at Galway, and the plan defaults the P1 to the higher pass', + 'The plan will not validate with both SAR-2 windows on Galway: the P1 moves because DECONFLICTED needs one per site', + 'It should not move: Galway is the customer site, and 7 dB of rain at 19 degrees may ease before the 11:12 rise', + ], + correctIndex: 0, + explanation: + 'Not a judgment call. The 11:45 window is behind the front, on better geometry, and can be decided on measured margin. Reallocate what the weather has already decided. Keep the decisions the weather has left you.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'arm-galway-to-watch-the-fade', + nice: ['S0421', 'K0721', 'K0740'], + title: 'Arm Galway to Watch the Fade', + description: + 'Galway flies its own SAR-2 pass anyway, unattended, so you can measure what the rain does. Modem 1 to 1370 MHz and program-track with MERIDIAN-SAR-2 as the target before you leave for Shetland.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reallocate-the-collect'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Armed', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 5 -> BEAT 1: SHETLAND, THE DRY SITE + // ============================================================ + { + id: 'shetland-weather-sweep', + nice: ['T0431', 'S0421', 'K0773'], + title: 'SH-02 Weather Sweep', + description: + 'Select SH-02. Reference locked, LNB stable, and retune modem 1 from 1414 MHz to the 1370 MHz SAR-2 carrier with the analyzer centred on 1370 MHz, 2 MHz span. AOS 11:10 from azimuth 023.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['arm-galway-to-watch-the-fade'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'gpsdo-locked', + description: 'SH-02 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'lnb-thermally-stable', + description: 'SH-02 LNB Thermally Stable', + params: { requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'SH-02 Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'SH-02 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-shetland-for-sar2', + nice: ['S0421', 'K1032'], + title: 'Pre-position Shetland for SAR-2', + description: 'Park the SH-02 tracker on the AOS azimuth, 23 degrees at 5 degrees elevation.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-weather-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'antenna-position', + description: 'SH-02 Az 23 / El 5', + params: { azimuth: 23, elevation: 5, tolerance: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: THE COLLECT FROM SHETLAND, THE FADE AT GALWAY + // ============================================================ + { + id: 'acquire-sar2-from-shetland', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-2 from Shetland', + description: 'AOS 11:10:42. Program-track MERIDIAN-SAR-2 on the SH-02 pedestal and confirm the imagery carrier on the Shetland RX analysis.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['preposition-shetland-for-sar2'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink on the Shetland Analyzer', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-the-collect-from-shetland', + nice: ['T0153', 'K0740', 'S0478'], + title: 'Decode the Collect from Shetland', + description: + 'Lock the 1370 MHz carrier on the SH-02 modem and hold C/N above 8 dB through culmination (11:15:35, 34 degrees, 659 km). This is the collect Erik is waiting on.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['acquire-sar2-from-shetland'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Customer Loop Closed', + params: { + character: Character.SYSTEM, + question: 'The collect is decoding at Shetland with margin. What closes the loop with Erik?', + options: [ + 'That the collect is captured, which site it came from, and that the decision cost him nothing he cares about', + 'The C/N at culmination, the rain rate at Galway, and the fade arithmetic, so he can see for himself why it moved', + 'Nothing yet, not the site and not the number, until Rotterdam has confirmed the frames and released them to him', + 'An apology for the site change, the reason it moved, and a promise that tomorrow comes from the dish he was told', + ], + correctIndex: 0, + explanation: 'Not the rain rate, not the decibels. The customer bought a collect, not a dish. The numbers go in the weather log for the people who read numbers.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'observe-the-galway-fade', + nice: ['K0721', 'T0153', 'K0740'], + title: 'Observe the Galway Fade', + description: + 'Select GW-01 while its own SAR-2 pass is still up (until 11:22). The pedestal is program-track locked on the bird; read the C/N the rain has left it and do the arithmetic.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-the-collect-from-shetland'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-2 Through the Rain', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N At or Below 5 dB (rain fade)', + params: { modemNumber: 1, maxCNRatio: 5, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Fade Measured', + params: { + character: Character.SYSTEM, + question: 'Galway, 19 degrees, 30 mm/h: the same bird Shetland is decoding at 11 dB reads a couple of dB here, on a clean lock of the pedestal. Where did the rest go?', + options: [ + 'Rain: about 7 dB of attenuation along the slant path through the cell, plus a warmer sky raising the noise temperature', + 'Pointing: rain on the reflector defocuses the beam, and at 19 degrees the pedestal is chasing a low, fast target', + 'The LNB: water in the feed has raised its noise figure, and with the heater off the horn has been filling since 11:01', + 'Doppler: the demodulator lost the carrier in the rain, and at 19 degrees the range rate is near its highest of the pass', + ], + correctIndex: 0, + explanation: + 'The clear-sky number for this geometry was only about 8.5 dB to begin with, and the slant path through the cell is 7.7 km. ITU-R P.838: specific attenuation goes as k times rain rate to the power alpha, and at 12 GHz k is a hundred times what it was at 4 GHz. A low pass makes it worse twice: longer path through the rain, and less clear-sky margin to spend.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4/5: LOG, THE FRONT TURNS, THE JUDGMENT + // ============================================================ + { + id: 'log-the-collect', + nice: ['S0575', 'K0645', 'T1580'], + title: 'Log the Collect', + description: 'Write the collect into the weather log with the decision that made it: which site, why, and what Galway measured in the rain.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['observe-the-galway-fade'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Collect Logged', + params: { + character: Character.SYSTEM, + question: 'What does the weather log need for the 11:12 collect?', + options: [ + 'Collect worked from SH-02 at 34 degrees with margin; Galway flown for the record and faded to about 2 dB; P1 moved before AOS', + 'Collect worked from SH-02 at 34 degrees with margin; Galway faded out in 30 mm/h; P1 moved after the Galway attempt failed', + 'Collect worked from SH-02 at 34 degrees with margin; the C/N at Shetland and nothing about Galway, since it was not the site', + 'Rain at Galway, collect moved to SH-02; Shetland decoded at 34 degrees with margin; nothing further needed on the line', + ], + correctIndex: 0, + explanation: + 'The reallocation came before AOS, not after a failed attempt, and Galway faded in 30 mm/h at 19 degrees. The measurement Galway made in the rain is the evidence the reallocation rule stands on next time. A decision without its number is an opinion.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '11:10Z MERIDIAN-SAR-2 (NMW P1 collect): reallocated to SH-02 before AOS, decoded at 34 deg with margin. GW-01 window flown unattended for the record: 30 mm/h rain, 19 deg, C/N faded to ~2 dB (about 7 dB of rain attenuation). Customer informed: collect captured.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'protect-the-feed', + nice: ['S0421', 'T0431', 'K0721'], + title: 'Protect the Feed', + description: 'The rain turns to sleet at 11:26. Feed heater on at GW-01 now, before the first flake, not at the first fade. Every minute it is off costs a decibel later.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-collect'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'feed-heater-enabled', + description: 'GW-01 Feed Heater Enabled', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Heater Timing', + params: { + character: Character.SYSTEM, + question: + 'Heater on at 11:24, sleet from 11:26, pass at 11:45. What does the feed carry at culmination, and what would it carry if the heater had waited for the first fade?', + options: [ + 'On before the sleet: nothing accumulates, the pass sees ~11 dB. Off until the fade: about 8.6 dB of ice, melting at 1 dB a minute', + 'The same either way: the heater clears ice in seconds, so the pass sees ~11 dB. Off until the fade: a minute of warm-up, then lock', + 'On early: the feed runs hot and adds noise, so the pass sees under 11 dB. Off until the fade: ice shows, heater on, no loss', + 'It does not matter: sleet does not stick to a Ku feed, so the pass sees ~11 dB. Off until the fade: the same ~11 dB, nothing to melt', + ], + correctIndex: 0, + explanation: + 'Ice builds with a 12 minute time constant and melts at 1 dB per minute; with the heater off until 11:45 there is no lock until the 8.6 dB is gone. The heater is a decision made before the loss, because after it the pass is already gone.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decide-the-ride-out', + nice: ['S0807', 'K0721', 'T0129'], + title: 'Decide the Ride-Out', + description: 'The P2 SAR-1 window at 11:45: keep it at Galway or hand it to Shetland too? Decide on measured margin, then confirm the plan carries your decision.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['protect-the-feed'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'contact-assigned', + description: 'SAR-1 11:45 Window on GW-01', + params: { contactId: 'W-SAR1-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Ride-Out Decided', + params: { + character: Character.SYSTEM, + question: 'Rain clearing by 11:26, heater on, sleet only. 30 degree pass, clear-sky ~11 dB, delivery standard 8. What is the 11:45 call?', + options: [ + 'Keep it at Galway: with the heater on the feed carries no ice, and 3 dB of margin covers what is left of the front', + 'Hand it to Shetland: 75 degrees there beats 30 degrees anywhere, and the extra elevation is margin you never have to measure', + 'Hand it to Shetland: a site that faded out an hour ago cannot be trusted, and 3 dB of margin is not enough to bet a window on', + 'Drop it: a P2 window is not worth the risk in sleet, and Shetland has the 75 degree pass if the customer asks for it', + ], + correctIndex: 0, + explanation: + 'Handing every window to Shetland loses capacity you have; if the heater had stayed off, the answer flips. The rain decided the P1. The P2 is yours to decide, and the number that decides it is the feed loss you can read on the ACU, not the weather you can see out of the window.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'retune-galway-for-sar1', + nice: ['S0421', 'K0773'], + title: 'Retune Galway for SAR-1', + description: + 'Modem 1 back to the 1414 MHz SAR-1 carrier, analyzer centred on the 1389 MHz SAR-1 beacon. The tracker is wherever the SAR-2 pass left it; program-track slews to the 11:45 rise at azimuth 142. Then skip ahead.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decide-the-ride-out'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: THE RIDE-OUT + // ============================================================ + { + id: 'ride-it-out', + nice: ['T0153', 'S0421', 'K0740'], + title: 'Ride It Out', + description: + 'AOS 11:44:59 from azimuth 142, in the sleet, on a clean feed. Program-track MERIDIAN-SAR-1, lock the 1414 MHz carrier and hold C/N above 8 dB through culmination (11:49:43, 30 degrees, 732 km).', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retune-galway-for-sar1'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + + // ============================================================ + // BEAT 4: LOG AND CLOSE + // ============================================================ + { + id: 'log-the-ride-out', + nice: ['S0575', 'K0721', 'K0740'], + title: 'Log the Ride-Out', + description: 'Write the 11:45 pass into the weather log with the feed loss it was flown on and the number it would have been flown on.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['ride-it-out'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Ride-Out Logged', + params: { + character: Character.SYSTEM, + question: 'The ACU feed-loss reading stayed at zero through the pass with the heater on since 11:24. What goes in the log beside the decode?', + options: [ + 'Decoded at ~11 dB on zero feed loss, heater on before the sleet; off, the feed would have carried about 8.6 dB at culmination', + 'Decoded at ~11 dB, heater on from 11:24; the sleet never reached the horn, so the heater made no difference to the pass', + 'Decoded at ~11 dB, the weather not as bad as forecast; the sleet cleared before culmination and the feed stayed dry', + 'Decoded at ~11 dB, which proves the heater was unnecessary; a clean feed at zero loss means there was no ice to melt', + ], + correctIndex: 0, + explanation: + 'With the heater off the pass would have been lost; the ride-out was the right call on the margin measured, not on the forecast. A pass that went well on a decision is only evidence if the log says what the decision was and what it avoided.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '11:45Z MERIDIAN-SAR-1 GW-01 (P2): ride-out in sleet, feed heater on from 11:24 (before onset 11:26), feed loss 0 dB, decoded at 30 deg, peak C/N ~11 dB. Heater off would have carried ~8.6 dB of ice at culmination: no lock.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'close-the-weather-log', + nice: ['K0721', 'S0575', 'K0645'], + title: 'Close the Weather Log', + description: 'Write the standing rule down so the next operator does not have to rediscover it in the next front.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-ride-out'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Standing Rule Written', + params: { + character: Character.SYSTEM, + question: 'What is the standing weather rule you write down?', + options: [ + 'Heater on at the first precipitation warning; P1 windows in a rain cell reallocated before AOS; P2 ridden out on measured margin', + 'Heater on whenever the forecast says rain; every window handed to the site with the better weather; nothing ridden out at all', + 'Heater on at the first fade the ACU shows; P1 windows reallocated after a failed attempt; P2 ridden out on the forecast', + 'Heater on whenever the forecast says sleet; never reallocate, since the customer was promised a site; P2 ridden out on the forecast', + ], + correctIndex: 0, + explanation: + 'Not at the first fade, and never on the forecast alone. Three sentences, three different losses: ice (prevent it), rain (route around it), margin (measure it). That is the whole of Ku weather operations.', + pointPenalty: 5, + documentSection: 'Standing Rule', + documentLine: + 'Standing rule: feed heater ON at the first precipitation warning, not the first fade. P1 windows inside a rain cell are reallocated before AOS. P2 windows are ridden out on measured feed loss and link margin, never on the forecast alone.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Fiona MacLeod at 10:52] +

+

+ "You've got the ugly end of it. Radar has the cell on Galway till half eleven and then it goes to sleet behind. I've got sky till two and my heater's been on since dawn out of habit. Erik's collect rises at ten past for me, twelve past for you. Say the word and it's mine." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'reallocate-the-collect': { + text: ` +

+ I do not care which dish. I care about the 11:12 collect. If Shetland gets it, Shetland gets it - just tell me before it rises, not after. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'decode-the-collect-from-shetland': { + text: ` +

+ Got the frames. Vessel is where I thought it was. Same time tomorrow, whichever dish is dry. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'protect-the-feed': { + text: ` +

+ [Text message from Fiona MacLeod at 11:25] +

+

+ "Heater? Good. Mine's been on since dawn. Sleet's the one that gets you - it doesn't fade, it just sits on the horn and gets heavier. If you want the quarter-to pass I'd rather you had it; I've got the seventy-five if it goes wrong." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'ride-it-out': { + text: ` +

+ [Text message from Fiona MacLeod at 11:52] +

+

+ "Told you it'd clear. Watched your number from here - eleven on a clean horn in sleet. That's the heater, not the weather. Right call keeping it." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'close-the-weather-log': { + text: ` +

+ Two collects, two dishes, one front, and I never had to ask which. That is what I am paying for. Talk tomorrow. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario15.ts b/src/campaigns/nats-eu/scenario15.ts new file mode 100644 index 00000000..0d56f378 --- /dev/null +++ b/src/campaigns/nats-eu/scenario15.ts @@ -0,0 +1,1013 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 15 - "Rotation Day" / Fleet COMSEC Under Tempo + * + * Rotterdam has ordered the fleet key rotation for today, and it has to + * happen between passes: the retiring command key may not be used after the + * MERIDIAN-SAR-1 window, and the first command on the new key has to reach + * MERIDIAN-SAR-2 inside its window to prove it on orbit. Two keys move: the + * command key on the TT&C console (begin, then complete) and, staged on the + * mission clock (phase 16, E3), the fleet traffic key, which rolls at 07:27 + * and leaves the payload crypto reading Mismatch until the operator re-keys + * before the SAR-2 pass. Receive on the old key, rotate both, prove both - + * the decode under the new traffic key cold, then chain up in order and + * KEY-VERIFY under the new command key - and safe down. + * + * Clock starts 2027-04-06 07:00:00 UTC. Passes (0 deg horizon, per station, + * scripts/author-passes.mjs): + * + * SH-02 MERIDIAN-SAR-1: AOS T+13.90 (07:13:53Z), max el 50.6 deg, LOS + * T+23.78 (Fiona's). + * GW-01 MERIDIAN-SAR-1: AOS T+15.98 (07:15:59Z, az 022), max el 27.0 deg at + * T+20.78 (07:20:47Z, 782 km), LOS T+25.52 (07:25:31Z, + * az 170), 9.5 min, southbound. Receive only. + * GW-01 MERIDIAN-SAR-2: AOS T+33.97 (07:33:58Z, az 190), max el 24.9 deg at + * T+38.67 (07:38:40Z, 829 km), LOS T+43.42 (07:43:25Z, + * az 335), 9.5 min, northbound. The proof pass. + * SH-02 MERIDIAN-SAR-2: AOS T+36.26, max el 11.4 deg, LOS T+44.57 (Fiona's + * telemetry contact). + * + * Eight and a half minutes between SAR-1 LOS and SAR-2 AOS for the command + * rotation, the traffic re-key and the retune. Command window is the SAR-2 + * pass with 20 s guards. All thresholds flown in + * test/campaigns/nats-eu-phase-c-validation.test.ts. + * + * Staged state (scenario-local clone): GW-01 RX modem 1 on the 1370 MHz SAR-2 + * carrier from last night's contact; TX modem 1 on 1405 MHz (SAR-1 uplink), + * to be retuned to 1435 MHz for SAR-2. SH-02 is the shared default. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0876: Knowledge of cryptographic key management concepts + * - S0844: Skill in reviewing logs to identify evidence + * - S0593: Skill in verifying system integrity + * + * Supporting Codes: + * - K0874: Knowledge of cryptographic key management + * - S0077: Skill in securing network communications + * - K0689: Knowledge of network systems management + * - T0129: Coordinate and manage system operations schedules + * - T0153: Monitor network capacity and performance + * - T1567: Conduct satellite command and control operations + * - K0645: Knowledge of standard operating procedures + * - K0728: Knowledge of cryptographic key storage and handling + */ + +/** MERIDIAN-SAR-1 (61701) at the S15 epoch: the 27 deg receive pass over Galway. */ +const SAR1_S15_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27096.29166667 .00001000 00000-0 10000-3 0 9992' as TleLine1, + tle2: '2 61701 97.2000 115.7500 0010000 90.0000 316.2500 15.60000000123458' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S15 epoch: the 25 deg proof pass over Galway. */ +const SAR2_S15_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27096.29166667 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61702 98.4000 300.5000 0010000 90.0000 172.0000 15.60000000123454' as TleLine2, +}; + +const meridianSar1S15 = createMeridianSar1(SAR1_S15_TLE); +const meridianSar2S15 = createMeridianSar2(SAR2_S15_TLE); + +/** GW-01 as the night shift left it: modem on SAR-2. Deep clone, never spread. */ +const galwayRotationDay: GroundStationConfig = structuredClone(galwayGroundStation); +galwayRotationDay.receivers![0].modems![0] = { + ...galwayRotationDay.receivers![0].modems![0], + frequency: 1370 as MHz, +}; + +export const natsEuScenario15Data: ScenarioData = { + id: 'nats-eu-scenario15', + url: 'nats-eu/scenarios/nats-eu-scenario15', + imageUrl: 'nats/15/card.png', + number: 15, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario14'], + title: 'Rotation Day', + subtitle: 'Fleet COMSEC Under Tempo', + duration: '40 min', + missionType: 'COMSEC Operations', + description: `07:00 local. Rotterdam has ordered the fleet key rotation for today, and it happens between passes or it does not happen.

The rule is in the audit log: no command on the retiring key after the 07:16 MERIDIAN-SAR-1 window, and the first command on the new key has to reach MERIDIAN-SAR-2 inside its 07:34 window to prove it on orbit. The traffic key rolls on the fleet schedule at 07:27, in the gap, and the station will not follow it on its own.

Acknowledge the order, build the day plan, receive SAR-1 on the old keys, rotate both keys in the gap, prove both on SAR-2, safe down, and close the record with evidence rather than a story.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'TT&C Commanding Console (command key)', + 'Payload Crypto (traffic key, AES-256-GCM)', + 'Security Console (audit log)', + 'Contact Plan Console', + ], + settings: { + isSync: true, + groundStations: [galwayRotationDay, shetlandGroundStation], + satellites: [meridianSar1S15, meridianSar2S15], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-06', + scenarioStartWallTime: '07:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-15?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // The skip stops 2 min before the next pass at either site. The rotation + // gap between SAR-1 LOS and SAR-2 AOS is 8.5 min of real work, so no skip + // is wanted there; leadTime and the Shetland windows keep it short. + timeSkip: { + leadTimeS: 120, + minSkipS: 300, + horizonHours: 1, + }, + + workingDocument: { + title: 'Fleet Key Rotation Record', + description: 'KEY-2027-Q1 retired, KEY-2027-Q2 activated, 2027-04-06. Evidence for each step, in order.', + }, + + // M3 - four contacts, two sites, no double-booking. The command contact + // must be Galway's because the TT&C console is Galway's. Windows are + // 0 deg horizon crossings, per station. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 2, + contacts: [ + { id: 'R-SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 51 deg)', priority: 2, windowStartS: 830, windowEndS: 1430, stationId: 'SH-02' }, + { + id: 'R-SAR1-GW', + satelliteNoradId: 61701, + label: 'MERIDIAN-SAR-1 (Galway, 27 deg) - receive on retiring key', + priority: 1, + windowStartS: 960, + windowEndS: 1530, + stationId: 'GW-01', + }, + { + id: 'R-SAR2-GW', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Galway, 25 deg) - KEY-VERIFY on new key', + priority: 1, + windowStartS: 2040, + windowEndS: 2610, + stationId: 'GW-01', + }, + { id: 'R-SAR2-SH', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Shetland, 11 deg)', priority: 2, windowStartS: 2180, windowEndS: 2670, stationId: 'SH-02' }, + ], + }, + + // M2/M5 - the proof. Window is the SAR-2 pass: AOS 07:33:58 (2038 s) + 20 s + // to LOS 07:43:25 (2605 s) - 20 s. Doppler comp and a Valid command key + // are both required for an ACK; a command sent during Pending Rotation is + // rejected and shows in the console history. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61702, + windowStartS: 2060, + windowEndS: 2585, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'KEY-VERIFY', label: 'Verify command-link key' }, + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + + // M6 - the order is in the audit log, with the key material load behind it. + security: { + accounts: [ + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'op-anneke', name: 'A. Visser (Constellation Ops, remote)', role: 'Constellation Operations', status: 'active' }, + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + ], + events: [ + { id: 'evt-night-logout', timeS: 0, timestampLabel: '05:12 UTC', actor: 'op-charlie', action: 'Console logout (night shift end)', category: 'auth', severity: 'info' }, + { + id: 'evt-rotation-order', + timeS: 0, + timestampLabel: '05:40 UTC', + actor: 'op-anneke', + action: 'Fleet key rotation ordered: retire KEY-2027-Q1 after SAR-1 window, activate KEY-2027-Q2 before SAR-2 window; traffic key rolls 07:27 UTC', + category: 'command', + severity: 'warning', + }, + { + id: 'evt-key-load', + timeS: 0, + timestampLabel: '06:15 UTC', + actor: 'op-anneke', + action: 'KEY-2027-Q2 material loaded to GW-01 COMSEC (inactive)', + category: 'command', + severity: 'info', + }, + { id: 'evt-svc-poll', timeS: 0, timestampLabel: '06:30 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + { id: 'evt-login-fiona', timeS: 0, timestampLabel: '06:48 UTC', actor: 'op-fiona', action: 'Console login (SH-02)', category: 'auth', severity: 'info' }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '06:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + ], + }, + + // E3 - the fleet traffic key rolls at 07:27 on the mission clock, in the + // gap after SAR-1 LOS. The payload crypto reads Mismatch until the + // operator re-keys it on the TX chain payload panel. + hardwareFaultEvents: [ + { id: 'traffic-key-roll', groundStationId: 'GW-01', target: 'crypto-key-mismatch', startTime: 1620, label: 'Fleet traffic key rotation executed 07:27Z (Rotterdam)' }, + ], + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0876'], + title: 'Read the Rotation Order', + description: 'Open the brief: the order, the two keys, the two windows, and what is forbidden between retire and activate.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Rotation Order Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Forbidden State Read', + params: { + character: Character.SYSTEM, + question: 'What is forbidden between "retire" and "activate"?', + options: [ + 'Any command: a key in rotation is not a valid key, and a rejected command in the window is time you do not get back', + 'Receiving: the downlink cannot be decoded while the key rotates, and a decode attempted in the gap corrupts the frames', + 'Tracking: the pedestal must be parked during a rotation, and a program-track slew in the gap is logged against you', + 'Nothing: the console queues commands and sends them when the key is valid, so the gap costs nothing but a delay', + ], + correctIndex: 0, + explanation: + 'The spacecraft rejects whatever is sent on a key in rotation. Rotation is a state, not a moment. Begin it after the last command on the old key, complete it before the first on the new one, and send nothing in between. Sixteen minutes to SAR-1. Shift clock started.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1 / BEAT 5: THE ORDER, THE PLAN, THE STATION + // ============================================================ + { + id: 'acknowledge-the-order', + nice: ['K0876', 'S0844'], + title: 'Acknowledge the Order', + description: 'Security console: read the audit trail, sign it off, and flag the rotation order so the record shows who saw it and when.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Security Console Open', + params: { tab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'audit-log-reviewed', + description: 'Audit Log Reviewed', + params: {}, + mustMaintain: false, + }, + { + type: 'security-event-acknowledged', + description: 'Rotation Order Acknowledged', + params: { eventId: 'evt-rotation-order' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'build-the-day-plan', + nice: ['K0689', 'T0129'], + title: 'Build the Day Plan', + description: 'Four contacts, two sites, no double-booking. Both Galway halves are yours; the command contact cannot be anywhere else.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acknowledge-the-order'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-1 07:16 Window on GW-01', + params: { contactId: 'R-SAR1-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-2 07:34 Window on GW-01', + params: { contactId: 'R-SAR2-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Command Contact Placed', + params: { + character: Character.SYSTEM, + question: 'Shetland sees SAR-2 at 11 degrees at the same time Galway sees it at 25. Why must the 07:34 contact be Galway’s?', + options: [ + 'The command originates from the TT&C console at GW-01: the key is loaded there, and a receive-only site cannot prove it', + '25 degrees is better than 11: the higher pass carries the margin a command ACK needs, and Shetland could not hold the lock', + 'The plan will not validate with two contacts on Shetland: the board is one site per window, and Galway is the free one', + 'It need not be: either site can send KEY-VERIFY, and the plan puts the contact on Galway only because 25 beats 11', + ], + correctIndex: 0, + explanation: + 'Fiona takes the telemetry contact from her side. A plan allocates receive windows. Commanding is a station capability, and only one station on this network has the key.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'galway-reference-and-keys', + nice: ['T0431', 'K0874', 'K0728'], + title: 'GW-01 Reference and Keys', + description: 'GPSDO locked, then both keys on record before anything moves: command key Valid on the TX chain, traffic key Valid on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['build-the-day-plan'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GW-01 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'tx-key-status', + description: 'TX Key Valid (pre-rotation)', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'rx-key-status', + description: 'RX Traffic Key Valid (pre-rotation)', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Two Keys Read', + params: { + character: Character.SYSTEM, + question: 'Two keys read Valid. Which is which, and who moves each one today?', + options: [ + 'Command key: uplink, rotated by you on the TT&C console in the gap. Traffic key: downlink, rolled by Rotterdam at 07:27', + 'Command key: uplink, and it is the traffic key shown twice. One key, rotated by you on the TT&C console in the gap', + 'Command key: uplink, rotated by you on the TT&C console. Traffic key: downlink, rotated by you with the same two buttons', + 'Command key: uplink, rolled by Rotterdam on the fleet schedule. Traffic key: downlink, the same; the station follows both', + ], + correctIndex: 0, + explanation: + 'The command key authenticates what goes up; the traffic key decrypts what comes down, and you follow its roll by re-keying the payload crypto. Two keys, two custodians, two schedules. S8 taught you the one you did not rotate is the one that moves.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'tune-for-sar1', + nice: ['S0421', 'K0773'], + title: 'Tune for SAR-1', + description: + 'The night shift left modem 1 on 1370 MHz. Retune to the 1414 MHz SAR-1 carrier, analyzer centred on the 1389 MHz beacon with a 2 MHz span, tracker parked on the 07:16 AOS azimuth: 22 degrees at 5 degrees elevation.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-reference-and-keys'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'GW-01 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'antenna-position', + description: 'Az 22 / El 5', + params: { azimuth: 22, elevation: 5, tolerance: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-4: SAR-1 ON THE RETIRING KEY + // ============================================================ + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'AOS 07:16 from azimuth 22. Program-track MERIDIAN-SAR-1 and confirm the beacon on RX analysis. Chain cold: nothing goes up on this pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-for-sar1'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'receive-on-the-retiring-key', + nice: ['T0153', 'K0740', 'K0876'], + title: 'Receive on the Retiring Key', + description: 'Lock the 1414 MHz carrier, hold C/N above 8 dB through culmination (07:20:47, 27 degrees, 782 km) and confirm the payload decrypts. Do not command.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'rx-crypto-status', + description: 'Payload Decrypting on Q1', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Receive-Only Understood', + params: { + character: Character.SYSTEM, + question: 'The command key is still Valid and the bird is up. Why not send REC-PLAYBACK now and get it out of the way?', + options: [ + 'The order retires KEY-2027-Q1 after this window; a command on it now is a command on a key about to die, and may be rejected', + 'The TT&C console is not configured for SAR-1 today; the TX modem is still on 1405 MHz, and a command on it goes nowhere', + 'The traffic key would reject it; REC-PLAYBACK is a payload command, and the payload crypto rolls at 07:27, before it could act', + 'No reason at all; a Valid key is a Valid key, and REC-PLAYBACK now saves a command on the SAR-2 window, which is the busy one', + ], + correctIndex: 0, + explanation: + 'If the rotation on the spacecraft side has already begun, the command is rejected and logged against you. The window is receive-only by order, not by capability. A key state on your console is a promise about the far end. On rotation day the promise expires at a time, not at a button.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'log-the-receive-pass', + nice: ['K0645', 'S0575'], + title: 'Log the Receive Pass', + description: 'LOS 07:25:31. One line into the rotation record: the last pass on Q1, decoded, no commands sent.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['receive-on-the-retiring-key'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Receive Pass Logged', + params: { + character: Character.SYSTEM, + question: 'What does the rotation record need for the 07:16 pass?', + options: [ + 'Last contact on KEY-2027-Q1: decoded, payload decrypting, zero commands sent; the absence of a command is the record', + 'Pass worked on KEY-2027-Q1: decoded at 27 degrees, C/N above 8 dB; the rotation record starts at the rotation itself', + 'The C/N at culmination on KEY-2027-Q1: 27 degrees, 782 km, decoded; the number is the record, the key is a detail', + 'Nothing for this pass: the rotation record starts at Begin on the TT&C console; a receive pass has no COMSEC content', + ], + correctIndex: 0, + explanation: + 'The record needs the absence of a command as much as the presence of one. The order says no command after this window. The evidence that you obeyed it is a line that says so, written before the key moves.', + pointPenalty: 5, + documentSection: 'Retire', + documentLine: '07:16Z MERIDIAN-SAR-1 GW-01: last contact on KEY-2027-Q1. Decoded at 27 deg, payload decrypting, zero commands sent. Q1 retired at LOS 07:25:31Z.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 5: THE GAP - TWO KEYS MOVE + // ============================================================ + { + id: 'rotate-the-command-key', + nice: ['K0876', 'K0874', 'S0077'], + title: 'Rotate the Command Key', + description: 'TT&C console: begin the rotation, then complete it. KEY-2027-Q2 must read Valid before the SAR-2 window opens at 07:34:18.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-receive-pass'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'TT&C Console Open', + params: { tab: 'commanding' }, + mustMaintain: false, + }, + { + type: 'key-rotation-completed', + description: 'Command Key Rotation Completed', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Rotation State Read', + params: { + character: Character.SYSTEM, + question: 'Between Begin and Complete the console read "Pending Rotation". What would a command sent in that state have done?', + options: [ + 'Been rejected as key-invalid and written into the command history with that reason: a rotation in progress is not a key', + 'Gone up on the old key, which is still loaded until Complete is pressed: the console sends on whatever key it holds', + 'Queued on the console until Complete and then gone up on the new key: the console holds commands through a rotation', + 'Gone up unauthenticated and been executed on the bird as plain text: the spacecraft accepts commands with no key at all', + ], + correctIndex: 0, + explanation: + 'The rejection is the failure mode this whole day is built to avoid. Two buttons, one state between them. The rotation is not a formality on a Monday morning; it is the interval in which the fleet has no key at all.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'read-the-traffic-key', + nice: ['K0728', 'K0874', 'S0844'], + title: 'Read the Traffic Key', + description: 'The fleet traffic key rolls at 07:27. Read the payload crypto on RX analysis after it does: the decryption key will not be what you left it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rotate-the-command-key'], + conditions: [ + { + type: 'rx-key-status', + description: 'RX Traffic Key Reads Mismatch', + params: { keyStatus: 'Mismatch', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Mismatch Diagnosed', + params: { + character: Character.SYSTEM, + question: 'Traffic key Mismatch on the payload crypto, command key Valid on Q2. Which key moved, and where is the fix?', + options: [ + 'Rotterdam rolled the fleet traffic key and the station did not follow; the fix is the re-key on the TX chain payload panel', + 'Your command rotation broke the traffic key on the console; the fix is to undo it on the TT&C console and rotate again', + 'The spacecraft zeroized its keys when the command key changed; the fix is a KEY-VERIFY under Q2 to reload them on orbit', + 'Nothing moved, Mismatch is the normal reading during a rotation window; the fix is to wait for the SAR-2 AOS and read again', + ], + correctIndex: 0, + explanation: + 'Not the TT&C console: the command key is a different key and is already done. Two keys, two custodians. The one Rotterdam moves is the one you follow; the one you move is the one Rotterdam verifies.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 're-key-the-payload-crypto', + nice: ['K0728', 'K0874', 'S0421'], + title: 'Re-key the Payload Crypto', + description: 'Load the new traffic key on the TX chain payload panel and confirm both directions read Valid before the SAR-2 window.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-traffic-key'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-key-status', + description: 'TX Traffic Key Valid (Q2)', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Traffic Key Valid (Q2)', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'tune-for-sar2-and-the-uplink', + nice: ['S0421', 'K0773', 'K1032'], + title: 'Tune for SAR-2 and the Uplink', + description: + 'Modem 1 to the 1370 MHz SAR-2 carrier, analyzer centred on 1370 MHz; TX modem 1 from 1405 MHz to the 1435 MHz SAR-2 telecommand IF (14035 MHz at the BUC). The tracker is wherever SAR-1 left it; program-track slews to the 07:34 rise at azimuth 190.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['re-key-the-payload-crypto'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'tx-modem-frequency-set', + description: 'GW-01 TX 1435 MHz', + params: { modemNumber: 1, frequency: 1435e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: PROVE BOTH KEYS ON SAR-2 + // ============================================================ + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'AOS 07:34 from azimuth 190. Program-track MERIDIAN-SAR-2 and confirm its beacon on RX analysis. Chain cold until the downlink is proven.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-for-sar2-and-the-uplink'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-2-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-on-the-new-traffic-key', + nice: ['K0728', 'T0153', 'K0740'], + title: 'Decode on the New Traffic Key', + description: + 'Lock the 1370 MHz carrier with the chain still cold, hold C/N above 8 dB and confirm the payload decrypts under Q2. That is the traffic key proven; an uplink on the rack would spoil this measurement, so it comes first.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'rx-crypto-status', + description: 'Payload Decrypting on Q2', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'chain-up-for-the-proof', + nice: ['S0421', 'T1567', 'K0645'], + title: 'Chain Up for the Proof', + description: 'Window open until 07:43:05. Uplink Doppler compensation on the TT&C console, then the chain in order: carrier into the muted BUC, unmute, HPA. Two minutes.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-on-the-new-traffic-key'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'prove-the-new-key-on-orbit', + nice: ['K0876', 'T1567', 'S0593'], + title: 'Prove the New Key on Orbit', + description: 'Send KEY-VERIFY under KEY-2027-Q2 and confirm the acknowledgement before the window closes at 07:43:05.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up-for-the-proof'], + conditions: [ + { + type: 'command-acknowledged', + description: 'KEY-VERIFY Acknowledged', + params: { commandId: 'KEY-VERIFY' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Proof Understood', + params: { + character: Character.SYSTEM, + question: 'KEY-VERIFY acknowledged. What exactly has been proven, and what has not?', + options: [ + 'That the spacecraft authenticated a command under Q2 inside the window; nothing about the traffic key or the other spacecraft', + 'That the whole fleet is on Q2 as of this ACK; nothing about the traffic key, which Rotterdam rolled and verifies on its own', + 'That both keys are live on this spacecraft, since one ACK covers the link; nothing about the other spacecraft in the fleet', + 'Only that the uplink reached the bird inside the window; nothing about either key, since the ACK does not check the key', + ], + correctIndex: 0, + explanation: + 'The command key is live on orbit. The traffic key was proved separately by the decode, and Rotterdam verifies the other spacecraft one by one. An ACK is proof of one thing on one bird. Rotation day is a list of them.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + + // ============================================================ + // BEAT 4: SAFE DOWN, CLOSE THE RECORD + // ============================================================ + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645'], + title: 'Safe the Uplink', + description: 'Proof in hand. Chain down in the mirror order: HPA output off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['prove-the-new-key-on-orbit'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'close-the-comsec-record', + nice: ['K0876', 'K0645', 'S0593'], + title: 'Close the COMSEC Record', + description: 'Anneke wants the rotation evidence. Write it as evidence.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Evidence Recorded', + params: { + character: Character.SYSTEM, + question: 'What is the rotation evidence?', + options: [ + 'The order acknowledged, the last Q1 contact with zero commands, the rotation completed, the re-key, and the ACK under Q2', + 'KEY-VERIFY acknowledged under Q2 inside the window, with the command history line beside it: the ACK is the evidence', + 'The console reading Valid on both keys, on the TX chain and on RX analysis, with the time each one turned Valid', + 'Anneke’s confirmation that the fleet is on Q2, countersigned in the audit log, with the 07:27 fleet notice attached', + ], + correctIndex: 0, + explanation: + 'Order in the audit log, last Q1 contact with zero commands, command rotation completed on the console, traffic re-key with both directions Valid, a decode under Q2, KEY-VERIFY acknowledged under Q2 inside the window. Any one alone is not proof. A rotation record is a chain: order, retire, rotate, follow, prove. Break any link and the fleet has a key nobody can vouch for.', + pointPenalty: 5, + documentSection: 'Activate', + documentLine: + '07:27Z fleet traffic key rolled (Rotterdam); GW-01 payload crypto re-keyed, TX/RX Valid on Q2. Command key rotation begun and completed on TT&C console before 07:34 window. 07:34Z MERIDIAN-SAR-2 GW-01: decoded under Q2 (traffic key proven cold), chain up in order, KEY-VERIFY ACK under KEY-2027-Q2 inside window, chain secured HPA-BUC-modem. Rotation order acknowledged in audit log 07:03Z. Fleet on Q2 at GW-01.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 05:41] +

+

+ "Rotation order is in your audit log. Old command key dies after your 07:16 SAR-1 window; new key proven on SAR-2 at 07:34 with KEY-VERIFY, inside the window. No commands in between. The traffic key rolls on the fleet schedule at 07:27, in your gap, and your station will not follow it on its own. No gaps, please. Confirm each step back to me." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'build-the-day-plan': { + text: ` +

+ [Text message from Fiona MacLeod at 07:03] +

+

+ "Give me the Shetland halves and I'll stay off the uplink. Fifty-one degrees on SAR-1 and an eleven-degree graze on SAR-2 - I'll take the telemetry off the second and leave the proving to you. Not touching a key today." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'rotate-the-command-key': { + text: ` +

+ Rotation complete on my side. Q2 is active in the fleet key store. Your window opens in a few minutes; your traffic key will have rolled by then, so look at it before you look at the sky. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'read-the-traffic-key': { + text: ` +

+ [Automated notice from MERIDIAN Ops at 07:27] +

+

+ "Fleet traffic key rotation executed 07:27:00Z per order. Sites: load new material before your next payload contact." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'prove-the-new-key-on-orbit': { + text: ` +

+ KEY-VERIFY acknowledged under Q2. SAR-2 is on the new key, and so is Galway. Two more sites to go and then the fleet is on Q2. Close your record; I will countersign it. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'close-the-comsec-record': { + text: ` +

+ That is an evidence chain, not a story. Order, retire, rotate, follow, prove. Thank you, Galway - same again next quarter. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario16.ts b/src/campaigns/nats-eu/scenario16.ts new file mode 100644 index 00000000..62c62d58 --- /dev/null +++ b/src/campaigns/nats-eu/scenario16.ts @@ -0,0 +1,1109 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, IfFrequency } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 16 - "Cascade" / Network Multi-Failure (Phase 2 capstone) + * + * Midday, the densest window of the week, three problems and one operator. + * MERIDIAN-SAR-2 slipped 90 s overnight and its Galway window now overlaps + * the SAR-1 command contact (the slipped pass). At 12:01 the Shetland BUC + * loses its cooling fan with a carrier still driving it from the morning's + * uplink check (timed fault, phase 16 E3): mute is the fix, power-off is + * wrong twice over. At 12:18, as the command window opens, Galway's GNSS + * antenna drops out and the reference goes to holdover with the switch up + * (timed fault): the command contact is flown on the oscillator and the + * re-lock confirmed when the signal returns at 12:27. Shetland is armed on + * program-track and takes the receive-only SAR-2 contact on a muted BUC with + * nobody on its console until the operator comes to check the number. + * + * Clock starts 2027-04-09 12:00:00 UTC. Passes (0 deg horizon, per station, + * scripts/author-passes.mjs): + * + * SH-02 MERIDIAN-SAR-1: AOS T+15.91 (12:15:54Z), max el 55.8 deg, LOS + * T+25.82 (nobody's: one operator). + * GW-01 MERIDIAN-SAR-1: AOS T+17.98 (12:17:59Z, az 021), max el 29.8 deg at + * T+22.82 (12:22:49Z, 728 km), LOS T+27.59 (12:27:35Z, + * az 173), 9.6 min, southbound. The command contact. + * SH-02 MERIDIAN-SAR-2: AOS T+21.28 (12:21:16Z, az 024), max el 28.0 deg at + * T+26.08 (12:26:05Z, 763 km), LOS T+30.85 (12:30:51Z, + * az 173), 9.6 min, southbound. Receive-only, unattended + * acquisition, checked from the console before LOS. + * GW-01 MERIDIAN-SAR-2: AOS T+23.54 (12:23:32Z), max el 15.5 deg, LOS + * T+32.38: the slipped half, overlapping SAR-1 on + * Galway by four minutes. Dropped. + * + * Command window is the Galway SAR-1 pass with 20 s guards (1100-1640 s). + * Faults: SH-02 BUC cooling fault at 90 s (reading jumps to 73 degC, +30 degC + * offset, clears at 1590 s when the fan is restored); GW-01 GNSS loss at + * 1120 s for 500 s (12:18:40 to 12:27:00). All thresholds flown in + * test/campaigns/nats-eu-phase-c-validation.test.ts, the Shetland one from + * Shetland. + * + * Staged state (scenario-local clones): GW-01 TX modem 1 on 1465 MHz from a + * SAR-3 shift (retune to 1405 for SAR-1), RX modem 1 on 1414. SH-02 TX modem + * 1 ON AIR into an UNMUTED BUC (HPA off) from the morning's uplink check - + * the drive that keeps the BUC hot until the operator removes it - and RX + * modem 1 on 1414 (retune to 1370 for SAR-2). + * + * NICE Framework Alignment: + * Primary Codes: + * - T0531: Troubleshoot system hardware and software + * - S0677: Skill in prioritizing and responding to multiple concurrent failures + * - S0807: Skill in making operational decisions under uncertainty + * + * Supporting Codes: + * - K0741: Knowledge of system administration concepts + * - K0689: Knowledge of network systems management + * - T0129: Coordinate and manage system operations schedules + * - T1567: Conduct satellite command and control operations + * - K0773: Knowledge of telecommunications principles and practices + * - T0153: Monitor network capacity and performance + * - K0740: Knowledge of system performance indicators + * - T1606: Report findings and recommendations + * - T1277: Document incidents and their impact + * - S0575: Skill in documenting operational procedures + */ + +/** MERIDIAN-SAR-1 (61701) at the S16 epoch: the 30 deg command contact over Galway. */ +const SAR1_S16_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27099.50000000 .00001000 00000-0 10000-3 0 9997' as TleLine1, + tle2: '2 61701 97.2000 193.5000 0010000 90.0000 308.2500 15.60000000123458' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S16 epoch: 28 deg over Shetland, the slipped 15 deg half over Galway. */ +const SAR2_S16_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27099.50000000 .00001000 00000-0 10000-3 0 9998' as TleLine1, + tle2: '2 61702 98.4000 199.7500 0010000 90.0000 288.5000 15.60000000123450' as TleLine2, +}; + +const meridianSar1S16 = createMeridianSar1(SAR1_S16_TLE); +const meridianSar2S16 = createMeridianSar2(SAR2_S16_TLE); + +/** GW-01 as the SAR-3 shift left it: TX modem on the SAR-3 uplink IF. Deep clone, never spread. */ +const galwayMidday: GroundStationConfig = structuredClone(galwayGroundStation); +galwayMidday.transmitters![0].modems[0] = { + ...galwayMidday.transmitters![0].modems[0], + ifSignal: { ...galwayMidday.transmitters![0].modems[0].ifSignal, frequency: 1465e6 as IfFrequency }, +}; + +/** + * SH-02 after the morning's uplink check: TX modem 1 still on air into an + * unmuted BUC (HPA off, so nothing leaves the rack). With the fan gone the + * driven BUC settles above the 70 degC alarm; muted, it settles well under. + */ +const shetlandHotBuc: GroundStationConfig = structuredClone(shetlandGroundStation); +shetlandHotBuc.transmitters![0].modems[0] = { + ...shetlandHotBuc.transmitters![0].modems[0], + isTransmitting: true, + isTransmittingSwitchUp: true, +}; + +export const natsEuScenario16Data: ScenarioData = { + id: 'nats-eu-scenario16', + url: 'nats-eu/scenarios/nats-eu-scenario16', + imageUrl: 'nats/16/card.png', + number: 16, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario15'], + title: 'Cascade', + subtitle: 'Network Multi-Failure', + duration: '40 min', + missionType: 'Evaluation', + description: `12:00 local. Densest window of the week, and the handover is a list of things that are wrong.

MERIDIAN-SAR-2 slipped 90 seconds overnight and now overlaps the SAR-1 command contact on Galway. Erik has collects on both. Fiona is on the Shetland roof with the BUC fan and the console is yours; nobody has checked the rack since the morning's uplink test. Anneke needs REC-PLAYBACK and a watchdog reset on SAR-1 inside the 12:18 window.

Order the work. Resolve the slip, ready Galway to command, cool the Shetland BUC the right way, arm Shetland to take SAR-2 on its own, fly the command contact through whatever happens to the reference, check Shetland's pass before it sets, and write the report Erik and maintenance both need. This is the qualification for the network.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'TT&C Commanding Console', + 'Contact Plan Console', + 'Ku-Band RF Front End (GPSDO / LNB / BUC / HPA)', + 'RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayMidday, shetlandHotBuc], + satellites: [meridianSar1S16, meridianSar2S16], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-09', + scenarioStartWallTime: '12:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-16?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + workingDocument: { + title: 'Shift Incident and Impact Report', + description: '2027-04-09 midday shift. Contacts as worked, customer impact, actions and open items.', + }, + + // M3 - the slipped pass. SAR-2's Galway window (P2) now overlaps the SAR-1 + // command contact (P1) on Galway; the SAR-2 collect is covered from + // Shetland (P1). Priority 1 must be assigned for the plan to validate. + // Windows are 0 deg horizon crossings, per station. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 1, + contacts: [ + { id: 'C-SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 56 deg)', priority: 2, windowStartS: 950, windowEndS: 1550, stationId: 'SH-02' }, + { + id: 'C-SAR1-GW', + satelliteNoradId: 61701, + label: 'MERIDIAN-SAR-1 (Galway, 30 deg) - command contact', + priority: 1, + windowStartS: 1080, + windowEndS: 1660, + stationId: 'GW-01', + }, + { + id: 'C-SAR2-SH', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Shetland, 28 deg) - NMW collect', + priority: 1, + windowStartS: 1280, + windowEndS: 1850, + stationId: 'SH-02', + }, + { + id: 'C-SAR2-GW', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Galway, 15 deg) - SLIPPED +90 s, overlaps SAR-1', + priority: 2, + windowStartS: 1410, + windowEndS: 1940, + stationId: 'GW-01', + }, + ], + }, + + // M2 - the command contact. Window is the Galway SAR-1 pass: AOS 12:17:59 + // (1079 s) + 20 s to LOS 12:27:35 (1655 s) - 20 s. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61701, + windowStartS: 1100, + windowEndS: 1640, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'OBC-WDT-RESET', label: 'Reset OBC watchdog' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + + // E3 - two faults on the mission clock. The Shetland BUC loses its fan at + // 12:01:30 with a carrier still driving it (labelled: the alarm is in the + // ops log); the fan is back at 12:26:30. Galway's GNSS drops at 12:18:40, + // as the command window opens, and returns at 12:27:00 - unlabelled, + // because noticing holdover on the GPS Timing panel is the evaluation. + hardwareFaultEvents: [ + { + id: 'sh-buc-fan', + groundStationId: 'SH-02', + target: 'buc-overtemp', + startTime: 90, + duration: 1500, + params: { startTemperatureC: 73, deltaC: 30 }, + label: 'SH-02 BUC cooling fan alarm', + }, + { id: 'gw-gnss-outage', groundStationId: 'GW-01', target: 'gpsdo-gnss-loss', startTime: 1120, duration: 500 }, + ], + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'S0807'], + title: 'Take the Shift', + description: 'Open the handover: a slipped pass, a command request, a fan alarm coming, and eighteen minutes. Decide the order before you touch anything.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Handover Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Order of Work', + params: { + character: Character.SYSTEM, + question: 'Slipped pass, command contact at 12:18, Shetland BUC alarm, SAR-2 collect. What is the order of work?', + options: [ + 'The plan first, because it decides which site needs which hardware; then Galway to command; then Shetland; then fly; report last', + 'Shetland first, because an alarm is an alarm; then the plan; then Galway to command; then fly; report last', + 'The command contact first, because Anneke is waiting on it; then Shetland; then the plan; then fly; report last', + 'The report first, so nothing is forgotten in the rush; then Shetland; then the plan; then Galway to command; then fly', + ], + correctIndex: 0, + explanation: + 'Galway before Shetland because the command window has a time, and the Shetland contact is receive-only and does not need the BUC at all. Triage is ordered by what each problem gates, not by how loud it is. A hot BUC on a receive-only site gates nothing today. Eighteen minutes to AOS. Shift clock started.', + pointPenalty: 0, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 5: RESOLVE THE SLIP + // ============================================================ + { + id: 'resolve-the-slipped-pass', + nice: ['S0807', 'K0689', 'T0129'], + title: 'Resolve the Slipped Pass', + description: + 'SAR-2 now overlaps SAR-1 on Galway. The command contact stays on GW-01; the SAR-2 collect goes to SH-02; the slipped Galway half is dropped. The plan must read DECONFLICTED.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-1 Command Contact on GW-01', + params: { contactId: 'C-SAR1-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-2 Collect on SH-02', + params: { contactId: 'C-SAR2-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Slip Resolved', + params: { + character: Character.SYSTEM, + question: 'Why is the slipped Galway SAR-2 half dropped rather than moved?', + options: [ + 'It overlaps the command contact on the only site that can command, and Shetland already has the same bird at 28 degrees', + 'A slipped pass is unflyable by definition, and a window that has moved 90 seconds no longer matches the ephemeris on the ACU', + 'The plan will not validate with a P2 contact assigned, and the board only needs the P1 windows covered to read DECONFLICTED', + 'It should be moved to Shetland, not dropped: two windows on one site is a backup, and 15 degrees is still above the mask', + ], + correctIndex: 0, + explanation: + 'The collect is covered better from the site that is free, 28 degrees against Galway’s 15, and dropping a window is a decision the plan records. Shetland cannot take two windows on the same bird at the same time either. The slip removed one window from the network; the plan says which one, and why.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 1: GALWAY READY TO COMMAND + // ============================================================ + { + id: 'galway-reference-check', + nice: ['T0431', 'K0740'], + title: 'GW-01 Reference Check', + description: 'GPSDO locked and out of holdover. Read it now so you know what it looked like before the window.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['resolve-the-slipped-pass'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GW-01 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GW-01 GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'galway-crypto-check', + nice: ['T0431', 'K0874', 'K0728'], + title: 'GW-01 Crypto Check', + description: 'Both keys Valid before a command goes up: command key on the TX chain, traffic key on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-reference-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-key-status', + description: 'TX Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'galway-uplink-setup', + nice: ['S0421', 'K0773', 'T1567'], + title: 'GW-01 Uplink Setup', + description: + 'The SAR-3 shift left TX modem 1 on 1465 MHz: retune to the 1405 MHz SAR-1 telecommand IF and engage uplink Doppler compensation on the TT&C console. RX modem 1 stays on 1414 MHz; analyzer centred on the 1389 MHz beacon, 2 MHz span.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-crypto-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'GW-01 TX 1405 MHz', + params: { modemNumber: 1, frequency: 1405e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'GW-01 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-galway-for-sar1', + nice: ['S0421', 'K1032'], + title: 'Pre-position Galway for SAR-1', + description: 'Park the tracker on the 12:18 AOS azimuth: 21 degrees at 5 degrees elevation.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-uplink-setup'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Az 21 / El 5', + params: { azimuth: 21, elevation: 5, tolerance: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 5 -> BEAT 1: SHETLAND, THE HOT BUC + // ============================================================ + { + id: 'shetland-alarm-sweep', + nice: ['T0153', 'K0741', 'T0531'], + title: 'SH-02 Alarm Sweep', + description: 'Select SH-02 and read its board. The fan alarm on the BUC is real; what matters is why the temperature is still climbing.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['preposition-galway-for-sar1'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Alarm Diagnosed', + params: { + character: Character.SYSTEM, + question: 'SH-02 BUC over-temperature, fan alarm, and the TX modem is still on air into the BUC from the morning’s uplink check. What is keeping it hot?', + options: [ + 'The drive. A BUC dissipates heat in proportion to its output; with the fan gone, a carrier going in holds it above the alarm', + 'The roof. A BUC on an outdoor mount takes the ambient temperature; with the fan gone, a warm afternoon holds it above the alarm', + 'The reference. An unlocked GPSDO makes the BUC work harder to hold frequency; with the fan gone, that holds it above the alarm', + 'The HPA. It is off, so its heat has nowhere to go but the BUC beside it; with the fan gone, that heat holds it above the alarm', + ], + correctIndex: 0, + explanation: + 'Remove the drive and it settles under the alarm, fan or no fan. The fix is on the transmit chain, not in the rack cabinet. Carrier off, BUC muted, keep it powered, let it come down.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'remove-the-drive', + nice: ['S0677', 'T0531', 'S0421'], + title: 'Remove the Drive from the Shetland BUC', + description: 'SH-02 TX chain: carrier off, BUC muted, BUC still powered. Shetland is receive-only today and needs none of it.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-alarm-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'SH-02 Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'SH-02 BUC Muted', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'confirm-shetland-buc-cooling', + nice: ['S0677', 'T0531', 'K0740'], + title: 'Confirm the Shetland BUC Cooling', + description: 'Watch it on the SH-02 TX chain: with the drive gone the BUC comes down under 70 degrees inside a couple of minutes. Do not power it off.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['remove-the-drive'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'buc-temperature-normal', + description: 'SH-02 BUC Under 70 degC', + params: { maxTemperature: 70, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Power-Off Rejected', + params: { + character: Character.SYSTEM, + question: 'Why not power the BUC off and let it cool properly?', + options: [ + 'Twice wrong: an unpowered BUC cools slower because its own regulation stops, and the alarm clears on a powered reading', + 'You should: off is always cooler than on, and an unpowered BUC has no output to dissipate, so the alarm clears sooner', + 'Reference re-lock: a powered-down BUC drops the external reference, and re-locking on power-up takes about an hour', + 'The HPA: it trips without a BUC ahead of it, and an HPA fault on top of a fan alarm takes the site off the board for the day', + ], + correctIndex: 0, + explanation: + 'An unpowered BUC cools five times slower than a muted one, and an off BUC never reads normal, it reads off. Mute removes the heat source; power-off removes the thermometer. Every equipment fault on this network has a right action and a louder wrong one. The louder one is usually the power switch.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'arm-shetland-for-sar2', + nice: ['S0421', 'K0645', 'T0129'], + title: 'Arm Shetland for SAR-2', + description: + 'Shetland acquires on its own at 12:21. Modem 1 from 1414 MHz to the 1370 MHz SAR-2 carrier, program-track with MERIDIAN-SAR-2 as the target. Then back to Galway.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['confirm-shetland-buc-cooling'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Armed', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Unattended Contact Understood', + params: { + character: Character.SYSTEM, + question: 'Shetland takes SAR-2 with a BUC alarm in the log and nobody at its console. What makes that acceptable?', + options: [ + 'The contact is receive-only: the BUC plays no part in it, the receive path is healthy, and program-track acquires on its own', + 'Nothing: a site with an active alarm should not fly, and a contact nobody watches is a contact nobody can log', + 'Fiona is on the roof: she can see the dish move, the BUC is beside her, and a pedestal in sight is as good as an operator', + 'The plan assigned it: an assigned contact configures the station, the modem tunes itself, and the pedestal follows the plan', + ], + correctIndex: 0, + explanation: + 'The BUC is the transmit chain, and a station armed on program-track acquires without an operator; what it needs is one to check the number before LOS. Assigned is a plan; armed is a station; a number read before LOS is a contact.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: THE COMMAND CONTACT, ON HOLDOVER + // ============================================================ + { + id: 'acquire-sar1-at-galway', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-1 at Galway', + description: 'Select GW-01. AOS 12:18 from azimuth 21. Program-track MERIDIAN-SAR-1 and confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['arm-shetland-for-sar2'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'chain-up-for-the-command', + nice: ['S0421', 'T1567', 'K0645'], + title: 'Chain Up for the Command', + description: 'Window open at 12:18:20. Carrier into the muted BUC, unmute, HPA. In that order.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1-at-galway'], + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'command-the-playback', + nice: ['T1567', 'S0421', 'K0773'], + title: 'Command the Playback', + description: 'Send REC-PLAYBACK and confirm the acknowledgement.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up-for-the-command'], + conditions: [ + { + type: 'command-acknowledged', + description: 'REC-PLAYBACK Acknowledged', + params: { commandId: 'REC-PLAYBACK' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'command-on-holdover', + nice: ['S0677', 'T0531', 'K0741'], + title: 'Command on Holdover', + description: + 'The GPS Timing panel is not what you read at noon. Read it, decide whether the second command goes up on it, and send OBC-WDT-RESET before the window closes at 12:27:20.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['command-the-playback'], + conditions: [ + { + type: 'tab-active', + description: 'GW-01 GPS Timing Panel Read', + params: { tab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Holdover Decision', + params: { + character: Character.SYSTEM, + question: 'GW-01 GPSDO: HOLDOVER active, GNSS switch up, zero satellites, since 12:18. The command window is open for eight more minutes. Does OBC-WDT-RESET go up?', + options: [ + 'Yes. A GNSS outage with the switch up is an outage, not an operator error; the oscillator holds the reference for tens of minutes', + 'No. Nothing goes up on a reference in holdover; the uplink Doppler pre-compensation needs GNSS time, and that is gone', + 'Yes, after cycling the GNSS switch; a switch cycle forces a re-lock, and the reference is back on satellites in a few seconds', + 'Yes, after power-cycling the GPSDO; a power cycle clears the holdover flag, and the reference is back inside the window', + ], + correctIndex: 0, + explanation: + 'The oscillator holds to microseconds and the uplink Doppler pre-compensation does not care. Send it, leave the switch alone, confirm re-lock when the signal returns. Holdover is what the disciplined oscillator is for. Cycling the switch cannot conjure a satellite; a power cycle is a ten-minute warm-up and the window is gone. The command goes up.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + { + type: 'command-acknowledged', + description: 'OBC-WDT-RESET Acknowledged (on holdover)', + params: { commandId: 'OBC-WDT-RESET' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 3 (SHETLAND) / BEAT 4 (GALWAY) + // ============================================================ + { + id: 'check-the-shetland-pass', + nice: ['T0153', 'T0431', 'K0740'], + title: 'Check the Shetland Pass', + description: + 'Select SH-02 while SAR-2 is still up (until 12:30:51). The pedestal should be program-track locked on it and the modem locked with C/N above 8 dB around culmination at 12:26.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['command-on-holdover'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'secure-galway', + nice: ['S0421', 'K0645'], + title: 'Secure Galway', + description: 'Back to GW-01. Both commands acknowledged: chain down in the mirror order, HPA off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['check-the-shetland-pass'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'GW-01 HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'GW-01 BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'GW-01 Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'confirm-galway-reference-recovered', + nice: ['T0431', 'K0740', 'T1580'], + title: 'Confirm the Galway Reference Recovered', + description: 'The GNSS signal returns at 12:27. Confirm on the GW-01 GPS Timing panel that the receiver has re-locked and left holdover, then log the contacts.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['secure-galway'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-gnss-locked', + description: 'GW-01 GNSS Receiving Again', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GW-01 Out of Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Contacts Logged', + params: { + character: Character.SYSTEM, + question: 'What does the report need for the two contacts?', + options: [ + 'SAR-1: both commands ACKed in the window, the second on holdover. SAR-2: Shetland unattended on a muted BUC, with the numbers', + 'SAR-1: both commands ACKed in the window, reference clean throughout. SAR-2: Shetland unattended, nothing to add, with the numbers', + 'SAR-1: REC-PLAYBACK and OBC-WDT-RESET acknowledged, nothing else. SAR-2: both contacts worked, so one line covers the pair', + 'SAR-1: both commands ACKed in the window, the second on holdover. SAR-2: Shetland’s contact, on Fiona’s log, not on this one', + ], + correctIndex: 0, + explanation: + 'Holdover from 12:18 to 12:27, no action at the switch, re-locked on return; Shetland locked and decoded with margin, checked before LOS. A fault that cleared is still a fault, and a contact nobody watched is still a contact. Both go in with the numbers or they did not happen.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '12:18Z MERIDIAN-SAR-1 GW-01: REC-PLAYBACK ACK; GW-01 GNSS outage 12:18:40-12:27:00Z, reference in holdover (switch up, zero satellites), OBC-WDT-RESET ACK on holdover, no action at the switch, re-locked on return; chain secured HPA-BUC-modem. 12:21Z MERIDIAN-SAR-2 SH-02 (unattended acquisition, BUC muted): locked, C/N >= 8 dB at 28 deg, checked 12:26Z. Dropped: C-SAR2-GW (slipped, overlapped command contact).', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'close-the-shetland-alarm', + nice: ['T0531', 'S0807', 'K0740'], + title: 'Close the Shetland Alarm', + description: 'Select SH-02 once more: the fan is back, the BUC is normal, and the alarm closes with a cause.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['confirm-galway-reference-recovered'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'buc-temperature-normal', + description: 'SH-02 BUC Temperature Normal', + params: { maxTemperature: 70, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Alarm Closed', + params: { + character: Character.SYSTEM, + question: 'Why was Shetland able to take SAR-2 with a BUC alarm open, and what does the alarm close as?', + options: [ + 'The contact was receive-only and the BUC is transmit only; the alarm closes as a fan fault with a carrier left driving the BUC', + 'The alarm cleared itself once the fan came back before LOS; the alarm closes as a transient fan fault with nothing further', + 'Fiona was on site and on the roof beside the BUC; the alarm closes as a fan fault she can confirm by eye, with no ticket needed', + 'It should not have, a site with an open alarm is off the board; the alarm closes as a fan fault, the contact as unplanned', + ], + correctIndex: 0, + explanation: + 'Mute, cool, keep the receive path working: a ticket for the fan and a note for whoever left the carrier up. A transmit-chain fault on a receive-only day gates nothing. Knowing which faults gate which work is what lets one operator run two sites.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 4: THE REPORT + // ============================================================ + { + id: 'customer-impact', + nice: ['T1606', 'T1277', 'S0478'], + title: 'Customer Impact', + description: 'Two lines for Erik: what he got, and what it cost him.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['close-the-shetland-alarm'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Impact Written', + params: { + character: Character.SYSTEM, + question: 'What is the customer impact of a slipped pass, a hot BUC and a reference in holdover?', + options: [ + 'None he can see: both collects captured, both commands ACKed on time; the slip cost the network a redundant window', + 'One collect lost to the slip: the Galway SAR-2 window was dropped from the plan; the slip cost the customer a collect', + 'Both collects degraded by the faults: one on a hot BUC, one on a reference in holdover; the faults cost the customer margin', + 'Unknown until Rotterdam processes the frames: both collects captured, both commands ACKed; the cost is theirs to report', + ], + correctIndex: 0, + explanation: + 'Zero missed tasking; the slip cost the network one redundant window, not the customer a collect. Qualified means nobody notices. The impact line says so, and the actions line says what it took.', + pointPenalty: 5, + documentSection: 'Impact', + documentLine: + 'Customer impact (NMW): none. Both P1 collects captured (SAR-1 GW-01, SAR-2 SH-02), REC-PLAYBACK and OBC-WDT-RESET acknowledged inside the window, zero missed tasking. Slip cost one redundant Galway window (C-SAR2-GW), not a collect.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'actions-and-open-items', + nice: ['T1606', 'T1277', 'S0575'], + title: 'Actions and Open Items', + description: 'One line for maintenance and one for Rotterdam: the tickets, and the question about the slip.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['customer-impact'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Actions Written', + params: { + character: Character.SYSTEM, + question: 'What are the actions and open items?', + options: [ + 'Maintenance: GW-01 GNSS antenna, SH-02 BUC fan. Rotterdam: root cause of the SAR-2 slip. Station: carrier left on the BUC', + 'Maintenance: nothing, every fault cleared itself. Rotterdam: nothing, the slip was flown around. Station: nothing open on either site', + 'Maintenance: replace the GW-01 GPSDO and the SH-02 BUC. Rotterdam: nothing, the slip is theirs. Station: both sites cold and locked', + 'Maintenance: SH-02 BUC fan. Rotterdam: request that SAR-2 be moved back to its old orbit. Station: GW-01 GPSDO to be power-cycled', + ], + correctIndex: 0, + explanation: + 'The GNSS ticket carries the outage (12:18-12:27, holdover flown); the slip is Rotterdam’s to explain because a 90 second slip removed a window from the plan; and a carrier was left driving the SH-02 BUC after the morning test. Three problems, three owners, and none of them is the customer. Qualified for NATS Europe network operations. Both sites are yours to run.', + pointPenalty: 5, + documentSection: 'Actions', + documentLine: + 'Actions: (1) Maintenance - GW-01 GNSS antenna fault, outage 12:18:40-12:27:00Z, reference held on oscillator. (2) Maintenance - SH-02 BUC cooling fan; carrier found driving the BUC after the morning uplink check, removed before the 12:18Z window. (3) Rotterdam - root cause of MERIDIAN-SAR-2 90 s slip requested. Both sites cold, references locked, plan DECONFLICTED.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Fiona MacLeod at 11:58] +

+

+ "Fan alarm on my BUC and I'm going up to look at it, so the console's yours. Nobody's touched the rack since the uplink check this morning. SAR-2 moved overnight and Rotterdam hasn't said why. Your GPSDO's been fine. Enjoy." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'resolve-the-slipped-pass': { + text: ` +

+ [Text message from Anneke Visser at 12:02] +

+

+ "SAR-2 slipped ninety seconds overnight - ours, we are looking at it. Your SAR-1 command contact stands: REC-PLAYBACK and an OBC watchdog reset inside the 12:18 window, please. Erik still wants both collects. Sort the board first." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'remove-the-drive': { + text: ` +

+ [Text message from Fiona MacLeod at 12:09] +

+

+ "Fan's dead, that's confirmed. Saw the temperature stop climbing on the panel the moment you muted it - that was the carrier, then, not the roof. Leave her powered; she'll come down. I'll be up here a while." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'command-the-playback': { + text: ` +

+ Playback acknowledged. One more: the watchdog reset, before the window closes. Whatever your reference is doing, the bird does not know about it. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'check-the-shetland-pass': { + text: ` +

+ [Text message from Fiona MacLeod at 12:27] +

+

+ "Watched the dish take SAR-2 from the roof, on a hot BUC and a cold tea. Nobody at the console and it flew itself. That's the network." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'customer-impact': { + text: ` +

+ Both collects. Both on time. I heard afterwards there were three things wrong at once. I never noticed, which I understand is the point. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'actions-and-open-items': { + text: ` +

+ [Text message from Charlie Brooks at 12:44] +

+

+ "Heard it was a busy lunch. A slipped pass, a hot BUC, a reference that went walkabout, and a customer who noticed none of it. Qualified means nobody notices. The network's yours." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario17.ts b/src/campaigns/nats-eu/scenario17.ts new file mode 100644 index 00000000..5fd19daa --- /dev/null +++ b/src/campaigns/nats-eu/scenario17.ts @@ -0,0 +1,972 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 17 - "Unusual Activity" / First Indicators (Gray Zone arc 1/8) + * + * Phase 3 opens quietly. A night shift with two routine receive passes, and + * three things that do not belong: three accounts each hit with three failed + * logins inside an hour (a spray, not a forgotten password), a Ku carrier that + * appears on the SAR-1 downlink just after culmination and does not Doppler + * (a ground emitter, not a satellite), and, while the operator is writing up + * the carrier, a full station-configuration export by a service account that + * was decommissioned in January. Nothing is attributed. Priya Sharma, NATS + * Group CSIRT, debuts: flag, note the time, then act - the order matters. + * + * Phase 18 (decision conditions): `call-the-carrier` is the first Gray Zone + * decision. It is graded on live evidence facts - a scripted interference + * event is radiating, no fault is injected, the RX key reads Valid - so + * "interference: characterise and report" is the only correct call. The + * intrusion option fires an escalation entry into the audit log; that entry is + * still there in S22. + * + * Clock starts 2027-04-12 02:00:00 UTC. Passes (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 02:00:00Z): + * + * MERIDIAN-SAR-1: AOS T+20.01 (02:20:00Z, az 187), max el 30.0 deg at T+24.77 + * (02:24:46Z, 717 km), LOS T+29.57 (02:29:34Z, az 339), 9.6 min, + * northbound. C/N >= 8 dB roughly 02:22:30 .. 02:27:00. + * MERIDIAN-SAR-2: AOS T+40.02 (02:40:01Z, az 137), max el 26.3 deg at T+44.76 + * (02:44:46Z, 809 km), LOS T+49.50 (02:49:30Z, az 354), 9.5 min, + * northbound. C/N >= 8 dB roughly 02:42:30 .. 02:47:00. + * + * The carrier: settings.interferenceEvents 'gw-ku-carrier', terrestrial path, + * emitter 22 km south-west of Galway, 11690 MHz (IF 1410, 4 MHz below the + * SAR-1 video at 1414 and inside its 36 MHz occupied band), 1 MHz wide, on + * from T+1530 s (02:25:30Z) for 510 s - through LOS to 02:34:00Z. Terrestrial + * emissions reach the antenna directly, so it stays on the analyzer after the + * bird has set, and it never drifts: two diagnostic tells the characterise + * objective quizzes. Shetland never hears it. + * + * Staged state (scenario-local clone of GW-01): RX modem 1 on the 1370 MHz + * SAR-2 carrier from the evening's last contact; analyzer at its 1414 / 60 MHz + * default; tracker parked where the evening left it. Receive only: no + * commanding, no HPA work. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0682: Knowledge of cyber attack stages and their indicators + * - K0935: Knowledge of incident categories and response procedures + * - S0648: Skill in detecting anomalies + * + * Supporting Codes: + * - K0946: Knowledge of incident reporting requirements + * - K0926: Knowledge of signal jamming tools and techniques + * - K0686: Knowledge of account management + * - S0844: Skill in reviewing logs to identify evidence + * - T1569: Perform account management + * - K0773: Knowledge of telecommunications principles and practices + * - S0421: Skill in operating network equipment + * - T0153: Monitor network capacity and performance + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + */ + +/** MERIDIAN-SAR-1 (61701) at the S17 epoch: the 30 deg pass, northbound from az 187. */ +const SAR1_S17_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27102.08333333 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61701 97.2000 228.0000 0010000 90.0000 226.2500 15.61320000123457' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S17 epoch: the 26 deg pass twenty minutes later. */ +const SAR2_S17_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27102.08333333 .00001000 00000-0 10000-3 0 9994' as TleLine1, + tle2: '2 61702 98.0000 253.7500 0010000 90.0000 152.0000 15.58070000123456' as TleLine2, +}; + +const meridianSar1S17 = createMeridianSar1(SAR1_S17_TLE); +const meridianSar2S17 = createMeridianSar2(SAR2_S17_TLE); + +/** GW-01 as the evening left it: modem 1 on the SAR-2 carrier. Deep clone, never spread. */ +const galwayNight: GroundStationConfig = structuredClone(galwayGroundStation); +galwayNight.receivers![0].modems![0] = { + ...galwayNight.receivers![0].modems![0], + frequency: 1370 as MHz, +}; + +export const natsEuScenario17Data: ScenarioData = { + id: 'nats-eu-scenario17', + url: 'nats-eu/scenarios/nats-eu-scenario17', + imageUrl: 'nats/17/card.png', + number: 17, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario16'], + title: 'Unusual Activity', + subtitle: 'First Indicators (Gray Zone 1/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `02:00, Galway, night shift. Two routine receive passes on the board, SAR-1 at 02:20 and SAR-2 at 02:40, and nothing else - except a text from Group Security that arrived at 01:52 and does not say much.

The overnight log has three failed-login bursts in it. The SAR-1 pass will have something in it too. None of it is attributed and none of it is loud. Priya Sharma's rule for the night is the whole lesson: look, flag, note the time, then act. In that order.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO)', + 'Security Console (audit log + access control)', + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayNight, shetlandGroundStation], + satellites: [meridianSar1S17, meridianSar2S17], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-12', + scenarioStartWallTime: '02:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-17?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Incident Record INC-2027-0412-GW01', + description: 'Opened on the night of 11/12 April. What was seen, when it was seen, what was done. In that order.', + }, + + // The carrier: a ground emitter south-west of the station on 11690 MHz, + // inside the SAR-1 video band, on from just after culmination through LOS. + // Terrestrial path: received directly, no Doppler, still there after the + // bird sets, never heard at Shetland. + interferenceEvents: [ + { + id: 'gw-ku-carrier', + frequency: 11690e6, + bandwidth: 1e6, + // EIRP dBm. ~140 dB of path loss over 24 km at 11.69 GHz through a far + // sidelobe puts it near the video's level at the feed: a plain dent. + power: 60, + polarization: 'V', + startTime: 1530, + duration: 510, + periodSeconds: 510, + onSeconds: 510, + path: 'terrestrial', + emitter: { latitude: 53.12, longitude: -9.32 }, + }, + ], + + // M6 - the overnight log. Routine traffic, three failed-login bursts an + // hour apart against three different accounts (the spray; the third one + // is against a service account decommissioned in January and is the entry + // to flag), and one live entry that arrives during the shift: a full + // configuration export by that same account. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-evening', name: 'R. Byrne', role: 'Operator (evening)', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'svc-legacy', name: 'Telemetry bridge (legacy, decommissioned 14 Jan)', role: 'Service Account', status: 'active' }, + ], + events: [ + { id: 'evt-evening-login', timeS: 0, timestampLabel: '17:58 UTC', actor: 'op-evening', action: 'Console login', category: 'auth', severity: 'info' }, + { + id: 'evt-cfg-rx-1370', + timeS: 0, + timestampLabel: '23:41 UTC', + actor: 'op-evening', + action: 'Set receiver 1 frequency 1370 MHz (SAR-2 23:52 contact)', + category: 'config', + severity: 'info', + }, + { id: 'evt-svc-poll-0000', timeS: 0, timestampLabel: '00:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + { + id: 'evt-evening-logout', + timeS: 0, + timestampLabel: '00:06 UTC', + actor: 'op-evening', + action: 'Console logout (end of shift, station unattended)', + category: 'auth', + severity: 'info', + }, + { + id: 'evt-spray-charlie', + timeS: 0, + timestampLabel: '01:12 UTC', + actor: 'op-charlie', + action: 'Failed login (3 attempts, remote source, then stopped)', + category: 'auth', + severity: 'warning', + }, + { + id: 'evt-spray-fiona', + timeS: 0, + timestampLabel: '01:40 UTC', + actor: 'op-fiona', + action: 'Failed login (3 attempts, same remote source, then stopped)', + category: 'auth', + severity: 'warning', + }, + { + id: 'evt-spray-legacy', + timeS: 0, + timestampLabel: '01:58 UTC', + actor: 'svc-legacy', + action: 'Failed login (3 attempts, same remote source, then stopped) - account decommissioned 14 Jan, still active', + category: 'auth', + severity: 'warning', + isAnomaly: true, + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '01:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-svc-poll-0200', timeS: 0, timestampLabel: '02:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + { + id: 'evt-config-export', + timeS: 1980, + timestampLabel: '02:33 UTC', + actor: 'svc-legacy', + action: 'Configuration read: full station configuration exported (antenna, LNB LO, modem plan, contact schedule) - no change ticket', + category: 'config', + severity: 'critical', + isAnomaly: true, + }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0935'], + title: 'Read the Night Brief', + description: "Open the shift brief. Two receive passes, a text from Group Security, and Priya's rule for anything that does not belong: look, flag, note the time, then act.", + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Night Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'The Order Understood', + params: { + character: Character.SYSTEM, + question: "Priya's rule is look, flag, note the time, then act. Why is the account not disabled first, before anything else?", + options: [ + 'Acting first destroys the record of what was seen and when; the flag and the time are the evidence, the action comes after', + 'Disabling an account needs Group Security approval at night; flagging is within the operator authority, so it goes first', + 'The console will not accept an access-control change until the audit log has been marked reviewed for the shift', + 'Acting first is fine for a service account; the rule only applies to named user accounts, which need a witness', + ], + correctIndex: 0, + explanation: + 'An entry acted on before it is flagged is an entry someone has to reconstruct later from memory. The flag and the time you saw it are the incident record; the action is what the record justifies. Twenty minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Night board. Confirm the active alarm state on GW-01 before anything is read into it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 02:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the night', + 'Security console alert - three failed logins have raised a station alarm', + 'LNB reference unlocked - hardware alarm left by the evening shift', + ], + correctIndex: 0, + explanation: + 'The AGC rail is empty sky, not a fault. The failed logins are in the audit log, not on the alarm board; the board watches hardware, the log watches people. Both get read tonight.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover. Every reading tonight is only worth what the reference is worth.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-1', + description: + 'Modem 1 is still on the 1370 MHz SAR-2 carrier from the 23:52 contact. Retune it to 1414 MHz and frame the analyzer on 1414 with a 40 MHz span, wide enough to hold the video, the 1389 beacon and the Doppler.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1414 MHz', + params: { centerFrequency: 1414e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-overnight-log', + nice: ['S0844', 'K0682'], + title: 'Read the Overnight Log', + description: + 'Open the Security console and read the trail from the evening handover to now, then sign off the review. Three warning entries an hour apart. Read them as a set before you read them one at a time.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Security Console Open', + params: { tab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'audit-log-reviewed', + description: 'Overnight Log Reviewed', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Pattern Recognised', + params: { + character: Character.SYSTEM, + question: + 'Three failed logins against C. Brooks at 01:12, three against F. MacLeod at 01:40, three against the legacy telemetry bridge at 01:58, all from one remote source, each stopping at three. What is that?', + options: [ + 'A spray: one source trying a few passwords on many accounts and stopping under the lockout, which reads as three small warnings', + 'Three people mistyping: a site lead, an operator and a service, each locked out on the third try and each unrelated', + 'A lockout test by the monitoring service: three accounts probed on schedule to confirm the three-attempt policy', + 'A brute force against the station: one account hit repeatedly until the lockout, then the attacker moved on', + ], + correctIndex: 0, + explanation: + 'Few attempts per account, many accounts, one source, stopping just under the lockout: that is a spray, and a spray is designed to look like three unrelated warnings. The logger gave you three entries. The pattern is the finding.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'flag-the-spray', + nice: ['S0844', 'K0946'], + title: 'Flag the Spray', + description: + 'Flag the 01:58 entry against svc-legacy - an account decommissioned in January that still answers. Flag it, note the time you saw it. Do not touch the account yet.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-overnight-log'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Spray Entry Flagged', + params: { eventId: 'evt-spray-legacy' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Time Noted', + params: { + character: Character.SYSTEM, + question: 'The account is decommissioned, still active, and was just tried. Why flag and note the time before disabling it?', + options: [ + 'The flag and the time seen are the first lines of the incident record; disabling first leaves a change with no reason attached', + 'Because disabling it might alert whoever is trying; leaving it active for the night keeps them from noticing', + 'Because a decommissioned account cannot be disabled from the console; it has to go through the monitoring service', + 'The flag is for Group Security to decide on the account; the operator has no authority over service accounts', + ], + correctIndex: 0, + explanation: + 'Nobody is being left a door open on purpose. The order is about the record: seen at 02:09, flagged at 02:09, disabled at 02:35 with a reason. A disabled account with no flag behind it is a change nobody can explain at 09:00.', + pointPenalty: 5, + documentSection: 'Indicators', + documentLine: + 'Overnight log: password spray from one remote source, 01:12 / 01:40 / 01:58, three accounts x three attempts, stopping under lockout. svc-legacy (decommissioned 14 Jan) still active and targeted. Flagged 02:09.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 02:20:00 from azimuth 187. Park the tracker on the rise azimuth at 5 degrees so the slew is done before she is over the horizon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-spray'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 187 / El 5', + params: { azimuth: 187, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Program-track SAR-1 before 02:20 and confirm the 1389 MHz beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar1', + nice: ['T0153', 'K0740'], + title: 'Decode the Routine Downlink', + description: 'Lock the 1414 MHz imagery carrier and hold C/N above 8 dB through the high-elevation segment, roughly 02:22:30 to 02:27.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'characterise-the-carrier', + nice: ['S0648', 'K0926', 'K0773'], + title: 'Characterise the Carrier', + description: + 'From about 02:25:30 there is a second carrier at 1410 MHz, 4 MHz below the video and inside its band, and the C/N has gone with it. Frame it on the analyzer: 1410 MHz, 2 MHz span. Watch it for a minute against the video.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar1'], + conditions: [ + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1410 MHz', + params: { centerFrequency: 1410e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'Carrier Seen at 1410 MHz', + params: { + signalId: 'INTERFERER-gw-ku-carrier', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Tell Read', + params: { + character: Character.SYSTEM, + question: + 'Over a minute the SAR-1 video walked 200 kHz down the span with the Doppler. The carrier at 1410 did not move. What does that tell you about where it comes from?', + options: [ + 'It is not on the bird: a signal with no Doppler on a pass that has plenty is coming from something that is not moving relative to you - the ground', + 'It is on the bird: the transponder locks the carrier to the beacon reference, so only the video shows the Doppler', + 'It is the LNB: a local oscillator spur sits still by definition, and 1410 is a known product of the 13100 LO', + 'It is Shetland: SH-02 is tracking the same bird and its receive LO leaks back down the inter-site link', + ], + correctIndex: 0, + explanation: + 'Doppler is the signature of a moving source. Everything from SAR-1 walks together; a carrier that sits still while the video moves is not coming from SAR-1, and if it is still there after LOS it is not coming from any satellite. The spectrum told you the geometry before anyone told you the frequency.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'call-the-carrier', + nice: ['S0648', 'K0682', 'K0926'], + title: 'Call the Carrier', + description: 'Fiona has SAR-1 clean from Shetland. Your C/N is down 6 dB with a still carrier in the band. Look at the key status, look at the carrier, then call it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['characterise-the-carrier'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'carrier-seen', + type: 'signal-detected', + description: 'Carrier read on RX Analysis', + params: { + signalId: 'INTERFERER-gw-ku-carrier', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + id: 'key-seen', + type: 'rx-key-status', + description: 'RX key status read on RX Analysis', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Fault, interference, intrusion, or your own uplink', + params: { + character: Character.FIONA_MACLEOD, + prompt: "I've got SAR-1 clean up here, eleven dB, no second carrier. Whatever you're seeing is yours. What is it?", + evidence: ['carrier-seen', 'key-seen'], + decisionOptions: [ + { + label: 'Receive-chain fault - raise a ticket and wait for the next pass', + correctWhen: { fact: 'equipment-fault-active', is: true }, + consequence: { log: 'Maintenance ticket raised against GW-01 RX chain (no fault found)' }, + feedback: + 'A fault does not put a clean 1 MHz carrier at a frequency of its own choosing. The chain is doing exactly what an AGC does with a strong in-band signal.', + }, + { + label: 'External interference - characterise it and report it', + correctWhen: { + all: [ + { fact: 'interference-active', is: true }, + { fact: 'equipment-fault-active', is: false }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Interference report opened: 1410 MHz IF (11690 MHz RF), 1 MHz, no Doppler, Galway only' }, + }, + { + label: 'Intrusion on the link - escalate to CSIRT and hold the receiver', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { + auditEvent: { + id: 'evt-op-escalation-sar1', + actor: 'op-duty', + action: 'Escalated to CSIRT: suspected intrusion on SAR-1 link (no crypto indication)', + category: 'access', + severity: 'warning', + }, + log: 'CSIRT escalation raised on the SAR-1 downlink', + }, + feedback: + 'Your key reads Valid and every frame decodes when the carrier is not there. There is no sign of anyone on the link but you and a ground transmitter. The escalation is in the log now; it will be read later.', + }, + { + label: 'Own uplink transponded back - secure the HPA before the decode', + consequence: { log: 'HPA checked: receive-only shift, nothing on the air from GW-01' }, + feedback: 'Nothing has left this station tonight. The HPA is cold and there is no uplink to transpond.', + }, + ], + explanation: + 'A carrier that appears mid-pass, does not Doppler, outlives the bird and is heard at one site only is a ground emitter in your band. Not a fault, not an intruder on the link, not you. It gets characterised and reported, and whoever it is gets found by people with the equipment to find them.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'open-the-incident', + nice: ['K0946', 'K0935', 'T1580'], + title: 'Open the Incident', + description: + 'LOS 02:29:34 and the carrier is still there. Open the incident record: the spray and the carrier, times, what each one looks like on the console, and what has not been done to either yet.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-carrier'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Incident Opened', + params: { + character: Character.SYSTEM, + question: 'Two indicators in one night, unrelated on their face. What goes in the incident record?', + options: [ + 'Both, as seen: the spray with its times and accounts, the carrier with its frequency, timing and the no-Doppler tell; no cause claimed for either', + 'The carrier only: the spray stopped under lockout and is closed; an interference report and an incident are different documents', + 'The spray only: the carrier is an RF matter for the regulator, and putting it in a security record confuses the two', + 'Both, with the link made: a spray at 01:58 and a carrier at 02:25 from the same actor probing the station two ways', + ], + correctIndex: 0, + explanation: + 'An incident record is what was seen, with times. Two things happened in one night; whether they are related is a conclusion someone reaches later, from this record, and the record is worth nothing if it already contains the conclusion.', + pointPenalty: 5, + documentSection: 'Incident', + documentLine: + 'INC-2027-0412-GW01 opened 02:31. (1) Password spray 01:12-01:58, one remote source, op-charlie / op-fiona / svc-legacy, 3 attempts each, stopped under lockout; svc-legacy decommissioned 14 Jan, still active. (2) Carrier 11690 MHz RF (1410 IF), ~1 MHz, from 02:25:30 through SAR-1 LOS 02:29:34 and after; no Doppler; not received at SH-02. C/N on SAR-1 video -6 dB while present. No cause claimed. Geolocation referred to specialist assets.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'flag-the-config-export', + nice: ['S0844', 'K0682', 'K0683'], + title: 'Flag the Configuration Export', + description: + 'While you were writing, the log moved. 02:33: a full station-configuration export by svc-legacy - antenna, LNB LO, modem plan, contact schedule. No change ticket. Flag it and note the time.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['open-the-incident'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Configuration Export Flagged', + params: { eventId: 'evt-config-export' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'What Was Taken', + params: { + character: Character.SYSTEM, + question: 'The export succeeded on an account that failed three logins at 01:58. What has whoever holds that export got?', + options: [ + 'Enough to plan against the station: which bird is where and when, on what frequency, through which LO - the contact schedule is targeting data', + 'Nothing usable: a configuration file is hardware settings, and without the crypto keys it cannot be used for anything', + 'Only what the public pass predictors already give: satellite positions are open data, so the schedule adds nothing', + 'A copy of the audit log: the export includes the trail, so they now know what has been flagged tonight', + ], + correctIndex: 0, + explanation: + 'A schedule is a list of where an antenna will point, when, and on what frequency. A frequency plan says what to transmit on to be in band. The carrier at 02:25 was already in band; whoever exported the configuration at 02:33 can be in band on every pass from now on. The station is a target, and the export is the map.', + pointPenalty: 5, + documentSection: 'Indicators', + documentLine: + '02:33 svc-legacy: full station configuration exported, no change ticket, 35 min after the failed logins on the same account. Flagged 02:34. Contents: antenna, LNB LO, modem plan, contact schedule.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'harden-the-service-account', + nice: ['T1569', 'K0686'], + title: 'Disable the Legacy Account', + description: 'Flagged, timed, recorded. Now act: set svc-legacy to disabled. It was decommissioned in January; tonight it was sprayed and then used.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-config-export'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'access-control-set', + description: 'svc-legacy Disabled', + params: { accountId: 'svc-legacy', accountStatus: 'disabled' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Why Now', + params: { + character: Character.SYSTEM, + question: 'The export already happened. Why does disabling the account still matter?', + options: [ + 'Because the account is a way in that still works: the export is done, the next use is not, and the record now shows who closed it and why', + 'It does not much; the damage is the export and the account is evidence now, so it should be left as found for CSIRT', + 'Because disabling it revokes the exported configuration; the file is tied to the account that read it', + 'Because the monitoring service needs it disabled to stop logging the account; the log is filling with its polls', + ], + correctIndex: 0, + explanation: + 'A door somebody has used is still a door. Disabling it does not undo the export; it ends the access. And because it was flagged and timed first, the disable has a reason attached that will still make sense at 09:00 to someone who was not here.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'retune-for-sar2', + nice: ['S0421', 'K0773'], + title: 'Retune for SAR-2', + description: + 'MERIDIAN-SAR-2 rises at 02:40:01 from azimuth 137. Retune modem 1 to its 1370 MHz carrier and the analyzer to 1370 with the 40 MHz span, and let the tracker take it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['harden-the-service-account'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'decode-sar2', + nice: ['T0153', 'K0740', 'S0421'], + title: 'Decode the Second Downlink', + description: + 'Retarget program-track to SAR-2, lock the 1370 MHz carrier and hold C/N above 8 dB through the high-elevation segment, roughly 02:42:30 to 02:47. The carrier has gone; note whether it comes back.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retune-for-sar2'], + conditions: [ + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'close-the-shift', + nice: ['T1580', 'K0946', 'K0645'], + title: 'Close the Record for the Night', + description: + 'SAR-2 decoded clean, the carrier did not return. Close the record: what is open, what the day shift and Group Security get at 09:00, and the one thing you would do differently.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Record Closed for the Night', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line - the one thing tonight that changes tomorrow?', + options: [ + 'A decommissioned account was still active for three months: every decommission gets a disable on the day, checked at the monthly baseline', + 'Both passes decoded above 8 dB and nothing was lost: no change to the procedure, the incident record covers the rest', + 'The analyzer should stay on 2 MHz spans: the carrier would have been seen earlier on a narrow span than on 40 MHz', + 'Night shifts should not run receive passes alone: the carrier and the export needed two people to handle at once', + ], + correctIndex: 0, + explanation: + 'The spray found an account that should not have existed; the export used it. Neither the carrier nor the spray was preventable from this console. The account was. That is the line that changes something.', + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed for the night 02:52. SAR-1 decoded to 02:25:30 then degraded by the carrier; SAR-2 decoded clean, carrier not observed. Open: interference source (referred), spray source, config export (Group Security 09:00). Action: svc-legacy disabled 02:36. Debrief: decommissioned accounts get disabled on the day; add to the monthly baseline.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Priya Sharma at 01:52] +

+

+ "Group Security here. Your station raised three auth warnings overnight; the monitoring feed shows me the count and not the content. Read the log before your first pass. If anything on it or on the pass does not belong: flag it, note the time you saw it, then act. In that order. I will be on this number." +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'flag-the-spray': { + text: ` +

+ Good. Three accounts, three tries each, one source - that is somebody being careful, not somebody forgetting. Leave the account as it is for now; I want it in the record before it is touched. Fly your pass. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'call-the-carrier': { + text: ` +

+ Clean up here the whole pass. If it is still there after she sets, it was never on her. Log what it did to your number - Rotterdam will want the before and after. +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'open-the-incident': { + text: ` +

+ That is an incident record, not a story. Two things, two times, no cause. Keep writing it that way. Do not clean anything up yet. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'harden-the-service-account': { + text: ` +

+ Disabled at 02:36 with the flag behind it. Now that account tells a story someone else can read. Take your second pass; I have what I need until morning. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'close-the-shift': { + text: ` +

+ Record received. The interference goes to people with direction-finding kit; the account and the export stay with me. You looked before you touched. Sleep well. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario18.ts b/src/campaigns/nats-eu/scenario18.ts new file mode 100644 index 00000000..65faa169 --- /dev/null +++ b/src/campaigns/nats-eu/scenario18.ts @@ -0,0 +1,914 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 18 - "Dirty Spectrum" / Persistent Interference (Gray Zone arc 2/8) + * + * Two days after the night of S17 the carrier is back, and it has learned the + * plan. On the SAR-2 pass it sits inside SAR-2's video band; on the SAR-1 + * pass forty minutes later it has moved to SAR-1's. It cycles - sixty seconds + * on, thirty off - which is a timer, not weather. Fiona flies the same birds + * from Shetland and never hears it. The operator characterises the cadence, + * calls it, notches it out and keeps the pass, records what the second + * appearance implies (whoever exported the configuration in S17 knows every + * IF on the station), builds the regulator package, and closes with the line + * that changes the pre-pass sweep. + * + * Phase 18: `call-the-cycle` is graded on the interference *envelope* fact - + * an event in progress counts in its off phase too, so the call does not flip + * every thirty seconds. The intrusion option writes an escalation into the + * audit log; S22 reads it. + * + * Clock starts 2027-04-14 10:00:00 UTC. Passes (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 10:00:00Z): + * + * MERIDIAN-SAR-2: AOS T+18.02 (10:18:00Z, az 023), max el 31.8 deg at T+22.82 + * (10:22:49Z, 681 km), LOS T+27.56 (10:27:34Z, az 176), 9.5 min, + * southbound. C/N >= 8 dB roughly 10:20:30 .. 10:25:00. + * MERIDIAN-SAR-1: AOS T+40.02 (10:40:00Z, az 136), max el 24.0 deg at T+44.75 + * (10:44:45Z, 876 km), LOS T+49.48 (10:49:29Z, az 357), 9.5 min, + * northbound. C/N >= 8 dB roughly 10:42:30 .. 10:47:00. + * + * The carrier (terrestrial, the S17 emitter, Galway only): + * 'gw-carrier-sar2' 11726 MHz -> IF 1374 (4 MHz above the SAR-2 video at + * 1370, inside its band), 1 MHz, from T+1410 s (10:23:30Z) for 480 s to + * 10:31:30Z - past LOS - cycling 60 s on / 30 s off. + * 'gw-carrier-sar1' 11690 MHz -> IF 1410 (the S17 frequency, inside the + * SAR-1 video band), 1 MHz, from T+2490 s (10:41:30Z) for 420 s to + * 10:48:30Z, same cadence. + * + * Staged state (scenario-local clone of GW-01): RX modem 1 on the 1414 MHz + * SAR-1 carrier from the morning; svc-legacy reads disabled since S17. Receive + * only. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0684: Knowledge of cyber defense and information security policies, procedures, and regulations + * - K0926: Knowledge of signal jamming tools and techniques + * - S0648: Skill in detecting anomalies + * + * Supporting Codes: + * - K0682: Knowledge of cyber attack stages and their indicators + * - K0946: Knowledge of incident reporting requirements + * - S0593: Skill in handling incidents + * - K0737: Knowledge of signal filtering techniques + * - K0773: Knowledge of telecommunications principles and practices + * - S0421: Skill in operating network equipment + * - T0153: Monitor network capacity and performance + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + */ + +/** MERIDIAN-SAR-2 (61702) at the S18 epoch: the 32 deg pass, southbound from az 023. */ +const SAR2_S18_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27104.41666667 .00001000 00000-0 10000-3 0 9992' as TleLine1, + tle2: '2 61702 98.4000 166.0000 0010000 90.0000 308.0000 15.62520000123459' as TleLine2, +}; + +/** MERIDIAN-SAR-1 (61701) at the S18 epoch: the 24 deg pass, northbound from az 136. */ +const SAR1_S18_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27104.41666667 .00001000 00000-0 10000-3 0 9991' as TleLine1, + tle2: '2 61701 97.2000 16.0000 0010000 90.0000 152.2500 15.55840000123450' as TleLine2, +}; + +const meridianSar1S18 = createMeridianSar1(SAR1_S18_TLE); +const meridianSar2S18 = createMeridianSar2(SAR2_S18_TLE); + +/** GW-01 as the morning left it: modem 1 on the SAR-1 carrier. Deep clone, never spread. */ +const galwayDay: GroundStationConfig = structuredClone(galwayGroundStation); +galwayDay.receivers![0].modems![0] = { + ...galwayDay.receivers![0].modems![0], + frequency: 1414 as MHz, +}; + +export const natsEuScenario18Data: ScenarioData = { + id: 'nats-eu-scenario18', + url: 'nats-eu/scenarios/nats-eu-scenario18', + imageUrl: 'nats/18/card.png', + number: 18, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario17'], + title: 'Dirty Spectrum', + subtitle: 'Persistent Interference (Gray Zone 2/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `10:00, Galway, two days on. The incident record from Monday night is open, the interference report is with the regulator, and nothing has been attributed. SAR-2 at 10:18 and SAR-1 at 10:40, both receive-only. Fiona flies both from Shetland in parallel.

If the carrier comes back, it will not be a coincidence twice. Characterise it, keep the pass, and build the package the regulator can act on. And notice what it does when the plan changes.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO) with IF Notch Filter', + 'Security Console (audit log + access control)', + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayDay, shetlandGroundStation], + satellites: [meridianSar1S18, meridianSar2S18], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-14', + scenarioStartWallTime: '10:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-18?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Interference Report IR-2027-0414-GW01', + description: 'For ComReg and Rotterdam. Victim, interferer, captures, cross-station confirmation, impact, mitigation - every line timestamped. Evidence, not conclusion.', + }, + + // The S17 emitter, back on two passes. Terrestrial: received directly, + // no Doppler, cycling 60 s on / 30 s off, never heard at Shetland. On + // the SAR-2 pass it sits in SAR-2's band; on the SAR-1 pass it moves to + // SAR-1's - the configuration export of S17, in use. + interferenceEvents: [ + { + id: 'gw-carrier-sar2', + frequency: 11726e6, + bandwidth: 1e6, + power: 60, + polarization: 'V', + startTime: 1410, + duration: 480, + periodSeconds: 90, + onSeconds: 60, + path: 'terrestrial', + emitter: { latitude: 53.12, longitude: -9.32 }, + }, + { + id: 'gw-carrier-sar1', + frequency: 11690e6, + bandwidth: 1e6, + power: 60, + polarization: 'V', + startTime: 2490, + duration: 420, + periodSeconds: 90, + onSeconds: 60, + path: 'terrestrial', + emitter: { latitude: 53.12, longitude: -9.32 }, + }, + ], + + // M6 - the log as it stands two days on. svc-legacy is disabled (S17); + // the escalation option in call-the-cycle writes into this log. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'svc-legacy', name: 'Telemetry bridge (legacy, decommissioned 14 Jan)', role: 'Service Account', status: 'disabled' }, + ], + events: [ + { + id: 'evt-legacy-disabled', + timeS: 0, + timestampLabel: '12 Apr 02:36 UTC', + actor: 'op-duty', + action: 'Account svc-legacy set to disabled (INC-2027-0412-GW01)', + category: 'access', + severity: 'info', + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '09:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-svc-poll-1000', timeS: 0, timestampLabel: '10:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0682'], + title: 'Read the Day Brief', + description: 'Open the shift brief: the state of the incident, the two passes, and what to do if the carrier comes back.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Day Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Posture Understood', + params: { + character: Character.SYSTEM, + question: 'Monday night the carrier was reported as interference with no cause claimed. If it appears again today, what changes about how you treat it?', + options: [ + 'The record: a second appearance is evidence of persistence, so every capture is timestamped for the regulator - the call itself is still made on what the station shows', + 'The call: a second appearance proves intent, so it is treated as an attack from the first second and commanding is held', + 'Nothing: each event is its own incident, characterised from scratch, and Monday night is not admissible in today’s report', + 'The mitigation: a repeat interferer is retuned around rather than notched, so the modem moves off the frequency it knows', + ], + correctIndex: 0, + explanation: + 'Persistence is a fact for the report, not a verdict for the console. The call is still made on the evidence in front of you; what a second night changes is how carefully that evidence is kept. Eighteen minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Board read. Confirm the active alarm state on GW-01 before the first pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 10:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the day', + 'Notch filter alarm - the IF notch is unpowered and the RX chain is flagged', + 'Interference alarm - the analyzer has flagged Monday night’s carrier as present', + ], + correctIndex: 0, + explanation: + 'Empty sky, not a fault. The analyzer does not raise alarms and the notch filter is powered and idle; the only thing on the board is the AGC rail, and it clears at AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover. Every capture in today’s report carries a frequency, and the frequency is only worth what the reference is worth.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready-sar2', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-2', + description: + 'Modem 1 is on the 1414 MHz SAR-1 carrier from the morning. Retune it to SAR-2’s 1370 MHz and frame the analyzer on 1370 with a 40 MHz span: video, the 1397 beacon, the Doppler, and room to see anything that is not yours.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 10:18:00 from azimuth 023, southbound. Park the tracker on the rise azimuth at 5 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready-sar2'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 023 / El 5', + params: { azimuth: 23, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'Program-track SAR-2 before 10:18 and confirm the 1397 MHz beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-2-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar2', + nice: ['T0153', 'K0740'], + title: 'Decode SAR-2 Clean', + description: + 'Lock the 1370 MHz carrier and hold C/N above 8 dB into the high-elevation segment, roughly 10:20:30 to 10:23. Log the clean number before anything else arrives; it is the "before" in the report.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'read-the-cycle', + nice: ['S0648', 'K0926'], + title: 'Read the Cycle', + description: + 'From about 10:23:30 the carrier is back: 1374 MHz IF, 4 MHz above the video and inside its band. Frame it on a 2 MHz span and watch it for two minutes. It is not steady this time.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2'], + conditions: [ + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1374 MHz', + params: { centerFrequency: 1374e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + { + type: 'signal-detected', + description: 'Carrier Seen at 1374 MHz', + params: { + signalId: 'INTERFERER-gw-carrier-sar2', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Cadence Read', + params: { + character: Character.SYSTEM, + question: 'Sixty seconds on, thirty off, repeating to the second, and still no Doppler. What does the cadence add to Monday night’s reading?', + options: [ + 'Intent: accidents are continuous or erratic, and a clean minute-scale on/off is a timer or a hand on a switch - an indicator, not proof', + 'Nothing new: duty cycle depends on the receiver AGC recovering, not on the source; the timing is yours, not theirs', + 'A radar: rotating antennas sweep past on a fixed period, and a minute-scale cadence is a slow scan rate', + 'A failing supply: a dying regulator on a neighbouring uplink cycles as it heats, which reads as a regular on/off', + ], + correctIndex: 0, + explanation: + 'Radar periods are seconds; a thermal cycle drifts; an accident is continuous or random. A minute-scale cadence that repeats to the second is a switch. It argues intent and it goes in the report as a measured number - first seen, period, on-time - not as a conclusion.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'call-the-cycle', + nice: ['S0648', 'K0926', 'K0682'], + title: 'Call It', + description: + 'Fiona has SAR-2 clean from Shetland. Your C/N drops 6 dB every time the carrier is on. Read the key status and the carrier, then decide what the station does about it - the pass is not over.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-cycle'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'carrier-seen', + type: 'signal-detected', + description: 'Carrier read on RX Analysis', + params: { + signalId: 'INTERFERER-gw-carrier-sar2', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + id: 'key-seen', + type: 'rx-key-status', + description: 'RX key status read on RX Analysis', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'What the station does about it', + params: { + character: Character.PRIYA_SHARMA, + prompt: 'Same emitter, new frequency, and now it cycles. Fiona is clean. What do you do with the rest of this pass?', + evidence: ['carrier-seen', 'key-seen'], + decisionOptions: [ + { + label: 'Deliberate interference - notch it, keep the pass, report it with the cadence measured', + correctWhen: { + all: [ + { fact: 'interference-active', is: true }, + { fact: 'equipment-fault-active', is: false }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Interference event 2: 1374 MHz IF, 60 s on / 30 s off, notch applied, regulator package in progress' }, + }, + { + label: 'Intrusion on the link - hold the receiver and escalate to CSIRT now', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { + auditEvent: { + id: 'evt-op-escalation-sar2', + actor: 'op-duty', + action: 'Escalated to CSIRT: suspected intrusion on SAR-2 link (no crypto indication)', + category: 'access', + severity: 'warning', + }, + log: 'CSIRT escalation raised on the SAR-2 downlink', + }, + feedback: 'The key reads Valid and every frame decodes in the off phase. This is denial at the RF layer, and holding the receiver hands them the pass for free.', + }, + { + label: 'Receive-chain fault - an LNB spur that cycles with temperature; raise a ticket', + correctWhen: { fact: 'equipment-fault-active', is: true }, + consequence: { log: 'Maintenance ticket raised against GW-01 LNB (no fault found)' }, + feedback: 'A spur does not choose a new frequency to sit inside a different bird’s band, and it does not keep a thirty-second beat.', + }, + { + label: 'Propagation - a ducting layer bringing in a distant terrestrial link; wait it out', + correctWhen: { fact: 'weather-attenuation-dominant', is: true }, + feedback: 'Ducting does not switch on and off to the second, and it does not follow your frequency plan from one pass to the next.', + }, + ], + explanation: + 'Same emitter, in band on a second bird, on a timer: deliberate interference, not intrusion and not a fault. The station’s job is to keep the pass with the notch, measure everything, and hand the finding to the regulator. Who and where is theirs to find.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'notch-the-carrier', + nice: ['S0593', 'K0737'], + title: 'Notch the Carrier', + description: 'Receive-side mitigation: a notch on the carrier at 1374 MHz IF, 2 MHz wide, 30 dB deep, notch 0. The video loses a sliver of its band and keeps its lock.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-cycle'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'RX Analysis Open', + params: { tab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'notch-filter-configured', + description: 'Notch at 1374 MHz IF on the Carrier', + params: { + notchCenterFrequency: 1374, + notchCenterFrequencyTolerance: 1, + notchBandwidth: 2, + notchBandwidthTolerance: 1, + notchDepth: 30, + notchDepthTolerance: 10, + notchIndex: 0, + }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Why the Notch', + params: { + character: Character.SYSTEM, + question: 'Why notch the carrier rather than move the modem off it?', + options: [ + 'The video is where it is: 1370 is the bird’s frequency, not yours to move; the notch removes their energy and keeps your carrier', + 'A notch is faster to set than a retune: the modem takes a minute to relock and the pass has less than that left', + 'Moving the modem would be an admission the link is denied; a notch keeps the incident at the RF layer on paper', + 'The notch is the only control the console offers during a pass; the modem frequency locks while program-track is engaged', + ], + correctIndex: 0, + explanation: + 'You receive on the frequency the satellite transmits on. There is nowhere to retune to. The notch is the surgical tool: it takes a megahertz of their signal out of your 36, costs you a fraction of a dB, and is reversible the moment they stop.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'log-the-first-event', + nice: ['K0946', 'T1580'], + title: 'Log Event 1', + description: + 'LOS 10:27:34; the carrier is still cycling on an empty sky. Put the first event in the report as evidence: frequency, cadence, first seen, effect, mitigation, and Shetland’s confirmation.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['notch-the-carrier'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Event 1 Recorded', + params: { + character: Character.SYSTEM, + question: 'The event outlived the pass again. Which line goes in the report?', + options: [ + '10:23:30 carrier 11726 MHz RF / 1374 IF, ~1 MHz, 60 s on 30 s off, no Doppler, present after LOS; C/N -6 dB when on; notched 10:26; SH-02 clean', + 'Interference confirmed on SAR-2, same as Monday; the emitter is the one reported on 12 April and is still operating', + 'Carrier at 1374 MHz seen during the SAR-2 pass; the notch handled it and the decode completed; no further action at this station', + 'Deliberate jamming of SAR-2 from a ground site south-west of Galway, 10:23 to 10:31; recommend the regulator locate the source', + ], + correctIndex: 0, + explanation: + 'The line the regulator can act on has numbers, times and a second station in it. "Same as Monday" and "deliberate" are conclusions; the report carries the measurements those conclusions will be drawn from.', + pointPenalty: 5, + documentSection: 'Events', + documentLine: + 'Event 1: 10:23:30Z carrier 11726 MHz RF (1374 MHz IF), ~1 MHz, cycle 60 s on / 30 s off, no Doppler, persists after SAR-2 LOS 10:27:34Z to ~10:31:30Z. Victim MERIDIAN-SAR-2 video 11730 MHz, C/N 11 dB -> 5 dB while on. Notch 1374/2 MHz/30 dB applied 10:26Z, lock held. SH-02 (F. MacLeod) clean on the same pass.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'rx-chain-ready-sar1', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-1', + description: 'SAR-1 rises at 10:40:00 from azimuth 136. Retune modem 1 to 1414 MHz and the analyzer to 1414 with the 40 MHz span. Leave notch 0 where it is.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-first-event'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1414 MHz', + params: { centerFrequency: 1414e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Retarget program-track to SAR-1 and confirm the 1389 MHz beacon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready-sar1'], + conditions: [ + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-sar1-under-it', + nice: ['S0593', 'K0737', 'T0153'], + title: 'Decode SAR-1 Under It', + description: + 'From 10:41:30 the carrier is back at 1410 MHz - inside SAR-1’s band this time, the S17 frequency, same cadence. Notch 1 on 1410, 2 MHz, 30 dB, and hold C/N above 7 dB through the high-elevation segment.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'notch-filter-configured', + description: 'Notch at 1410 MHz IF on the Carrier', + params: { + notchCenterFrequency: 1410, + notchCenterFrequencyTolerance: 1, + notchBandwidth: 2, + notchBandwidthTolerance: 1, + notchDepth: 30, + notchDepthTolerance: 10, + notchIndex: 1, + }, + mustMaintain: true, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 7 dB', + params: { modemNumber: 1, minCNRatio: 7, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'it-followed-the-plan', + nice: ['K0682', 'K0683', 'S0648'], + title: 'It Followed the Plan', + description: 'Two passes, two birds, two frequencies - each one inside the band of the satellite you were receiving at the time. Record what that implies.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar1-under-it'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Implication Recorded', + params: { + character: Character.SYSTEM, + question: 'The carrier sat in SAR-2’s band during SAR-2 and in SAR-1’s band during SAR-1. What does that tell you, and what does it not?', + options: [ + 'Whoever runs it knows the frequency plan and the schedule - the 12 April export in use; it does not tell you who, or from where', + 'Whoever runs it is tracking the satellites with their own receiver and following the video; the export is unrelated', + 'The station is being probed from inside: only someone on the console could time the change to the retune', + 'Coincidence: two Ku carriers near two downlinks is within the noise of a busy band, and the report should say so', + ], + correctIndex: 0, + explanation: + 'You changed frequency between passes because the plan said to; the carrier changed with you, to a frequency inside the new band, at the right time. That is the configuration export from Monday night, in use. It is an indicator of capability and of intent. It is not attribution, and the report does not say who.', + pointPenalty: 5, + documentSection: 'Events', + documentLine: + 'Event 2: 10:41:30Z carrier 11690 MHz RF (1410 MHz IF), ~1 MHz, same cadence, inside the MERIDIAN-SAR-1 video band during the SAR-1 pass; notch 1 applied, C/N held > 7 dB. Assessment: the interferer is following the station frequency plan across passes (cf. INC-2027-0412-GW01, configuration export 12 Apr 02:33). No attribution.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'file-the-regulator-report', + nice: ['K0684', 'K0946', 'T1580'], + title: 'File the Regulator Package', + description: 'LOS 10:49:29. Assemble the package for ComReg, copied to Rotterdam: the fields the regulator needs to act, and nothing the regulator has to take on trust.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['it-followed-the-plan'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Package Assembled', + params: { + character: Character.SYSTEM, + question: 'Which set of fields makes the regulator package actionable?', + options: [ + 'Victim and interferer parameters (frequency, bandwidth, cadence, first and last seen), captures at stated settings, the second-station confirmation, impact and mitigation - all timestamped', + 'The interferer’s identity and site, worked out here from the bearing and the frequency, with a request that the regulator confirm it', + 'A summary: SAR-1 and SAR-2 interfered with on 14 April, notched, decodes completed; details available from the station on request', + 'The incident record from 12 April in full, with today’s events appended, so the regulator sees the whole picture from the spray onward', + ], + correctIndex: 0, + explanation: + 'The package is evidence, not conclusion: what was received, at what settings, when, at which sites, with what effect. Attribution belongs to the regulator and to whoever has direction-finding equipment. The security incident is a separate record with a separate reader.', + pointPenalty: 5, + documentSection: 'Regulator', + documentLine: + 'IR-2027-0414-GW01 to ComReg, cc Rotterdam: victim MERIDIAN-SAR-2 (11730 MHz V) and MERIDIAN-SAR-1 (11686 MHz V), GW-01 Galway 53.27N 9.05W. Interferer: 11726 MHz then 11690 MHz, ~1 MHz, 60 s on / 30 s off, no Doppler, terrestrial. First seen 12 Apr 02:25:30Z; today 10:23:30-10:31:30Z and 10:41:30-10:48:30Z. Captures at 2 MHz span attached. SH-02 Shetland clean on both passes. Impact: C/N -6 dB when on; mitigated by IF notch, decodes completed. Geolocation referred.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'close-and-debrief', + nice: ['T1580', 'K0645', 'S0593'], + title: 'Close the Report and Debrief', + description: 'Two events, two notches, two decodes. Close the report and write the line that changes tomorrow’s sweep.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['file-the-regulator-report'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Debrief Line', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line?', + options: [ + 'The export is live: they know every IF on the station. Pre-stage a notch for every planned downlink before AOS, and treat the plan as known to them until Rotterdam changes it', + 'Both decodes completed and the notches worked; the procedure held and no change is needed beyond keeping the report current', + 'The analyzer should be left on a 2 MHz span at all times; the 40 MHz framing delayed the read of the carrier by a minute', + 'The second pass should have been flown from Shetland; the interferer cannot reach SH-02, so Galway should stand down on SAR passes', + ], + correctIndex: 0, + explanation: + 'Notch 0 was set for 1374 and the carrier arrived at 1410. You knew the plan; so did they. Tomorrow the notches are staged before the pass, and the plan is assumed compromised until it is reissued. That is the line that changes something; Shetland is not a fix for Galway.', + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed 10:56Z. Debrief: interferer follows the station frequency plan; pre-stage IF notches for every planned downlink in the pre-pass sweep; request Rotterdam reissue the IF plan (INC-2027-0412-GW01 export assumed live). Open: regulator package filed, attribution referred.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 09:41] +

+

+ "Your Monday report is with ComReg and with me. They can act on measurements, not on a story: if that carrier comes back today I want frequency, bandwidth, cadence, first seen, and a second station saying it did not hear it. Fiona is flying both passes from Shetland in parallel. Keep your decodes; the report is what you do between them." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'read-the-cycle': { + text: ` +

+ Clean up here, eleven dB, nothing in the band. Sixty on, thirty off, is it? That is a timer. Write the seconds down; someone will ask. +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'call-the-cycle': { + text: ` +

+ Good call, and the right order. Keep the pass; a receiver you switch off is a pass they took from you for free. Measure everything. I will read the record after your second bird. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'it-followed-the-plan': { + text: ` +

+ They read your plan. Assume they have all of it - every IF, every window - until Rotterdam gives you a new one. That is not a conclusion for the regulator; it is a fact for us. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'file-the-regulator-report': { + text: ` +

+ Package received. Two sites, two frequencies, a cadence to the second - that is a report they can take to a van with an antenna on it. I will reissue the IF plan tonight. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'close-and-debrief': { + text: ` +

+ Two passes kept, two events measured, nothing attributed that should not be. Stage your notches tomorrow. They will try something else. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario19.ts b/src/campaigns/nats-eu/scenario19.ts new file mode 100644 index 00000000..239a0e97 --- /dev/null +++ b/src/campaigns/nats-eu/scenario19.ts @@ -0,0 +1,928 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 19 - "Frequency Agility" / TRANSEC Ride-Through (Gray Zone arc 3/8) + * + * The adversary moves from the downlink to the uplink. Rotterdam reissued the + * IF plan after S18 and loaded a hop-set key on MERIDIAN-SAR-2 this morning; + * the station has the matching key in its COMSEC store, inactive. The SAR-2 + * pass is a commanding pass: chain up, one clean command, and then - two + * minutes into the window - a jammer on the 14035 MHz command uplink at the + * satellite. The next command NAKs with the key Valid, Doppler engaged and the + * window open. The operator calls it (uplink denial, not a key problem), keys + * the hop set with Anneke, takes the waveform to hopping, gets sync, and the + * resend ACKs through the jamming. Then SAR-1 receive-only, untouched: a + * jammer reaches what it can reach. + * + * Phase 18: `call-the-nak` is graded on the new `uplink-jammed` fact + * (CommandingManager.isUplinkJammed: a transponder-path event on the target + * bird overlapping settings.commanding.uplinkFrequencyHz, with no TRANSEC + * sync). M7 TRANSEC (transec-mode-set / transec-sync-locked) debuts here as a + * response mechanic; hop-sync is what clears the denial. + * + * Clock starts 2027-04-16 13:00:00 UTC. Passes (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 13:00:00Z): + * + * MERIDIAN-SAR-2: AOS T+20.02 (13:20:01Z, az 138), max el 27.9 deg at T+24.72 + * (13:24:43Z, 765 km), LOS T+29.48 (13:29:29Z, az 353), 9.5 min, + * northbound. The commanding pass; window 13:20:21 .. 13:29:09 + * (T+1221 .. T+1749). + * MERIDIAN-SAR-1: AOS T+42.02 (13:42:01Z, az 005), max el 25.2 deg at T+46.76 + * (13:46:46Z, 839 km), LOS T+51.49 (13:51:29Z, az 224), 9.5 min, + * southbound. Receive only. C/N >= 8 dB roughly 13:44:30 .. 13:49. + * + * The jammer: settings.interferenceEvents 'sar2-uplink-jam', transponder + * path at SAR-2's 14035 MHz telecommand uplink, 2 MHz, H-pol, from T+1350 s + * (13:22:30Z) for 400 s to 13:29:10Z - the rest of the window. Relayed to the + * downlink like any transponder event, so it is visible on the analyzer too. + * + * Staged state (scenario-local clone of GW-01): TX modem 1 on 1405 MHz (SAR-1 + * command IF) from yesterday, to be retuned to 1435 for SAR-2; RX modem 1 on + * 1414. BUC muted. Hop-set key loaded but the waveform fixed. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0925: Knowledge of wireless technologies and their security + * - S0593: Skill in handling incidents + * - K0751: Knowledge of system threats + * + * Supporting Codes: + * - K0926: Knowledge of signal jamming tools and techniques + * - S0648: Skill in detecting anomalies + * - S0675: Skill in configuring RF transmit equipment within safe limits + * - K0874: Knowledge of cryptographic key management + * - T1567: Configure system hardware, software, peripheral equipment + * - K0773: Knowledge of telecommunications principles and practices + * - S0421: Skill in operating network equipment + * - T0153: Monitor network capacity and performance + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + */ + +/** MERIDIAN-SAR-2 (61702) at the S19 epoch: the 28 deg commanding pass, northbound from az 138. */ +const SAR2_S19_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27106.54166667 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61702 98.4000 58.0000 0010000 90.0000 229.7500 15.59860000123456' as TleLine2, +}; + +/** MERIDIAN-SAR-1 (61701) at the S19 epoch: the 25 deg receive pass, southbound from az 005. */ +const SAR1_S19_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27106.54166667 .00001000 00000-0 10000-3 0 9992' as TleLine1, + tle2: '2 61701 98.0000 200.0000 0010000 90.0000 211.7500 15.57020000123457' as TleLine2, +}; + +const meridianSar1S19 = createMeridianSar1(SAR1_S19_TLE); +const meridianSar2S19 = createMeridianSar2(SAR2_S19_TLE); + +/** GW-01 as yesterday left it: TX on SAR-1's command IF, BUC muted. Deep clone, never spread. */ +const galwayAgility: GroundStationConfig = structuredClone(galwayGroundStation); +galwayAgility.receivers![0].modems![0] = { + ...galwayAgility.receivers![0].modems![0], + frequency: 1414 as MHz, +}; +galwayAgility.rfFrontEnds[0].buc = { ...galwayAgility.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario19Data: ScenarioData = { + id: 'nats-eu-scenario19', + url: 'nats-eu/scenarios/nats-eu-scenario19', + imageUrl: 'nats/19/card.png', + number: 19, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario18'], + title: 'Frequency Agility', + subtitle: 'TRANSEC Ride-Through (Gray Zone 3/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `13:00, Galway. Rotterdam reissued the IF plan overnight and loaded a hop-set key on SAR-2 this morning; yours is in the COMSEC store, inactive. SAR-2 at 13:20 is a commanding pass - a housekeeping dump and a payload status. SAR-1 at 13:42 is receive-only.

Whoever has been in your band has had two days to read the new plan. If the uplink is next, the command console will tell you in one word, and the key will still say Valid. Know what that means before it happens.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO, 12600 MHz BUC LO) + HPA', + 'TT&C Commanding Console with TRANSEC waveform (Security console)', + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayAgility, shetlandGroundStation], + satellites: [meridianSar1S19, meridianSar2S19], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-16', + scenarioStartWallTime: '13:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-19?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Uplink Denial Record UD-2027-0416-GW01', + description: 'The SAR-2 command window of 16 April: what was sent, what came back, what was done, and what the hop set cost.', + }, + + // M2/M5 - the commanding pass. The window is the SAR-2 pass with 20 s + // guards. uplinkFrequencyHz couples the console to interference on the + // command carrier: while the jammer below is in its envelope and TRANSEC + // is not synced, commands NAK 'uplink-jammed'. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61702, + windowStartS: 1221, + windowEndS: 1749, + requireDopplerComp: true, + requireValidKey: true, + uplinkFrequencyHz: 14035e6, + commands: [ + { id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }, + { id: 'PLD-STATUS', label: 'Payload status request' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + + // M7 - the TRANSEC waveform on the command carrier. Hop set keyed at both + // ends: Rotterdam loaded SAR-2's this morning; the station loads its own + // from the COMSEC store on Anneke's call. + transec: { + groundStationId: 'GW-01', + hopChannelsHz: [14031e6, 14033e6, 14035e6, 14037e6, 14039e6], + requireKey: true, + }, + + // The jammer: on SAR-2's command uplink at the satellite, two minutes + // into the window, for the rest of it. A fixed carrier is denied; a keyed + // hop set is not something a jammer parked on one frequency can follow. + interferenceEvents: [ + { + id: 'sar2-uplink-jam', + satelliteNoradId: 61702, + frequency: 14035e6, + bandwidth: 2e6, + power: 5, + polarization: 'H', + startTime: 1350, + duration: 400, + periodSeconds: 400, + onSeconds: 400, + }, + ], + + // M6 - the log as it stands; the escalation option in call-the-nak writes here. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'svc-legacy', name: 'Telemetry bridge (legacy, decommissioned 14 Jan)', role: 'Service Account', status: 'disabled' }, + ], + events: [ + { + id: 'evt-plan-reissued', + timeS: 0, + timestampLabel: '15 Apr 21:10 UTC', + actor: 'op-charlie', + action: 'IF plan MER-IF-2027-04b applied (reissued by Rotterdam after IR-2027-0414)', + category: 'config', + severity: 'info', + }, + { + id: 'evt-hopkey-loaded', + timeS: 0, + timestampLabel: '08:20 UTC', + actor: 'op-charlie', + action: 'TRANSEC hop-set key HOP-SAR2-04 loaded to COMSEC store (inactive)', + category: 'command', + severity: 'info', + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '12:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-svc-poll-1300', timeS: 0, timestampLabel: '13:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0925'], + title: 'Read the Brief', + description: + 'Open the shift brief: the reissued plan, the hop set keyed at Rotterdam, the two commands SAR-2 needs inside its window, and what a NAK with a Valid key means.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Hop Set Understood', + params: { + character: Character.SYSTEM, + question: 'Rotterdam loaded a hop-set key on SAR-2 this morning and the station has the matching key in store. Why is the waveform still fixed at 13:00?', + options: [ + 'Hopping is a response, not a default: it costs command-link margin and burns a key that has to be replaced, so it is used when a fixed carrier is denied and not before', + 'The console cannot hop until the window opens: hop-sync needs the bird in view, so the mode switch is made at AOS every pass', + 'The key is loaded but not verified: Rotterdam has to send a hop-verify command before the station is allowed to enable the mode', + 'It is always fixed on the first command of a pass: the housekeeping dump must go on the known carrier so the bird can confirm the plan', + ], + correctIndex: 0, + explanation: + 'A hop set is a consumable. Once it has been used on the air it is assumed observed and gets retired. You hop when a fixed carrier is being denied, on a call, with both ends keyed - and you say so in the record. Twenty minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Board read. Confirm the active alarm state on GW-01 before the uplink goes anywhere near the air.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 13:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the pass', + 'TRANSEC alarm - hop-set key loaded but waveform fixed, flagged as a mismatch', + 'BUC muted alarm - the uplink is inhibited and the board wants it cleared', + ], + correctIndex: 0, + explanation: + 'Empty sky, not a fault. A muted BUC is the correct state for a station with nothing to send, and a loaded-but-inactive hop key is exactly what the brief says it should be. The AGC rail clears at AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover, BUC on the external reference. Doppler compensation and a hopping waveform both key off it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'tune-the-uplink', + nice: ['S0421', 'K0773', 'T1567'], + title: 'Tune the Uplink', + description: + "TX modem 1 is on SAR-1's 1405 MHz command IF from yesterday. Retune it for SAR-2's 14035 MHz TT&C uplink through the 12600 MHz BUC LO, and engage uplink Doppler compensation on the TT&C console before the window opens at 13:20:21.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX 1435 MHz', + params: { modemNumber: 1, frequency: 1435e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Uplink IF Arithmetic', + params: { + character: Character.SYSTEM, + question: 'BUC LO 12600 MHz, low-side. SAR-2 TT&C uplink 14035 MHz. Where does the modem go?', + options: ['1435 MHz (RF minus LO, low-side)', '1405 MHz (leave it, same modem IF)', '1370 MHz (the SAR-2 receive IF)', '26635 MHz (LO plus RF, low-side)'], + correctIndex: 0, + explanation: + 'Low-side on the uplink: IF = RF - LO = 14035 - 12600 = 1435. 1405 is SAR-1 and 1370 is what you receive SAR-2 on, not what you send to it. Nothing about the reissued plan changed the arithmetic; only the numbers.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'key-check', + nice: ['K0874', 'T0431'], + title: 'Key Check', + description: 'Command key Valid on the TX chain. Remember what it reads now; it will read the same later, and that will matter.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-the-uplink'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-key-status', + description: 'Command Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-2', + description: 'Retune RX modem 1 to 1370 MHz and frame the analyzer on 1370 with a 40 MHz span. The uplink is the story today, but the downlink is how you see it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['key-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 13:20:01 from azimuth 138, northbound. Park the tracker on the rise azimuth at 5 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 138 / El 5', + params: { azimuth: 138, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'Program-track SAR-2 before 13:20 and confirm the 1397 MHz beacon on RX analysis. The chain stays cold until she is locked.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-2-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'chain-up', + nice: ['S0675', 'S0421', 'K0645'], + title: 'Chain Up in Order', + description: 'Window open at 13:20:21. Carrier into the muted BUC, unmute, HPA. Two minutes.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'first-command', + nice: ['T1567', 'K1032'], + title: 'Housekeeping Dump', + description: 'Send HK-DUMP and confirm the acknowledgement. A clean ACK on the fixed carrier is the baseline for everything that follows.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up'], + conditions: [ + { + type: 'command-acknowledged', + description: 'HK-DUMP Acknowledged', + params: { commandId: 'HK-DUMP' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'ACK Read', + params: { + character: Character.SYSTEM, + question: 'HK-DUMP ACKed inside the window on the fixed carrier. What has that proven about the command link right now?', + options: [ + 'That the bird heard this command: key, Doppler, window and carrier were all good at that instant - and nothing more than that instant', + 'That the link is clear for the pass: an ACK is a link check, and the next command can go without re-reading the console', + 'That the hop set will not be needed today: a denied carrier would have shown on the first command, not a later one', + 'That the reissued plan is confirmed on the bird: an ACK on the new IF means SAR-2 has the same plan the station has', + ], + correctIndex: 0, + explanation: + 'An ACK is receipt of one command at one moment. It says nothing about the next thirty seconds, which is why the console is read before every send and not once per pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-the-nak', + nice: ['S0648', 'K0926', 'K0751'], + title: 'Call the NAK', + description: + 'From 13:22:30 PLD-STATUS comes back NAK: uplink denied. Key reads Valid, Doppler engaged, window open, and there is a new carrier on the analyzer where the transponder returns the uplink. Read the key and the console, then call it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['first-command'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'key-seen', + type: 'tx-key-status', + description: 'Command key read on TX Chain', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + id: 'console-seen', + type: 'uplink-doppler-comp-enabled', + description: 'TT&C console read', + params: { requiresObservation: true, observationTab: 'commanding' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Why the NAK', + params: { + character: Character.ANNEKE_VISSER, + prompt: 'Your PLD-STATUS did not reach me and your key shows Valid at my end too. Window is open for six more minutes. What is it, and what do you want to do?', + evidence: ['key-seen', 'console-seen'], + decisionOptions: [ + { + label: 'Uplink denial - key the hop set at both ends and take the waveform to hopping', + correctWhen: { + all: [ + { fact: 'uplink-jammed', is: true }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Uplink denial called on the SAR-2 command carrier; hop set requested at both ends' }, + }, + { + label: 'Key problem - begin a command-key rotation and resend under the new key', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { log: 'Command-key rotation begun on a Valid key (no key fault)' }, + feedback: 'Both ends read Valid, and a key rejection is its own reason on the console. Rotating a good key in an open window spends the window on nothing.', + }, + { + label: 'Doppler compensation dropped - re-engage it and resend on the fixed carrier', + feedback: 'Compensation is engaged; the console would have said so if it were not. A resend on the same carrier is another NAK.', + }, + { + label: 'Window closed early - stand down and take the payload status on SAR-1', + correctWhen: { fact: 'command-window-open', is: false }, + consequence: { log: 'Commanding stood down inside an open window' }, + feedback: 'The window badge reads OPEN and SAR-1 is a receive-only pass with no command uplink. Standing down here hands them the window.', + }, + ], + explanation: + 'Every reason the console can give for itself - key, Doppler, window - is clean, and the transponder is returning a carrier that is not yours. The uplink is being denied at the satellite. Nothing on this station is broken and no key is compromised; a fixed carrier is being sat on, and the answer to that is to stop being on one frequency.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'go-to-hopping', + nice: ['K0925', 'S0593', 'T1567'], + title: 'Go to Hopping', + description: + 'Anneke keys SAR-2. On the Security console: load the hop-set key from the store, set the waveform to frequency hopping, and confirm SYNC LOCKED. Both ends, or it is nothing.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-nak'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'transec-mode-set', + description: 'Waveform in Hopping Mode', + params: { transecMode: 'hopping' }, + mustMaintain: true, + }, + { + type: 'transec-sync-locked', + description: 'Hop-Sync Locked', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Both Ends', + params: { + character: Character.SYSTEM, + question: 'Why does hop-sync need the key loaded at both ends before the mode does anything?', + options: [ + 'The hop set is the shared secret: the bird can only follow the carrier if it knows the same sequence, and a jammer parked on one frequency cannot', + 'Because the station key unlocks the mode switch: without it the console refuses to leave fixed mode, as a safety interlock', + 'Because the bird has to acknowledge the mode change on the fixed carrier first, which is why the first hop is always sent unkeyed', + 'It does not, strictly: the key only encrypts the command payload, and hopping would work unkeyed at a lower data rate', + ], + correctIndex: 0, + explanation: + 'Frequency hopping is only agile to someone who does not have the sequence. The key is the sequence. With it at both ends the carrier moves and the bird moves with it; without it at one end, one of you is transmitting into nothing.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'ride-through', + nice: ['K0925', 'T1567', 'S0593'], + title: 'Ride It Through', + description: 'Sync locked. Resend PLD-STATUS and take the ACK through the jamming. The jammer is still there; it is just no longer where you are.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['go-to-hopping'], + conditions: [ + { + type: 'command-acknowledged', + description: 'PLD-STATUS Acknowledged Under TRANSEC', + params: { commandId: 'PLD-STATUS' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'What Hopping Did', + params: { + character: Character.SYSTEM, + question: 'The ACK came back with the jammer still on the analyzer. What did hopping change, and what did it not?', + options: [ + 'It moved your carrier out from under the jammer, at the cost of margin and a hop set that is now spent; it did not remove the jammer or tell you who it is', + 'It removed the jammer from the transponder: a hopping carrier captures the AGC and the fixed interferer is suppressed at the bird', + 'It proved the jammer was never on the uplink: the NAK was a console fault that the mode change happened to reset', + 'It made the link permanently agile: the hop set stays in use for the rest of the arc and the fixed carrier is retired', + ], + correctIndex: 0, + explanation: + 'Agility is a way past a denial, not a cure for it. The carrier is spread over five channels a jammer on one frequency cannot cover; the bird follows because it has the sequence. The jammer is still transmitting, the set is now burned, and the report still has to say what was seen.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645'], + title: 'Safe the Uplink', + description: 'LOS 13:29:29. Chain down in the mirror order: HPA output off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['ride-through'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'log-the-denial', + nice: ['K0946', 'T1580'], + title: 'Log the Denial', + description: + 'Put the window in the record: the clean ACK, the NAK and its reason, what the console showed, the call, the hop set and when sync locked, the ACK that followed.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Window Recorded', + params: { + character: Character.SYSTEM, + question: 'Which line goes in the record for the SAR-2 window?', + options: [ + '13:21 HK-DUMP ACK fixed; 13:23 PLD-STATUS NAK uplink denied, key Valid, Doppler on, window open; called denial 13:24; hop set keyed both ends, sync 13:25; PLD-STATUS ACK hopping 13:26', + 'SAR-2 window worked: two commands acknowledged, one on the second attempt after a transient uplink problem cleared', + 'Jamming attack on the SAR-2 command link defeated by TRANSEC; the adversary has moved from the downlink to the uplink and is escalating', + 'PLD-STATUS delayed three minutes by interference; the hop set was activated as a precaution and both commands completed', + ], + correctIndex: 0, + explanation: + 'Times, what came back, what the console read, what was done, in order. "Transient" and "attack" are conclusions; the record has the NAK reason and the key state at the moment of the NAK, which is what anyone reading it later needs.', + pointPenalty: 5, + documentSection: 'Window', + documentLine: + 'SAR-2 window 13:20:21-13:29:09Z: HK-DUMP ACK 13:21 (fixed carrier 14035 MHz). PLD-STATUS NAK 13:23 "uplink denied - carrier jammed"; key Valid both ends, Doppler comp on, window open; transponded return shows a 2 MHz carrier on the uplink. Called uplink denial 13:24. Hop set HOP-SAR2-04 keyed at Rotterdam and GW-01, mode hopping, SYNC LOCKED 13:25. PLD-STATUS resent, ACK 13:26 under TRANSEC. Jammer present through LOS.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-sar1', + nice: ['T0153', 'K0740', 'K0926'], + title: 'Decode SAR-1 Untouched', + description: + 'SAR-1 rises at 13:42:01 from azimuth 005. Retune RX modem 1 to 1414 MHz, retarget program-track, lock the carrier and hold C/N above 8 dB through the high-elevation segment. Nothing should touch it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-denial'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Reach Read', + params: { + character: Character.SYSTEM, + question: 'SAR-1 decoded clean with nothing in the band. Why was this pass untouched when SAR-2 was denied twenty minutes ago?', + options: [ + 'A jammer reaches what it can reach: the SAR-2 denial was an uplink at the satellite, and SAR-1 was a receive-only pass with nothing of yours on the air to sit on', + 'Because the hop set is still active: once the waveform is hopping, every carrier on the station is agile, downlinks included', + 'Because the adversary stood down after the ride-through: a defeated jammer stops for the day, and SAR-1 fell after that', + 'Because SAR-1 uses a different LNB LO: the reissued plan moved SAR-1 to a band the jammer does not cover', + ], + correctIndex: 0, + explanation: + 'Uplink denial is aimed at the satellite; downlink interference is aimed at the station. They are different attacks on different ends of the link. A receive-only pass has no uplink to deny, and a jammer that was on 14035 MHz at SAR-2 has no reach into what SAR-1 sends you on 11686.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'close-and-debrief', + nice: ['T1580', 'K0645', 'K0874'], + title: 'Close the Record and Debrief', + description: 'Both passes worked. Close the record: what is open, what Rotterdam does about the hop set, and the line that changes the next commanding pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar1'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Debrief Line', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line?', + options: [ + 'A hop set used on the air is a hop set observed: HOP-SAR2-04 is retired, the next set is staged before every commanding pass, and the mode switch is rehearsed in the pre-pass sweep', + 'Both commands completed and TRANSEC worked as designed; no change to the procedure beyond noting the jammer for the regulator', + 'Commanding passes should be flown from Shetland until the jammer is found: the uplink denial reaches Galway, not SH-02', + 'The first command of every pass should go out hopping: agility from AOS removes the NAK and the three minutes it cost', + ], + correctIndex: 0, + explanation: + 'The set that saved the window is now spent, and the switch cost three minutes of a nine-minute window because it was done for the first time under pressure. Stage the next set, rehearse the switch, hop on a call. Hopping from AOS burns a set every pass for a denial that may not come, and the denial was aimed at the satellite - Shetland would have been denied too.', + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed 13:58Z. Open: uplink jammer (regulator package per IR-2027-0414 to be extended), attribution referred. Action: HOP-SAR2-04 retired as observed; Rotterdam to stage HOP-SAR2-05 before the next commanding pass. Debrief: hop set staged and mode switch rehearsed in every commanding pre-pass sweep; hop on a called denial, not from AOS.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 12:44] +

+

+ "New IF plan is on your station as of last night. I loaded hop set HOP-SAR2-04 on SAR-2 at 08:20; your copy is in the store, inactive. SAR-2 at 13:20 is commanding: HK-DUMP, then PLD-STATUS. If your carrier is denied you call it and I key my end on the call - not before, the set is not free. SAR-1 after is receive only." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'first-command': { + text: ` +

+ HK-DUMP on my screen, acknowledged 13:21. Send the payload status when you are ready. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'call-the-nak': { + text: ` +

+ Denial it is. Keying SAR-2 now - HOP-SAR2-04 active at my end as of this moment. Load yours, go to hopping, tell me when you have sync. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'go-to-hopping': { + text: ` +

+ Sync at my end too. Resend PLD-STATUS. Whoever is on 14035 is about to be on it alone. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'ride-through': { + text: ` +

+ PLD-STATUS acknowledged, 13:26, under TRANSEC. Chain down at LOS. I will retire this set tonight and stage the next one; you did not spend it for nothing. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'close-and-debrief': { + text: ` +

+ Downlink Monday, uplink today. That is somebody working through what your station does, one end at a time. The record you just closed is how the next station on their list gets warned. Well kept. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario2.ts b/src/campaigns/nats-eu/scenario2.ts new file mode 100644 index 00000000..de4fab4a --- /dev/null +++ b/src/campaigns/nats-eu/scenario2.ts @@ -0,0 +1,782 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dB, dBm } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 2 - "Proving the Link" / Acceptance Testing & Link Budgets + * + * New mechanic: M1 link-budget / EIRP planning console (Link Analysis tab). + * GW-01 is still in commissioning, so every claimed number has to be proven on + * a test card: predict the C/N for the next MERIDIAN-SAR-1 pass from first + * principles, then measure it at three points on the pass and show the + * measured margin clears the demod threshold at culmination. + * + * Phase 16 rewrite: the acceptance shift around the pass. The uplink is + * confirmed cold, the reference and LNB are checked, the survey inputs are read + * off the rack before they go into the worksheet, the path loss is checked at + * the mask as well as at culmination, and the measurement is taken on the + * rising leg, at culmination and on the setting leg so the elevation curve is + * observed rather than asserted. A second bird runs as an optional regression. + * + * Clock: sim starts 2027-03-15 13:45:00 UTC (the brief freezes it until read); + * the passes are scenario 1's, so the RF envelope is the one Phase A validated: + * - MERIDIAN-SAR-1: AOS 14:03:10 (5 deg mask) az 000, max el 28.0 at 14:06:45 + * (761 km), LOS 14:10:18. Settled C/N through the real chain (harness): + * el 10 deg 5.5 dB, el 15 deg 7.2 dB, el 18 deg 8.0 dB, culmination 10.9 dB, + * symmetric on the way down. + * - MERIDIAN-SAR-2: AOS 14:18:42 az 130, max el 25.0 at 14:22:10, LOS 14:25:40; + * C/N >= 8 dB roughly 14:20:45 .. 14:23:45, peak 10.2 dB. + * + * Link-budget numbers (measured, not assumed - see + * test/campaigns/nats-eu-phase-b-validation.test.ts): + * - slant range at max elevation 761 km -> FSPL 171.4 dB at 11686 MHz + * (2270 km at the 5 deg mask -> 180.9 dB, which is why the window is the + * middle of the pass) + * - EIRP 28 dBm, GW-01 4m Ku gain 51.8 dBi, Tsys 88 K, BW 36 MHz, misc 1 dB + * - correct worksheet -> C/N 10.96 dB (expectedCNRDb 11.0, tolerance 1.0) + * - measured peak through the real chain: 10.93 dB at culmination + * - threshold 6 dB (QPSK 3/4 demod) + 2 dB required margin -> commit anywhere in + * the window 14:05:10 .. 14:08:20. + * + * Staged state (scenario-local clone of GW-01): BUC muted, as a receive-only + * acceptance should find it. + * + * NICE Framework Alignment: + * Primary Codes: + * - T0080: Test and evaluate system performance against requirements + * - S0015: Skill in conducting test events + * - K0740: Knowledge of system performance indicators + * + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - K0645: Knowledge of standard operating procedures + * - K0773: Knowledge of telecommunications principles and practices + * - T0431: Check system hardware availability, functionality, integrity + * - S0421: Skill in operating network equipment + * - T1580: Report service status to stakeholders + */ + +/** GW-01 for a receive-only acceptance: BUC muted. Deep clone, never spread. */ +const galwayAcceptance: GroundStationConfig = structuredClone(galwayGroundStation); +galwayAcceptance.rfFrontEnds[0].buc = { ...galwayAcceptance.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario2Data: ScenarioData = { + id: 'nats-eu-scenario2', + url: 'nats-eu/scenarios/nats-eu-scenario2', + imageUrl: 'nats/2/card.png', + number: 2, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario1'], + title: 'Proving the Link', + subtitle: 'Acceptance Testing & Link Budgets', + duration: '30-35 min', + missionType: 'Commissioning', + description: `GW-01 is not accepted yet. Yesterday you worked a pass; today you have to prove the station performs to the number on the contract, and sign a test card saying so.

The acceptance test is simple to state and unforgiving to fake: predict the carrier-to-noise ratio for the next MERIDIAN-SAR-1 pass from the link budget, then measure it on the rising leg, at culmination and on the setting leg, and show the two agree with margin over the demodulator threshold.

Charlie has left the site survey numbers on the console. Read the ones you can verify off the rack first. Do the arithmetic before AOS - the pass is seven minutes long and the useful part is shorter than that.`, + equipment: ['4m Ku-Band LEO Tracking Antenna', 'Ku-Band RF Front End (13100 MHz LNB LO)', 'Link Analysis Console', 'QPSK 3/4 Receiver'], + settings: { + isSync: true, + groundStations: [galwayAcceptance], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:45:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-2?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Acceptance Test Card GW-01-ATP-002', + description: 'MERIDIAN-SAR-1 imagery downlink, C/N at maximum elevation. Sections: Prediction, Measurement, Verdict.', + }, + + // M1 - the acceptance test card. expectedCNRDb is what a CORRECT worksheet + // yields from the numbers published in the objective description below; + // requiredMarginDb is measured against the live receiver at Commit Link. + linkBudget: { + label: 'GW-01 acceptance: MERIDIAN-SAR-1 downlink at max elevation', + expectedCNRDb: 11.0, + toleranceDb: 1.0, + thresholdCNRDb: 6, + requiredMarginDb: 2, + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the Test Card', + description: 'Open the shift brief and confirm you understand what acceptance requires: a predicted C/N, a measured C/N, and margin over threshold.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Test Card Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Acceptance Criteria Understood', + params: { + character: Character.SYSTEM, + question: 'Acceptance needs a predicted C/N, a measured C/N, and margin over the demod threshold. Which of those can you do before AOS?', + options: [ + 'The prediction - it comes from the link budget, not the pass.', + 'The measurement - the receiver reads C/N whether or not the bird is up.', + 'None of them. Everything waits for AOS.', + ], + correctIndex: 0, + explanation: 'Right. The budget is arithmetic on known geometry and hardware; do it now so the pass is spent measuring, not calculating. Eighteen minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: PRE-PASS - STATION STATE AND SURVEY INPUTS + // ============================================================ + { + id: 'tx-cold-check', + nice: ['T0431', 'K0645'], + title: 'Confirm the Uplink is Cold', + description: 'A receive-only acceptance: HPA output disabled and BUC muted before the pass. Confirm both on the TX chain.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Why Cold', + params: { + character: Character.SYSTEM, + question: 'Nothing on this test card transmits. Why check the TX chain at all before the pass?', + options: [ + 'An untasked carrier breaks flight rules, and an HPA with no drive amplifies its own noise', + 'The receiver cannot lock while the HPA is disabled, and a muted BUC drops the LNB reference', + 'The link budget needs the HPA output power, and the card cannot be computed without it', + 'The interlocks handle it either way, and the log shows whatever state the chain was in', + ], + correctIndex: 0, + explanation: + 'Cold and confirmed. A carrier radiated at a bird with no tasking breaks flight rules, and an HPA enabled with nothing behind it amplifies its own noise into the feed. The transmit half of the licence is a later card; today the station only listens, and it has to be seen to only listen.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-and-lnb', + nice: ['T0431', 'K0740'], + title: 'Reference and LNB Check', + description: 'GPSDO locked, LNB thermally stable. Both feed the system noise figure the test card assumes.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tx-cold-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'lnb-thermally-stable', + description: 'LNB Thermally Stable', + params: { requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'read-the-rack', + nice: ['S0015', 'K0740'], + title: 'Read the Survey Inputs Off the Rack', + description: + 'Before the survey numbers go into the worksheet, verify the one you can: LNB gain 65 dB on RX analysis. Then account for the system noise temperature on the card.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-and-lnb'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'lnb-gain-set', + description: 'LNB Gain 65 dB', + params: { gain: 65 as dB, gainTolerance: 0.5, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'System Noise Accounted For', + params: { + character: Character.SYSTEM, + question: 'The card puts Tsys at 88 K with the LNB at 60 K. Where do the other 28 K come from?', + options: [ + 'Sky and atmosphere at 28 degrees elevation plus the feed loss ahead of the LNA', + 'The GPSDO reference plus the phase noise it adds to the LNB local oscillator', + 'Rounding in the survey plus the 1 dB miscellaneous loss folded into the card', + 'The BUC, even when muted, plus its coupling through the OMT into the receive side', + ], + correctIndex: 0, + explanation: + 'Everything in front of the first amplifier adds noise: the sky the beam looks through, the atmosphere at that elevation, the feed and the OMT. Tsys is the sum referenced to the LNA input, and it is why the same LNB gives a different Tsys at a different elevation.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'open-link-analysis', + nice: ['T0080', 'S0015'], + title: 'Open the Link Analysis Console', + description: 'The Link Analysis tab holds the acceptance worksheet. Open it and read the survey numbers before you start entering values.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-rack'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Link Analysis Tab Open', + params: { tab: 'link-budget' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'compute-link-budget', + nice: ['T0080', 'S0015', 'K0740'], + title: 'Predict the Downlink C/N', + description: + 'Fill the worksheet for MERIDIAN-SAR-1 at maximum elevation and press Compute. Survey numbers: satellite EIRP 28 dBm; slant range at max elevation 761 km (free-space path loss 171.4 dB at 11686 MHz); GW-01 receive gain 51.8 dBi; system noise temperature 88 K; occupied bandwidth 36 MHz; miscellaneous losses 1 dB.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['open-link-analysis'], + timeLimitSeconds: 5 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'link-budget-computed', + description: 'Predicted C/N Matches Acceptance Truth', + params: {}, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'path-loss-check', + nice: ['K0773', 'K0740'], + title: 'Check the Path Loss at the Mask', + description: + 'The worksheet used 761 km at culmination. At the 5 degree mask the slant range is 2270 km. Work out the path loss there and what it means for the measurement window.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['compute-link-budget'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'FSPL at AOS', + params: { + character: Character.SYSTEM, + question: 'FSPL is 171.4 dB at 761 km. What is it at 2270 km, the range at the 5 degree mask?', + options: [ + '180.9 dB - about 9.5 dB more, so C/N at AOS is roughly 1.5 dB', + '176.1 dB - about 4.7 dB more, so C/N at AOS is roughly 6.3 dB', + '171.4 dB - no more at all, so C/N at AOS is still roughly 11 dB', + '190.4 dB - about 19 dB more, so C/N at AOS is roughly -8 dB', + ], + correctIndex: 0, + explanation: + 'Path loss goes with range squared: 20 log10(2270 / 761) = 9.5 dB, which takes the 11 dB at culmination down to about 1.5 dB at the mask. Ten times the log is 4.7 dB; forty times is 19 dB; neither is free-space loss. That single number is why the acceptance window is the middle of the pass and why nobody decodes at the horizon.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'review-pass-schedule', + nice: ['K1032', 'S0421'], + title: 'Confirm the Measurement Point', + description: 'Open the Pass Schedule tab and confirm when MERIDIAN-SAR-1 culminates. That is where the card wants the number.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['path-loss-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Culmination Read', + params: { + character: Character.SYSTEM, + question: 'When and where does the acceptance measurement get taken?', + options: [ + '14:06:45, 28 degrees elevation, 761 km - culmination', + '14:03:10, 5 degrees elevation, 2270 km - AOS', + '14:10:18, 5 degrees elevation, 2270 km - LOS', + '14:22:10, 25 degrees elevation, 761 km - SAR-2 culmination', + ], + correctIndex: 0, + explanation: + 'Culmination is the shortest range of the pass, so it is the best C/N the station will see on this geometry and the number the card is written against. The 14:22 culmination is SAR-2, which is not on the card.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: THE ACCEPTANCE PASS + // ============================================================ + { + id: 'track-for-acceptance', + nice: ['S0421', 'K1032'], + title: 'Track MERIDIAN-SAR-1', + description: + 'Put the antenna in program-track on MERIDIAN-SAR-1 before 14:03 so the measurement is taken on boresight, not on the shoulder of the beam. Confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-pass-schedule'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'rising-leg', + nice: ['T0080', 'K0740'], + title: 'Measure on the Rising Leg', + description: + 'Lock the 1414 MHz carrier and read C/N around 15 degrees on the way up (about 14:04:45). Expect roughly 7 dB: above the 6 dB demodulator threshold, below the acceptance line.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['track-for-acceptance'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N >= 5 dB on the Rising Leg', + params: { modemNumber: 1, minCNRatio: 5, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'commit-the-link', + nice: ['T0080', 'K0740', 'S0015'], + title: 'Measure and Commit the Link', + description: + 'With the receiver locked on the 1414 MHz downlink, return to Link Analysis and press Commit Link near culmination (14:05:10 to 14:08:20). Acceptance needs at least 2 dB of margin over the 6 dB demodulator threshold.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rising-leg'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'link-margin-met', + description: 'Measured Margin >= 2 dB Over Threshold', + params: { minMarginDb: 2 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'setting-leg', + nice: ['T0080', 'K0773'], + title: 'Measure on the Setting Leg', + description: 'Stay on RX analysis past culmination and read C/N around 15 degrees on the way down (about 14:08:40). Compare it with the rising leg.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['commit-the-link'], + conditions: [ + { + type: 'receiver-snr-threshold', + description: 'C/N >= 5 dB on the Setting Leg', + params: { modemNumber: 1, minCNRatio: 5, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Curve Read', + params: { + character: Character.SYSTEM, + question: 'At 15 degrees on the way up you read about 7 dB. At 15 degrees on the way down you read about 7 dB. Why the same?', + options: [ + 'Same elevation, same slant range, same path loss - the curve is symmetric about culmination', + 'The AGC has settled by the setting leg - the two readings agree once the loop catches up', + 'Doppler is equal and opposite on the two legs - the demod reports the same C/N either way', + 'Coincidence - the two readings are independent samples that happened to fall on the same value', + ], + correctIndex: 0, + explanation: + 'Same elevation means the same slant range, the same path loss and the same sky noise. C/N on a pass is a function of geometry. Read one leg and you have predicted the other; a difference between them is a fault, not physics.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 4: RECORD + // ============================================================ + { + id: 'record-measurements', + nice: ['T0080', 'T1580'], + title: 'Record the Measurements', + description: 'LOS at 14:10. Enter the measurement section of the test card.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['setting-leg'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Measurement Recorded', + params: { + character: Character.SYSTEM, + question: 'What goes in the measurement section of GW-01-ATP-002?', + options: [ + 'Predicted 11.0 dB; measured 10.9 dB at 28 deg; margin 4.9 dB over 6 dB; ~7 dB at 15 deg both legs', + 'Predicted 11.0 dB; measured 10.9 dB at 28 deg; margin 0.1 dB under prediction; no shoulder readings', + 'Predicted 11.0 dB; measured 10.9 dB at 28 deg; margin 2.9 dB over 8 dB; ~7 dB at 15 deg both legs', + 'Predicted 11.0 dB; measurement not required; margin 5.0 dB over 6 dB; shoulders predicted ~7 dB', + ], + correctIndex: 0, + explanation: + 'Prediction, measurement, margin over the 6 dB demodulator threshold, and the two shoulder readings that prove the curve. Margin is measured against the threshold, not against the prediction and not against the 8 dB service line. A card Rotterdam can check tomorrow against a different pass.', + pointPenalty: 5, + documentSection: 'Measurement', + documentLine: + 'MERIDIAN-SAR-1 14:03Z pass: predicted C/N 11.0 dB at culmination; measured 10.9 dB at 28 deg / 761 km; margin over 6 dB demod threshold 4.9 dB; rising leg 15 deg ~7 dB, setting leg 15 deg ~7 dB.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 5: REGRESSION RUN (SAR-2) + // ============================================================ + { + id: 'set-up-regression', + nice: ['S0421', 'S0015'], + title: 'Set Up the Regression Run', + description: 'MERIDIAN-SAR-2 rises at 14:18:42 from azimuth 130. It is not on the card; run it anyway. Retune modem 1 to its 1370 MHz imagery carrier.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['record-measurements'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Why a Regression Run', + params: { + character: Character.SYSTEM, + question: 'SAR-2 is not on the acceptance card. Why work its pass?', + options: [ + 'A second bird on a second geometry: if the model predicts this one too, the number was not luck', + 'Rotterdam pays per pass: every contact worked before acceptance is billable against the contract', + 'The receiver needs the exercise: a second lock before sign-off proves the modem is stable', + 'No reason on the card: it is a stretch goal only, and the verdict is signed without it', + ], + correctIndex: 0, + explanation: 'Repeatability. A 25 degree pass at 828 km should come in around 10 dB; if it does, the model holds across the constellation.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'regression-sar2', + nice: ['S0421', 'T0080'], + title: 'Regression: MERIDIAN-SAR-2', + description: 'Program-track MERIDIAN-SAR-2 and confirm C/N above 8 dB near its 25 degree culmination (14:22:10). Optional: the card is complete without it.', + groundStation: 'GW-01', + isOptional: true, + prerequisiteObjectiveIds: ['set-up-regression'], + conditions: [ + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N >= 8 dB on SAR-2', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4: SIGN + // ============================================================ + { + id: 'sign-the-test-card', + nice: ['T0080', 'K0740'], + title: 'Sign the Test Card', + description: 'Record the acceptance verdict: does the measured link support the service, and why does the prediction matter if you measured it anyway?', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['set-up-regression'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Acceptance Result Recorded', + params: { + character: Character.SYSTEM, + question: 'Measured C/N came in within a decibel of prediction. Why does acceptance require the prediction at all?', + options: [ + 'Because a match proves the station performs as designed - a good number from a broken model is luck.', + 'It does not - the measurement is the only thing that matters, and the prediction is a formality.', + 'Because the customer contract specifies a calculation, not a test - the measurement is the formality.', + ], + correctIndex: 0, + explanation: + "Exactly. A good measurement from a broken model is luck, not acceptance. Agreement between model and measurement is what lets you predict tomorrow's pass, and every pass after it. GW-01 is accepted.", + pointPenalty: 5, + documentSection: 'Verdict', + documentLine: + 'ACCEPTED. Measured within 1 dB of prediction with 4.9 dB margin over threshold. GW-01 cleared for MERIDIAN receive tasking. Signed GW-01 operator; countersigned C. Brooks.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:44] +

+

+ "Yesterday counted as first light. It does not count as acceptance. Rotterdam wants a number on a signed card before they schedule us for real tasking, and the number has to be predicted before it is measured or it is not a test, it is a guess that happened to work. Survey figures are on the Link Analysis console. Read the ones you can check off the rack first. Do the arithmetic before AOS." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'tx-cold-check': { + text: ` +

+ Receive-only card. I want the amplifier disabled and the BUC muted where Rotterdam can see it in the log. A station that radiates when nobody asked is a station that does not get tasking. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'read-the-rack': { + text: ` +

+ Sixty-five dB on the LNB is on the survey and it is on the rack; check they agree. Tsys you cannot read off anything - that is the sky plus everything in front of the LNA. Know what is in the eighty-eight. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'compute-link-budget': { + text: ` +

+ EIRP is in dBm on that sheet. Boltzmann is in dBW. Mind the thirty. Everyone loses it once; lose it now, on a worksheet, not on a card. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'path-loss-check': { + text: ` +

+ Now do the same sum for the mask. Three times the range. That number is the whole reason the useful part of a pass is three minutes in the middle. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'track-for-acceptance': { + text: ` +

+ Program-track before it rises. A measurement on the shoulder of the beam is a measurement of your pointing, not the station. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'rising-leg': { + text: ` +

+ Read it now, low. Seven-ish at fifteen degrees. Remember the number; you will see it again on the way down and it should be the same. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'commit-the-link': { + text: ` +

+ Top of the pass. Commit it when the margin reads what the card needs, not a second earlier. That button records what the receiver sees at the moment you press it. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'record-measurements': { + text: ` +

+ [Text message from Anneke Visser, MERIDIAN Ops, at 14:11] +

+

+ "Galway - SAR-1 telemetry shows your receive window 14:04:50 to 14:08:30, clean. Send the card when it is signed and I will schedule you. Welcome to the network." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'set-up-regression': { + text: ` +

+ Second bird is not on the card. Run it anyway. One good number is a number; two good numbers on two geometries is a model. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'sign-the-test-card': { + text: ` +

+ Sign it. I will countersign it. Rotterdam will pretend they never doubted us. Accepted for receive - the other half of the licence is tomorrow's problem. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario20.ts b/src/campaigns/nats-eu/scenario20.ts new file mode 100644 index 00000000..f890a765 --- /dev/null +++ b/src/campaigns/nats-eu/scenario20.ts @@ -0,0 +1,1001 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 20 - "False Time" / GNSS Spoof on the Station Reference (Gray Zone arc 4/8) + * + * Downlink (S17-S18), uplink (S19), and now the one input every other clock + * on the station trusts without asking. Pre-dawn Monday, two receive passes, + * nothing of ours on the air. At 05:08 a GNSS spoofer comes up in Galway's + * footprint: the GPSDO keeps LOCKED with a full satellite count, and the GNSS vs + * REF delta-T on the GPS Timing tab starts walking at two microseconds a + * second. Rotterdam sees it first as telemetry timestamps drifting ahead of + * theirs. Shetland reads +0.0: same sky, no walk - the lie is local. The + * operator calls it (a spoof, not an outage and not a fault), takes the GNSS + * switch down so the reference free-runs on the OCXO, flies SAR-1 on the + * oscillator, reports it, probes the sky once after LOS (still walking - back + * to holdover), and comes back to GNSS only when the offset stays still with + * GNSS re-selected. SAR-2 decodes on true time. + * + * Phase 18: `call-the-lie` is graded on the timing-drifting + + * gnss-constellation-healthy fact pair - the spoof signature is that both are + * true at once. `probe-the-sky` is a second decision whose correct answer + * depends on the mission clock (the spoofer is up until 05:36). Two new + * conditions debut: gpsdo-time-offset-exceeds (the read of the walk) and + * gpsdo-time-offset-stable (the all-clear proof, gated on reference mode gnss + * so holdover cannot fake it). M8 GnssThreatManager gains station scoping so + * SH-02 is the cross-check. + * + * Clock starts 2027-04-19 05:00:00 UTC. Passes (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 05:00:00Z): + * + * MERIDIAN-SAR-1: AOS T+21.98 (05:21:59Z, az 006), max el 29.2 deg at T+26.75 + * (05:26:45Z, 735 km), LOS T+31.47 (05:31:28Z, az 219), 9.5 min, + * southbound. Flown on holdover. C/N >= 8 dB roughly 05:24:30 .. 05:29. + * MERIDIAN-SAR-2: AOS T+44.01 (05:44:00Z, az 006), max el 27.0 deg at T+48.74 + * (05:48:44Z, 788 km), LOS T+53.48 (05:53:29Z, az 222), 9.5 min, + * southbound. Flown back on GNSS. + * + * The spoofer: settings.gnssThreat, GW-01 only, from T+480 s (05:08:00Z) to + * T+2160 s (05:36:00Z), 2 us/s. Not an RF event on the Ku analyzer - L1 is + * not in the LNB's band - so the tell is on the GPS Timing tab and in + * Rotterdam's timestamps, and the cross-check is the other station. + * + * Staged state (scenario-local clone of GW-01): RX modem 1 on 1370 MHz + * (SAR-2 video IF, from Friday); to be retuned to 1414 for SAR-1. BUC muted, + * no uplink this shift. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0752: Knowledge of system vulnerabilities (GNSS as a single point of trust) + * - S0648: Skill in detecting anomalies + * - K0740: Knowledge of timing and synchronization + * + * Supporting Codes: + * - K0926: Knowledge of signal jamming tools and techniques (spoofing) + * - S0593: Skill in handling incidents + * - K0751: Knowledge of system threats + * - T0431: Monitor timing reference systems + * - S0421: Skill in operating communications equipment + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor network capacity and performance + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + * - K0741: Knowledge of alarm states and their meaning + */ + +/** MERIDIAN-SAR-1 (61701) at the S20 epoch: the 29 deg pass, southbound from az 006. */ +const SAR1_S20_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27109.20833333 .00001000 00000-0 10000-3 0 9999' as TleLine1, + tle2: '2 61701 97.6000 80.0000 0010000 90.0000 290.0000 15.60630000123454' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S20 epoch: the 27 deg pass, southbound from az 006. */ +const SAR2_S20_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27109.20833333 .00001000 00000-0 10000-3 0 9990' as TleLine1, + tle2: '2 61702 98.0000 84.0000 0010000 90.0000 204.0000 15.59010000123459' as TleLine2, +}; + +const meridianSar1S20 = createMeridianSar1(SAR1_S20_TLE); +const meridianSar2S20 = createMeridianSar2(SAR2_S20_TLE); + +/** GW-01 as Friday left it: RX on SAR-2's video IF, BUC muted. Deep clone, never spread. */ +const galwayFalseTime: GroundStationConfig = structuredClone(galwayGroundStation); +galwayFalseTime.receivers![0].modems![0] = { + ...galwayFalseTime.receivers![0].modems![0], + frequency: 1370 as MHz, +}; +galwayFalseTime.rfFrontEnds[0].buc = { ...galwayFalseTime.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario20Data: ScenarioData = { + id: 'nats-eu-scenario20', + url: 'nats-eu/scenarios/nats-eu-scenario20', + imageUrl: 'nats/20/card.png', + number: 20, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario19'], + title: 'False Time', + subtitle: 'GNSS Spoof on the Reference (Gray Zone 4/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `05:00, Galway, Monday. Two receive passes before the day shift: SAR-1 at 05:22, SAR-2 at 05:44. Nothing of yours on the air. Downlink, then uplink - whoever has been working through this station has one input left to try, and it is the one every clock on the site trusts without asking.

The GPSDO will say LOCKED, satellites tracked, zero error. Know what that panel cannot tell you before it stops being true.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO) with GPS-disciplined 10 MHz reference', + 'GPS Timing panel (GNSS vs REF delta-T)', + 'SH-02 Shetland: second GPSDO, same sky (cross-check)', + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + 'Security Console (audit log)', + ], + settings: { + isSync: true, + groundStations: [galwayFalseTime, shetlandGroundStation], + satellites: [meridianSar1S20, meridianSar2S20], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-19', + scenarioStartWallTime: '05:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-20?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Timing Incident Record TI-2027-0419-GW01', + description: + 'The reference on 19 April: what the GPS Timing panel read, what Shetland read, when GNSS was taken down, what the offset froze at, and when it was trusted again.', + }, + + // M8 - the spoofer. GW-01 only: SH-02 sees the same constellation and no + // walk, which is the operator's cross-check. Up from 05:08 to 05:36. + gnssThreat: { + groundStationIds: ['GW-01'], + spoofStartS: 480, + spoofEndS: 2160, + offsetDriftUsPerS: 2, + }, + + // M6 - the log. Rotterdam's monitoring service notices the timestamp skew + // two minutes into the spoof; the operator flags it in report-the-attack. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'svc-legacy', name: 'Telemetry bridge (legacy, decommissioned 14 Jan)', role: 'Service Account', status: 'disabled' }, + ], + events: [ + { + id: 'evt-hopset-retired', + timeS: 0, + timestampLabel: '16 Apr 22:05 UTC', + actor: 'op-charlie', + action: 'TRANSEC hop set HOP-SAR2-04 retired (observed on air 16 Apr); HOP-SAR2-05 staged', + category: 'command', + severity: 'info', + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '04:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-svc-poll-0500', timeS: 0, timestampLabel: '05:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + { + id: 'evt-ts-skew', + timeS: 600, + timestampLabel: '05:10 UTC', + actor: 'svc-monitor', + action: 'Telemetry poll: GW-01 frame timestamps +240 us ahead of Rotterdam reference and increasing', + category: 'config', + severity: 'warning', + }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0752'], + title: 'Read the Brief', + description: + 'Open the shift brief: two receive passes, the reference every clock on the station keys off, and what a GNSS spoof looks like on the panel that is supposed to tell you the time is good.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Signature Understood', + params: { + character: Character.SYSTEM, + question: 'According to the brief, what does a GNSS spoof look like on the GPS Timing tab?', + options: [ + 'A healthy constellation - LOCKED, a full satellite count - with the GNSS vs REF delta-T walking steadily in one direction', + 'Satellites dropping toward zero and the lock badge going UNLOCKED as the spoofer captures the receiver', + 'The holdover badge going ACTIVE on its own as the GPSDO detects the bad time and protects itself', + 'OCXO temperature climbing out of the oven window as the disciplining loop fights the false signal', + ], + correctIndex: 0, + explanation: + 'A spoofer does not take satellites away; it gives you satellites that agree with each other and lie together. The receiver has no way to know. The only thing on the panel that moves is the offset between what GNSS says the time is and what the reference has been keeping - and the GPSDO will steer toward the lie because that is its job. Twenty-two minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Board read. Confirm the active alarm state on GW-01 at 05:00 before anything else.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 05:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the pass', + 'GPSDO holdover alarm - the reference dropped GNSS overnight and is free-running', + 'LNB reference unlock - the downconverter lost its 10 MHz during the night', + ], + correctIndex: 0, + explanation: + 'Empty sky, not a fault. The reference is locked and disciplined, the LNB is on it, and the AGC rail clears at AOS. The board will look exactly like this in eight minutes too - that is the problem.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'timing-baseline', + nice: ['T0431', 'K0740'], + title: 'Timing Baseline', + description: 'GPS Timing tab: locked, GNSS tracking, not in holdover, and read the GNSS vs REF delta-T while it still says what it should.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-gnss-locked', + description: 'GNSS Tracking (4+ satellites)', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Baseline Read', + params: { + character: Character.SYSTEM, + question: 'What does GNSS vs REF delta-T read on GW-01 right now?', + options: [ + '+0.0 us - the GNSS timing solution and the disciplined reference agree', + '+40 us - the reference sits at the holdover spec limit until the next discipline cycle', + '-- us - the readout is blank until the GPSDO enters holdover', + '0 ns - it mirrors the UTC accuracy figure above it', + ], + correctIndex: 0, + explanation: + 'Zero, and it should stay zero. UTC accuracy is how well the receiver thinks it knows the time; delta-T is whether the receiver and the oscillator agree about it. When those two disagree and the receiver still says it is sure, one of them is being lied to.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-1', + description: 'Retune RX modem 1 to 1414 MHz and frame the analyzer on 1414 with a 40 MHz span. SAR-1 video is 11686 MHz; the LNB LO is 13100.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['timing-baseline'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1414 MHz', + params: { centerFrequency: 1414e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 05:21:59 from azimuth 006, southbound. Park the tracker on the rise azimuth at 5 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 006 / El 5', + params: { azimuth: 6, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'spot-the-walk', + nice: ['S0648', 'T0431'], + title: 'Spot the Walk', + description: + 'From 05:08 the GNSS vs REF delta-T on GW-01 starts moving. Watch it on the GPS Timing tab until it is past 20 microseconds, and read the constellation panel while it does.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'gpsdo-time-offset-exceeds', + description: 'Offset Past 20 us on GPS Timing', + params: { minOffsetUs: 20, requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Constellation Read', + params: { + character: Character.SYSTEM, + question: 'The offset is walking. What does the GNSS Constellation panel read while it does?', + options: [ + 'A full satellite count and LOCKED - the constellation looks healthy the whole time', + 'Satellites falling one by one as the spoofer captures them', + 'ACQUIRING - the receiver is cycling lock as the two signals fight', + 'Holdover ACTIVE - the GPSDO has already stopped trusting GNSS on its own', + ], + correctIndex: 0, + explanation: + 'Nothing on the constellation panel changes. That is the signature: the receiver is tracking a full, consistent set of satellites and every one of them is telling it the same wrong time. A GPSDO cannot detect a lie it has no second opinion about.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'cross-check-sh02', + nice: ['S0648', 'K0740'], + title: 'Cross-Check Shetland', + description: 'Select SH-02 and open its GPS Timing tab. Same constellation, three hundred miles away. Read its delta-T.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['spot-the-walk'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-gnss-locked', + description: 'SH-02 GNSS Tracking Read', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Shetland Read', + params: { + character: Character.SYSTEM, + question: 'What does GNSS vs REF delta-T read on SH-02?', + options: [ + '+0.0 us - same sky, no walk: whatever is wrong is local to Galway', + 'The same walk as GW-01 - the constellation itself has been compromised', + 'A larger offset - Shetland is further north and closer to the source', + '-- us - SH-02 has no GPSDO of its own and takes its reference from Galway', + ], + correctIndex: 0, + explanation: + 'Two receivers under the same satellites, and only one of them is being walked. The satellites are fine. Something within radio range of Galway is louder than they are and is saying a different time. That is the whole diagnosis, and it took one tab.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-the-lie', + nice: ['S0648', 'K0752', 'K0751'], + title: 'Call the Lie', + description: + 'Back on GW-01. Rotterdam is on the line: their timestamps from your frames are drifting. Read the offset and the constellation on the GPS Timing tab, then call it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['cross-check-sh02'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'offset-seen', + type: 'gpsdo-time-offset-exceeds', + description: 'Offset read on GPS Timing', + params: { minOffsetUs: 20, requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + id: 'constellation-seen', + type: 'gpsdo-gnss-locked', + description: 'Constellation read on GPS Timing', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'What the Reference Is Doing', + params: { + character: Character.ANNEKE_VISSER, + prompt: + 'Your frames from the 05:10 poll are timestamped a quarter of a millisecond ahead of ours and it is growing. Your GPSDO reports locked and nine satellites; Fiona at Shetland reads zero. SAR-1 is up in ten minutes. What is it, and what do you want to do?', + evidence: ['offset-seen', 'constellation-seen'], + decisionOptions: [ + { + label: 'GNSS spoof - the constellation is healthy and the time is walking. Take the GNSS switch down and fly SAR-1 on the oscillator', + correctWhen: { + all: [ + { fact: 'timing-drifting', is: true }, + { fact: 'gnss-constellation-healthy', is: true }, + ], + }, + consequence: { log: 'GNSS spoof called on the GW-01 reference; GNSS to be taken down, SAR-1 to be flown in holdover' }, + }, + { + label: 'GNSS outage - satellites are being lost. Leave GNSS selected and let the GPSDO drop into holdover by itself', + correctWhen: { fact: 'gnss-constellation-healthy', is: false }, + consequence: { log: 'GNSS outage called with nine satellites tracked; reference left on GNSS' }, + feedback: + 'A full satellite count and LOCKED - nothing is being lost. The GPSDO will never drop into holdover on its own, because as far as it can tell GNSS is fine. Every second it stays selected it steers the reference further toward the lie.', + }, + { + label: 'GPSDO fault - the oscillator is drifting. Power-cycle the GPSDO and re-acquire before AOS', + correctWhen: { fact: 'equipment-fault-active', is: true }, + consequence: { log: 'GPSDO power-cycle ordered for a reference that is following GNSS correctly' }, + feedback: + 'The oscillator is doing exactly what it is told: following GNSS. A power cycle costs a warm-up, re-acquires the same lie, and Shetland already told you the box is not the problem.', + }, + { + label: 'Within spec - 40 us is the holdover limit and we are under it. Keep tracking and log it', + correctWhen: { fact: 'timing-drifting', is: false }, + consequence: { log: 'Walking timing offset logged as within spec' }, + feedback: + 'The 40 us figure is how far the oscillator may drift from a trusted GNSS. It says nothing about GNSS itself being wrong. This offset is growing two microseconds every second and will not stop until you stop listening.', + }, + ], + explanation: + 'Constellation healthy, offset walking, the other station clean: a spoof, local to Galway. Not an outage - nothing was lost. Not a fault - nothing on the station is broken. The defence is to stop trusting the input, not to fix the equipment: take GNSS down, let the OCXO free-run, and fly the pass on a reference that is a few hundred microseconds off true but no longer moving.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'go-to-holdover', + nice: ['S0593', 'K0740'], + title: 'Stop Trusting GNSS', + description: 'GPS Timing tab: take the GNSS switch down. The GPSDO drops to holdover, the disciplining loop opens, and the reference free-runs on the OCXO. Hold it there.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-lie'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-reference-mode-set', + description: 'Reference in Holdover', + params: { referenceMode: 'holdover' }, + mustMaintain: true, + maintainDuration: 20, + }, + { + type: 'status-check', + description: 'Cost and Benefit Understood', + params: { + character: Character.SYSTEM, + question: 'What did going to holdover cost, and what did it buy?', + options: [ + 'It froze the reference where the lie had walked it - a few hundred microseconds off true - and stopped it walking further; the OCXO now drifts nanoseconds per hour on its own', + 'Nothing - holdover re-derives true time from the stored almanac and the offset returns to zero', + 'The pass - the RX modem cannot lock without a disciplined 10 MHz and SAR-1 will have to be waived', + 'One 10 MHz output - the distribution amplifier drops to four of five ports until GNSS is re-selected', + ], + correctIndex: 0, + explanation: + 'Holdover does not know the truth either; it just stops listening to the lie. The offset you see now is what the reference will carry until it is re-disciplined by a GNSS you trust, and every frame timestamp from here carries it. Write the figure down: Rotterdam corrects by it later.', + pointPenalty: 5, + documentSection: 'Reference', + documentLine: + '05:08 GNSS vs REF delta-T on GW-01 began walking +2 us/s; constellation LOCKED with a full satellite count throughout. SH-02 delta-T +0.0 (same sky). Rotterdam timestamps skew confirmed 05:10. Called GNSS spoof. GNSS switch down, GPSDO holdover, offset frozen at the value shown on the panel at that moment.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Program-track SAR-1 before 05:22 and confirm the beacon on RX analysis. The reference is free-running; the tracker does not care.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['go-to-holdover'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-on-holdover', + nice: ['T0153', 'K0740'], + title: 'Decode on the Oscillator', + description: 'Lock the SAR-1 carrier and hold C/N above 8 dB through the high segment with the reference in holdover. Then say why that works.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + timeLimitSeconds: 6 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Holdover Decode Explained', + params: { + character: Character.SYSTEM, + question: 'The reference is in holdover and the decode is clean. Why does that work?', + options: [ + 'The modem needs a stable 10 MHz, not a true one: a free-running OCXO holds parts in ten-to-the-eleventh for hours. The frame timestamps are what carry the error, not the symbols', + 'Holdover switches the modem to its own internal crystal, so the GPSDO is out of the chain entirely', + 'It does not, really - the C/N you see is the AGC compensating for a reference that is drifting the LNB LO', + 'The video downlink carries its own time code and re-disciplines the GPSDO through the modem', + ], + correctIndex: 0, + explanation: + "Frequency stability and time truth are different things. The LNB LO, the modem symbol clock and the tracker all want a reference that does not move; none of them care what o'clock it is. The thing that cares is the timestamp on every frame going to Rotterdam, and that is why the frozen offset goes in the record.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'report-the-attack', + nice: ['S0593', 'T1580'], + title: 'Report It', + description: + 'Security console: flag the 05:10 monitoring entry - the timestamp skew is the first external evidence. Then decide what goes to Priya and Rotterdam now, while it is still happening.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-on-holdover'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Timestamp Skew Entry Flagged', + params: { eventId: 'evt-ts-skew' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Report Contents', + params: { + character: Character.SYSTEM, + question: 'What goes to Priya and Rotterdam now?', + options: [ + 'Time of onset, the delta-T reading and its rate, the constellation state, the SH-02 cross-check, when GNSS was taken down and the offset it froze at, and that SAR-1 was flown on the oscillator', + 'That GPS is down over Ireland and every NATS station should go to holdover until further notice', + 'Nothing until the spoofer stops - a report on an event still in progress is incomplete and will have to be reissued', + 'The GPSDO serial number and a request for a replacement unit on the next maintenance visit', + ], + correctIndex: 0, + explanation: + 'What was seen, where, when, at what rate, what the other station saw, what was done and what it cost. GPS is not down - one station is being lied to, and the report that says so is what keeps Shetland from going to holdover for nothing. An event in progress is reported in progress.', + pointPenalty: 5, + documentSection: 'Report', + documentLine: + 'Reported to Group CSIRT and Rotterdam 05:30: GNSS spoof local to GW-01 from 05:08, +2 us/s, constellation healthy throughout, SH-02 clean. GW-01 reference in holdover; frozen offset logged for timestamp correction. SAR-1 flown on the oscillator, decode clean. Spoofer status: ongoing.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'probe-the-sky', + nice: ['S0648', 'K0926'], + title: 'Probe the Sky', + description: + 'SAR-1 is down. The only way to know whether the spoofer is still up is to listen: put the GNSS switch up, watch the delta-T for a few seconds on the GPS Timing tab, and call what it does.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['report-the-attack'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'probe-gnss', + type: 'gpsdo-reference-mode-set', + description: 'GNSS re-selected', + params: { referenceMode: 'gnss' }, + mustMaintain: false, + }, + { + id: 'probe-offset', + type: 'gpsdo-time-offset-exceeds', + description: 'Offset read on GPS Timing', + params: { minOffsetUs: 20, requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'What the Offset Did', + params: { + character: Character.ANNEKE_VISSER, + prompt: 'You have GNSS back up. What is the delta-T doing, and where do you want the reference?', + evidence: ['probe-gnss', 'probe-offset'], + decisionOptions: [ + { + label: 'Still walking - the spoofer is up. GNSS switch back down; probe again later', + correctWhen: { fact: 'timing-drifting', is: true }, + consequence: { log: 'Probe: offset resumed walking with GNSS re-selected; reference returned to holdover' }, + }, + { + label: 'Holding still - the spoofer is off the air. Leave GNSS selected and let it re-discipline', + correctWhen: { + all: [ + { fact: 'timing-drifting', is: false }, + { fact: 'gnss-constellation-healthy', is: true }, + ], + }, + consequence: { log: 'Probe: offset held still with GNSS re-selected; reference left on GNSS' }, + }, + { + label: 'It reset to zero - GNSS re-acquired true time on the switch', + feedback: + 'The offset is what GNSS walked the reference to. Re-selecting GNSS does not undo the walk; it resumes it if the spoofer is still there and leaves it where it is if not. Nothing resets to zero.', + }, + { + label: 'Cannot tell yet - wait for the holdover error figure to clear before trusting the delta-T', + feedback: + 'The holdover error is the OCXO drift against the last discipline point; it is not the spoof and it does not clear. The delta-T is the read, and it answers in seconds.', + }, + ], + explanation: + 'The probe is the only instrument you have: give the receiver the sky for a few seconds and watch whether the offset moves. Moving means the spoofer is up and the switch goes back down - the cost is a few more microseconds. Still means the sky is honest again. Either way the answer is on the panel, not on the clock.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'all-clear', + nice: ['S0593', 'T0431'], + title: 'Ride It Out, Then Come Back', + description: + 'Stay on holdover while the offset keeps moving on each probe. When GNSS is re-selected and the delta-T holds still for thirty seconds with the constellation tracking, the sky is honest again - leave it up.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['probe-the-sky'], + timeLimitSeconds: 8 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-reference-mode-set', + description: 'Reference Back on GNSS', + params: { referenceMode: 'gnss' }, + mustMaintain: true, + }, + { + type: 'gpsdo-time-offset-stable', + description: 'Offset Still for 30 s on GNSS', + params: { holdSeconds: 30 }, + mustMaintain: true, + }, + { + type: 'gpsdo-gnss-locked', + description: 'GNSS Re-Acquired (4+ satellites)', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'All-Clear Called Correctly', + params: { + character: Character.SYSTEM, + question: 'What did you verify before trusting GNSS again?', + options: [ + 'The delta-T stopped moving with GNSS re-selected and stayed still - the spoofer is off the air. The satellite count was never evidence of anything', + 'Thirty minutes had passed since onset - a spoofer cannot hold a receiver captured longer than that', + 'The satellite count came back to nine - the constellation re-acquired after the attack', + 'The holdover error figure dropped back to zero - the OCXO had re-converged on GNSS', + ], + correctIndex: 0, + explanation: + 'The only thing that ever moved was the offset, so the only thing that proves the all-clear is the offset not moving with the receiver listening. The count was full throughout. The clock face tells you nothing about who is on the air.', + pointPenalty: 5, + documentSection: 'Reference', + documentLine: + 'Probe after SAR-1 LOS: delta-T resumed walking with GNSS re-selected; returned to holdover. Second probe: delta-T still for 30 s with GNSS re-selected and 9 satellites tracked; spoofer off the air. Reference left on GNSS to re-discipline. Frozen offset carried on all SAR-1 frame timestamps.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'SAR-2 rises 05:44:00 from azimuth 006. Retune RX modem 1 to 1370 MHz, retarget program-track, and take the lock.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['all-clear'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-sar2', + nice: ['T0153', 'K0740'], + title: 'Decode SAR-2 on True Time', + description: 'Lock the SAR-2 carrier and hold C/N above 8 dB with the reference re-disciplined. Then say what is different about these frames.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Frames Compared', + params: { + character: Character.SYSTEM, + question: 'SAR-2 is decoding as cleanly as SAR-1 did. What is different about these frames?', + options: [ + "Their timestamps are true again. SAR-1's carry the frozen offset, and Rotterdam corrects them by the figure in the record", + 'Nothing - both passes were decoded at the same C/N on the same modem', + "SAR-2's are Doppler-corrected by the re-disciplined reference; SAR-1's were not", + "SAR-1's frames are unusable - data taken in holdover cannot be timestamped at all", + ], + correctIndex: 0, + explanation: + 'Same modem, same C/N, same decode. The only thing the spoof ever touched was the time written on each frame, and the only reason that is recoverable is the number you wrote down when you took the switch down.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'close-and-debrief', + nice: ['T1580', 'K0645', 'K0752'], + title: 'Close the Record and Debrief', + description: 'Both passes decoded, the reference back on an honest sky. Close the record with the line that changes the sweep from tomorrow.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Debrief Line', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line?', + options: [ + 'GNSS is an input, not the truth: the delta-T goes on every sweep from now on, a healthy constellation with a walking offset is a spoof until proven otherwise, and the other station is the cross-check', + 'The station runs on holdover permanently - GNSS cannot be trusted at this site and the OCXO is good enough', + 'A second GNSS antenna is fitted on the far side of the building so the two receivers can vote', + 'Passes are flown with the GPSDO powered down during any suspected event to deny the spoofer a target', + ], + correctIndex: 0, + explanation: + 'Downlink, uplink, and now time. The same station, three inputs, three mornings. Each one looked healthy on the panel that was supposed to catch it. What caught them was a reading that should not have been moving and a second opinion. Put the reading on the sweep and keep the second opinion close.', + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed 05:58Z. Open: GNSS spoofer local to GW-01 05:08-05:36 (source unlocated, referred to Group CSIRT); frozen offset issued to Rotterdam for SAR-1 timestamp correction. Debrief: GNSS vs REF delta-T added to the dashboard sweep; healthy constellation with walking delta-T is called a spoof, not an outage; SH-02 delta-T is the cross-check before any reference action.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 04:40] +

+

+ "Early one. SAR-1 05:22 and SAR-2 05:44, both receive only, nothing to send. HOP-SAR2-05 is staged but you will not need it today. Our monitoring polls your station every ten minutes and compares frame timestamps against our reference; if I ring you before AOS it is because those numbers moved, not because I am awake." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'spot-the-walk': { + text: ` +

+ It is me. The 05:10 poll has your frames a quarter of a millisecond ahead of ours, and the 05:00 poll did not. Your GPSDO is telling us it is locked. Before I wake anyone else - look at Shetland. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'call-the-lie': { + text: ` +

+ Spoof. Then the box is fine and the sky is not. Take it down and fly SAR-1 on the oscillator - write down the offset at the moment you do, because that number is on every frame you send me until this is over. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'decode-on-holdover': { + text: ` +

+ SAR-1 frames coming in clean, timestamps carrying your offset exactly as logged. I am correcting them at this end. Flag the monitoring entry and send the report while it is still happening; Priya will want the onset time more than she wants the ending. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'probe-the-sky': { + text: ` +

+ A probe is cheap: a few seconds of listening and a few microseconds of offset. Back to holdover if it moves. When it holds still with the receiver up, and only then, leave it up. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'all-clear': { + text: ` +

+ Your timestamps just came back into line with ours without you touching anything. Whoever it was has gone. SAR-2 in eight minutes - fly it on the honest sky and close the record. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'close-and-debrief': { + text: ` +

+ Downlink, uplink, time. Three inputs in a week, each one dressed up as normal on the panel that should have caught it. What caught them was you knowing what normal looks like and one reading that was not. The source is mine to chase now. Get some sleep. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario21.ts b/src/campaigns/nats-eu/scenario21.ts new file mode 100644 index 00000000..3ef34bfc --- /dev/null +++ b/src/campaigns/nats-eu/scenario21.ts @@ -0,0 +1,983 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 21 - "Knocking on the Door" / Command-Link Intrusion Attempt (Gray Zone arc 5/8) + * + * Downlink, uplink, time - and now the key. Wednesday morning, a SAR-2 + * commanding pass with two commands. HK-DUMP goes up and ACKs at 09:25. Ninety + * seconds later Rotterdam's telemetry shows the bird rejecting three inbound + * frames with a stale authentication counter, and the station's own crypto + * unit logs the same three frames: byte-for-byte the HK-DUMP the operator sent, + * re-transmitted from somewhere that is not this station. A replay. The key is + * intact - the bird rejected the frames because the counter had moved, which + * is exactly what the counter is for - and every badge on the station reads + * Valid. The operator has to call that correctly (replay against an intact + * key, not a compromise), rotate the key out of cycle on Anneke's order + * because the adversary now provably holds ciphertext of it, send the second + * command under the new key, and then answer Priya's question: the retired key + * and the unit's log - keep them or zeroize them? + * + * Phase 18: two decisions. `call-the-knock` is graded on crypto-intact alone + * (a replay looks like nothing on the station and like everything in the + * log; the log entries are the evidence, so they are flagged - and therefore + * no longer an "unacknowledged anomaly" - by the time the call is made). `retain-the-evidence` is the 13.6 lesson with a + * cost that lands one scenario later: the zeroize branch fires + * destroyEvidence on the KG-01 unit log, the campaign record remembers it, + * and S22's attribution report cannot tie the probe to the actor. First + * shipped use of key-rotation-completed as an incident response rather than + * a scheduled event, and of the audit log's `command` category as evidence. + * + * Clock starts 2027-04-21 09:00:00 UTC. Passes (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 09:00:00Z): + * + * MERIDIAN-SAR-2: AOS T+23.98 (09:23:58Z, az 008), max el 30.6 deg at T+28.74 + * (09:28:44Z, 703 km), LOS T+33.45 (09:33:27Z, az 219), 9.5 min, + * southbound. The commanding pass; window 09:24:18 .. 09:33:07 + * (T+1458 .. T+1987). + * MERIDIAN-SAR-1: AOS T+46.00 (09:46:00Z, az 006), max el 26.2 deg at T+50.77, + * LOS T+55.48 (az 223). In the sky, not tasked. + * + * The knock: settings.security events at T+1620 (09:27:00Z) - Rotterdam's + * monitoring entry and the KG-01 unit log, both anomalies. No RF event, no + * fault: the station is clean throughout, which is the point. + * + * Staged state (scenario-local clone of GW-01): TX modem 1 on 1405 MHz + * (SAR-1 command IF) - to be retuned to 1435; RX modem 1 on 1414 (SAR-1) - to + * be retuned to 1370. BUC muted. Command key KEY-2027-Q2 Valid; out-of-cycle set KEY-2027-Q2b + * staged in the COMSEC store since S19's debrief. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0729: Knowledge of cryptographic key management concepts + * - S0648: Skill in detecting anomalies + * - K0751: Knowledge of system threats + * + * Supporting Codes: + * - K0874: Knowledge of cryptographic key management + * - K0875: Knowledge of key management systems + * - S0593: Skill in handling incidents + * - K0946: Knowledge of logging and audit practices + * - T1567: Configure system hardware, software, peripheral equipment + * - S0675: Skill in configuring RF transmit equipment within safe limits + * - K1032: Knowledge of satellite-based communication systems + * - S0421: Skill in operating communications equipment + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + * - K0741: Knowledge of alarm states and their meaning + * - T0153: Monitor network capacity and performance + */ + +/** MERIDIAN-SAR-2 (61702) at the S21 epoch: the 31 deg commanding pass, southbound from az 008. */ +const SAR2_S21_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27111.37500000 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61702 98.4000 142.0000 0010000 90.0000 282.0000 15.61950000123450' as TleLine2, +}; + +/** MERIDIAN-SAR-1 (61701) at the S21 epoch: a 26 deg pass at 09:46, untasked. */ +const SAR1_S21_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27111.37500000 .00001000 00000-0 10000-3 0 9992' as TleLine1, + tle2: '2 61701 98.0000 146.2500 0010000 90.0000 196.2500 15.58070000123456' as TleLine2, +}; + +const meridianSar1S21 = createMeridianSar1(SAR1_S21_TLE); +const meridianSar2S21 = createMeridianSar2(SAR2_S21_TLE); + +/** GW-01 as Friday left it: RX on SAR-1's video IF, TX still on SAR-1's command IF, BUC muted. Deep clone, never spread. */ +const galwayKnock: GroundStationConfig = structuredClone(galwayGroundStation); +galwayKnock.receivers![0].modems![0] = { + ...galwayKnock.receivers![0].modems![0], + frequency: 1414 as MHz, +}; +galwayKnock.rfFrontEnds[0].buc = { ...galwayKnock.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario21Data: ScenarioData = { + id: 'nats-eu-scenario21', + url: 'nats-eu/scenarios/nats-eu-scenario21', + imageUrl: 'nats/21/card.png', + number: 21, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario20'], + title: 'Knocking on the Door', + subtitle: 'Command-Link Intrusion Attempt (Gray Zone 5/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `09:00, Galway, Wednesday. SAR-2 at 09:24 is a commanding pass: a housekeeping dump, then a payload status. The key is KEY-2027-Q2, Valid at both ends; KEY-2027-Q2b has been staged in the store since Friday's debrief.

They have had the downlink, the uplink and the clock. The only thing on this station they have not touched is the key - and the console will tell you the moment they try, in a log entry that looks like nothing at all.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO, 12600 MHz BUC LO) + HPA', + 'TT&C Commanding Console with KG-01 command crypto unit', + 'Security Console (audit log: station, monitoring service, KG-01 unit log)', + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayKnock, shetlandGroundStation], + satellites: [meridianSar1S21, meridianSar2S21], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-21', + scenarioStartWallTime: '09:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-21?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'COMSEC Incident Record CI-2027-0421-GW01', + description: 'The SAR-2 window of 21 April: what was sent, what came back to the bird from elsewhere, what the key did, what was kept.', + }, + + // M2/M5 - the commanding pass. Window is the SAR-2 pass with 20 s guards. + // No uplinkFrequencyHz: nothing is jammed today. The knock is in the log. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61702, + windowStartS: 1458, + windowEndS: 1987, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }, + { id: 'PLD-STATUS', label: 'Payload status request' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + ], + }, + + // M6 - the log. The knock lands at T+1620 as two entries: Rotterdam's + // monitoring service sees the bird reject three frames, and KG-01 logs the + // same three frames as re-sent copies of the operator's own HK-DUMP. The + // KG-01 entry is the evidence S22 needs; the zeroize branch destroys it. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'kg-01', name: 'KG-01 command crypto unit', role: 'Equipment', status: 'active' }, + { id: 'svc-legacy', name: 'Telemetry bridge (legacy, decommissioned 14 Jan)', role: 'Service Account', status: 'disabled' }, + ], + events: [ + { + id: 'evt-q2b-staged', + timeS: 0, + timestampLabel: '16 Apr 22:10 UTC', + actor: 'op-charlie', + action: 'Command key KEY-2027-Q2b staged to COMSEC store (inactive, out-of-cycle spare)', + category: 'command', + severity: 'info', + }, + { + id: 'evt-ti-closed', + timeS: 0, + timestampLabel: '19 Apr 06:02 UTC', + actor: 'op-duty', + action: 'Timing incident TI-2027-0419-GW01 closed; frozen offset issued to Rotterdam', + category: 'config', + severity: 'info', + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '08:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-svc-poll-0900', timeS: 0, timestampLabel: '09:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + { + id: 'evt-rotterdam-counter', + timeS: 1620, + timestampLabel: '09:27 UTC', + actor: 'svc-monitor', + action: 'SAR-2 telemetry: 3 inbound TT&C frames rejected at the spacecraft - authentication counter stale (09:26:41-09:26:55)', + category: 'command', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-kg-replay-log', + timeS: 1632, + timestampLabel: '09:27 UTC', + actor: 'kg-01', + action: 'KG-01 unit log: 3 frames observed on the return matching command traffic sent 09:25 (HK-DUMP), counter 0x2F13 stale, source not this station', + category: 'command', + severity: 'warning', + isAnomaly: true, + }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0729'], + title: 'Read the Brief', + description: + 'Open the shift brief: two commands in the SAR-2 window, the key that is live and the one that is staged, and what the authentication counter does when a frame arrives twice.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Counter Understood', + params: { + character: Character.SYSTEM, + question: 'A command frame the station sent is captured and sent again to the bird a minute later, unchanged. What happens?', + options: [ + 'The bird rejects it: every authenticated frame carries a counter, the counter has moved on, and a stale counter fails authentication even though the key and the ciphertext are genuine', + 'The bird executes it again: the frame is genuine, correctly encrypted under the live key, and the bird has no way to know it is a copy', + 'The bird rejects it because the Doppler is wrong: a frame sent from elsewhere arrives off-frequency and never demodulates', + 'The station rejects it: KG-01 sees its own frame come back on the return and blocks the uplink until the key is rotated', + ], + correctIndex: 0, + explanation: + 'That is what the counter is for. A replay proves the adversary can capture and re-transmit your command traffic; it does not prove they can read it or make their own. The bird says no, and both ends log that it said no. Twenty-four minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Board read. Confirm the active alarm state on GW-01 before the uplink goes anywhere near the air.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 09:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the pass', + 'COMSEC alarm - a spare key staged in the store is flagged until it is either loaded or destroyed', + 'BUC muted alarm - the uplink is inhibited and the board wants it cleared', + ], + correctIndex: 0, + explanation: + 'Empty sky, not a fault. A staged spare is what a store is for, and a muted BUC is correct for a station with nothing to send yet. The board will read exactly like this at 09:27 too.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'key-baseline', + nice: ['K0874', 'T0431'], + title: 'Key Baseline', + description: 'Command key Valid on the TX chain, traffic key Valid on the receive side. Read both now; both will still read Valid at 09:27, and that is the whole problem.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-key-status', + description: 'Command Key Valid (TX Chain)', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'Traffic Key Valid (RX Analysis)', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'audit-baseline', + nice: ['K0946', 'K0645'], + title: 'Read the Log Before the Pass', + description: 'Security console: sign off the log as it stands at 09:00, so that anything that appears during the window is new by definition.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['key-baseline'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Security Console Open', + params: { tab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'audit-log-reviewed', + description: 'Log Signed Off at 09:00', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Baseline Read', + params: { + character: Character.SYSTEM, + question: 'What does the log hold at 09:00 that matters for this window?', + options: [ + 'KEY-2027-Q2b staged as an inactive spare on the 16th, the timing incident closed on the 19th, a login and a poll - nothing anomalous, and nothing from KG-01 at all', + 'An unacknowledged KG-01 entry from overnight - the unit has been rejecting frames since 03:00 and nobody flagged it', + 'The spare key was loaded, not staged - Charlie activated Q2b on the 16th and the console is already on it', + 'A svc-legacy login at 08:40 - the decommissioned bridge is answering again', + ], + correctIndex: 0, + explanation: + 'Quiet, and signed off as quiet. KG-01 writes to this log only when it has something to say, which it has not yet. The value of the sign-off is that the next KG-01 line cannot be mistaken for background.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'arm-the-uplink', + nice: ['S0421', 'K0773', 'T1567'], + title: 'Arm the Uplink', + description: + "TX modem 1 is on SAR-1's 1405 MHz command IF. Retune it for SAR-2's 14035 MHz TT&C uplink through the 12600 MHz BUC LO, and engage uplink Doppler compensation on the TT&C console before the window opens at 09:24:18.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['audit-baseline'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX 1435 MHz', + params: { modemNumber: 1, frequency: 1435e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-2', + description: 'Retune RX modem 1 to 1370 MHz and frame the analyzer on 1370 with a 40 MHz span. The return is where the bird tells you what it heard.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['arm-the-uplink'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 09:23:58 from azimuth 008, southbound. Park the tracker on the rise azimuth at 5 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 008 / El 5', + params: { azimuth: 8, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'Program-track SAR-2 before 09:24 and confirm the 1397 MHz beacon on RX analysis. The chain stays cold until she is locked.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-2-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'chain-up', + nice: ['S0675', 'S0421', 'K0645'], + title: 'Chain Up in Order', + description: 'Window open at 09:24:18. Carrier into the muted BUC, unmute, HPA. Two minutes.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'first-command', + nice: ['T1567', 'K1032'], + title: 'Housekeeping Dump', + description: 'Send HK-DUMP and confirm the acknowledgement. Note the time: the frame you just sent is about to come back.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up'], + conditions: [ + { + type: 'command-acknowledged', + description: 'HK-DUMP Acknowledged', + params: { commandId: 'HK-DUMP' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'ACK Read', + params: { + character: Character.SYSTEM, + question: 'HK-DUMP ACKed at 09:25 under KEY-2027-Q2. Who, apart from the bird, could now hold a copy of that frame?', + options: [ + 'Anyone with a receiver under the uplink footprint or on the transponded return: the frame is encrypted, so they cannot read it, but they can record it', + 'Nobody: an authenticated frame is bound to this station and the bird discards anything that did not originate here', + 'Rotterdam only: the frame is mirrored to constellation ops over the ground network, not over the air', + 'Anyone who has the key: without KEY-2027-Q2 the frame is not even detectable as a command', + ], + correctIndex: 0, + explanation: + 'Encryption hides the content, not the transmission. A frame on the air can be captured by anyone under it, and the transponder repeats your uplink to everyone under the downlink. That is why the counter exists - and why what happens next is not a surprise.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'spot-the-knock', + nice: ['S0648', 'K0946'], + title: 'Spot the Knock', + description: + "09:27. Two new lines in the log: Rotterdam's monitoring service and KG-01, ninety seconds after your ACK. Flag the monitoring entry first and read what the bird did.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['first-command'], + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Counter-Reject Entry Flagged', + params: { eventId: 'evt-rotterdam-counter' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Reject Read', + params: { + character: Character.SYSTEM, + question: 'Three inbound frames rejected at the spacecraft with a stale authentication counter, 09:26:41 to 09:26:55. What does that entry tell you?', + options: [ + 'Someone sent the bird command frames that authenticated under the live key but carried a counter the bird had already passed - genuine ciphertext, re-sent. The bird refused all three', + 'The command key is out of step between the station and the bird - a counter mismatch is the first sign of a key rotation that only happened at one end', + "Three of the station's own frames were garbled by Doppler and re-sent by the console automatically, and the bird refused the retries", + "The monitoring service has a clock error - a counter is a timestamp, and Rotterdam's reference is still carrying the 19 April offset", + ], + correctIndex: 0, + explanation: + 'Your one HK-DUMP ACKed at 09:25. Three more arrived at the bird that you did not send, under your key, and the bird threw them out because their counter was old. That is a replay. The key worked exactly as designed at both ends.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-the-knock', + nice: ['S0648', 'K0751', 'K0729'], + title: 'Call the Knock', + description: + 'Read the command key on the TX chain and flag the KG-01 unit log on the Security console - the unit saw the same three frames and knows they were yours. Then call it: what is this, and what does the key need?', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['spot-the-knock'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'key-seen', + type: 'tx-key-status', + description: 'Command key read on TX Chain', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + id: 'unit-log-flagged', + type: 'security-event-acknowledged', + description: 'KG-01 unit log flagged on the Security console', + params: { eventId: 'evt-kg-replay-log', requiresObservation: true, observationTab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'What the Knock Is', + params: { + character: Character.ANNEKE_VISSER, + prompt: + 'Three of your frames came back at my bird from somewhere else and it refused them. Your key reads Valid at my end. Window is open for six more minutes and you owe me a PLD-STATUS. What is this, and what do you want to do with the key?', + evidence: ['key-seen', 'unit-log-flagged'], + decisionOptions: [ + { + label: + 'A replay against an intact key - the bird rejected it. Keep the log, rotate to Q2b out of cycle because they hold ciphertext of Q2, and send PLD-STATUS under the new key', + correctWhen: { fact: 'crypto-intact', is: true }, + consequence: { log: 'Replay against intact command key called; out-of-cycle rotation to KEY-2027-Q2b requested at both ends' }, + }, + { + label: 'Key compromised - zeroize KEY-2027-Q2 on KG-01 now and hold commanding until Rotterdam re-keys the bird', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { + destroyEvidence: { auditEventId: 'evt-kg-replay-log' }, + log: 'Command key zeroized on KG-01 with the key intact; unit log cleared with it', + }, + feedback: + "Nothing about the key failed: the bird rejected the frames because the counter had moved, which is the key working. Zeroize is for a key you believe is in someone else's hands. It also wipes the unit, and KG-01's log of what it saw goes with it - the only record on this station of where those frames came from.", + }, + { + label: 'Our own retries - the console re-sent HK-DUMP on a Doppler miss. Nothing to do; send PLD-STATUS', + feedback: + 'The console sends once and logs once; there is one HK-DUMP in its history and one ACK. KG-01 says the three frames arrived on the return from a source that is not this station. Sending the next command on the same key hands them another frame to keep.', + }, + { + label: "A counter desync after the timing incident - Rotterdam's reference still carries the 19 April offset. Ask them to resync and resend", + feedback: + 'A counter is not a clock; it does not care what time it is. Your own HK-DUMP authenticated a minute earlier on the same counter chain. The offset was corrected on the 19th and the log says so.', + }, + ], + explanation: + 'Every badge on the station says Valid because the key is valid. The evidence is in two log entries: the bird said no to frames it should never have seen, and KG-01 recognised them as yours. A replay proves capture, not compromise - so the answer is proportionate: keep the record, rotate the key they have been recording, carry on.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'rotate-on-order', + nice: ['K0874', 'K0875', 'T1567'], + title: 'Rotate Out of Cycle', + description: + 'Anneke has keyed SAR-2 to KEY-2027-Q2b. TT&C console: begin the rotation, confirm it, and read Valid before you send anything else. Two minutes of the window go to this.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-knock'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'TT&C Console Open', + params: { tab: 'commanding' }, + mustMaintain: false, + }, + { + type: 'key-rotation-completed', + description: 'Command Key Rotated to KEY-2027-Q2b', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Rotation Reasoned', + params: { + character: Character.SYSTEM, + question: 'Why rotate a key that was never compromised?', + options: [ + 'Because they hold recorded traffic under it: every frame captured under Q2 is a frame they can try again, and a key that has been recorded is a key you stop using - rotation bounds the exposure without destroying anything', + 'Because the counter is exhausted: three stale rejects push the authentication counter past its limit and the key cannot sign another frame', + 'Because Rotterdam requires it: any anomaly on the command link triggers a mandatory rotation regardless of cause', + 'It should not be rotated - a rotation under pressure is how a good key gets replaced by one loaded wrong', + ], + correctIndex: 0, + explanation: + 'The key is fine; its history is not. Rotation retires the traffic they recorded and costs two minutes of window. Zeroize would have cost the key, the unit and the evidence. Proportionate response is the whole skill.', + pointPenalty: 5, + documentSection: 'Rotation', + documentLine: + 'Called replay against intact KEY-2027-Q2 09:28. Out-of-cycle rotation to KEY-2027-Q2b ordered by Rotterdam; SAR-2 keyed 09:28, GW-01 rotation begun and confirmed on the TT&C console, key Valid 09:29. Q2 retired, not zeroized.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'second-command', + nice: ['T1567', 'K1032'], + title: 'Payload Status Under the New Key', + description: 'Send PLD-STATUS under KEY-2027-Q2b and confirm the ACK before the window closes at 09:33:07.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rotate-on-order'], + conditions: [ + { + type: 'command-acknowledged', + description: 'PLD-STATUS Acknowledged', + params: { commandId: 'PLD-STATUS' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'New Key Read', + params: { + character: Character.SYSTEM, + question: 'PLD-STATUS ACKed under Q2b. If the three recorded frames are sent to the bird again tomorrow, what happens?', + options: [ + 'They fail twice over: the counter is stale and the key they were encrypted under is no longer loaded on the bird. What they recorded is now worth nothing', + 'They still authenticate: a recorded frame carries its own key material, so a rotation does not touch frames captured before it', + 'The bird executes them: Q2b re-based the counter, so the old counter values are fresh again', + 'They cannot be sent at all: rotation changes the uplink frequency and the recorded frames are on the old carrier', + ], + correctIndex: 0, + explanation: + 'Two locks on the same door. The counter would have held on its own; the rotation makes their recording useless even if it did not. That is what the two minutes bought.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'safe-the-uplink', + nice: ['S0421', 'K0645'], + title: 'Safe the Uplink', + description: 'LOS 09:33:27. Chain down in the mirror order: HPA output off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['second-command'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'retain-the-evidence', + nice: ['S0593', 'K0946', 'K0876'], + title: 'Retain or Zeroize', + description: + 'Priya is on the line about KEY-2027-Q2 and the KG-01 unit log. Re-read the unit log entry on the Security console, then answer her: what happens to the retired key and the record of what it saw?', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['safe-the-uplink'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'unit-log-reread', + type: 'security-event-acknowledged', + description: 'KG-01 unit log re-read on the Security console', + params: { eventId: 'evt-kg-replay-log', requiresObservation: true, observationTab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'The Retired Key and the Unit Log', + params: { + character: Character.PRIYA_SHARMA, + prompt: + 'Q2 is retired and Q2b is live. The unit that held Q2 still has its log of the three frames - the only record anywhere of what came back and when. Some sites zeroize a probed key on principle. What do you want done with Q2 and with that log?', + evidence: ['unit-log-reread'], + decisionOptions: [ + { + label: 'Retain both under seal for CSIRT: the retired key is out of service, and the KG-01 log is the evidence that ties these frames to the actor', + correctWhen: { fact: 'crypto-intact', is: true }, + consequence: { log: 'KEY-2027-Q2 and the KG-01 unit log retained under seal for Group CSIRT' }, + }, + { + label: 'Zeroize Q2 on the unit - a probed key should not exist, whatever the log says', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { + destroyEvidence: { auditEventId: 'evt-kg-replay-log' }, + pointDelta: 5, + log: 'KEY-2027-Q2 zeroized on KG-01 after rotation; unit log cleared with it', + }, + feedback: + "Zeroize clears the unit, and the unit is where the log lives. The key was already out of service; what you destroyed was the only record of the frames' source and timing. When Priya writes the attribution report, that line will be missing.", + }, + { + label: 'Zeroize the live key as well and hold commanding until Rotterdam issues a fresh set', + feedback: + 'Q2b has never been on the air and was never recorded. Zeroizing it hands them a commanding outage they did not have to work for, and clears the unit log in the process.', + }, + { + label: 'Nothing further - the rotation closed the matter and the log rolls over on its own', + feedback: + 'The rotation closed the exposure, not the incident. A unit log rolls over; a record under seal does not. Priya asked because the answer is not "nothing".', + }, + ], + explanation: + "What to reset and what to capture first. The retired key is harmless in the store and useful in a report; the unit log is the one artefact that says where the frames came from. Zeroize is for a key in someone else's hands. This one was only in their recorder.", + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'close-and-debrief', + nice: ['T1580', 'K0645', 'K0729'], + title: 'Report the COMSEC Incident', + description: 'File the COMSEC incident: what was replayed, what the key did, what was rotated, what was kept. Then the line that changes the next commanding pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retain-the-evidence'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Incident Filed', + params: { + character: Character.SYSTEM, + question: 'Which line goes in the COMSEC incident report?', + options: [ + '09:25 HK-DUMP ACK under Q2. 09:26:41-55 three frames matching HK-DUMP rejected at SAR-2, counter stale, source not GW-01 (KG-01 log). Called replay, key intact. Rotated to Q2b both ends 09:29; PLD-STATUS ACK under Q2b. Q2 and KG-01 log retained under seal', + 'Command key compromised during the SAR-2 window; emergency rotation performed; adversary now holds Q2 and the station is operating on a spare', + 'Intermittent command rejects on SAR-2 traced to a counter error; resolved by a key rotation; no further action', + 'Attempted intrusion on the command link defeated by cryptographic authentication; the adversary has escalated to active attack on the constellation', + ], + correctIndex: 0, + explanation: + 'Times, what was sent, what came back, what the key did, what was done, what was kept. "Compromised" is wrong and "resolved" is a conclusion; the report carries the counter state and the KG-01 line, because those are what the next reader needs.', + pointPenalty: 5, + documentSection: 'Report', + documentLine: + 'COMSEC incident CI-2027-0421-GW01: replay of GW-01 command traffic at SAR-2 09:26:41-55Z, three frames, counter stale, rejected at spacecraft; source not this station per KG-01. Key intact throughout. Rotated Q2 to Q2b out of cycle 09:29; PLD-STATUS ACK under Q2b 09:31. Q2 and KG-01 unit log retained under seal for Group CSIRT.', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Debrief Line', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line?', + options: [ + 'Every command frame on the air is a frame they can keep: a key used in a contested window is rotated after it, the spare is always staged, and a unit log is evidence before it is a nuisance', + 'Commanding passes stop until the source of the replay is found: the key is not safe while someone is recording it', + 'Zeroize on every anomaly from now on: the two minutes a rotation costs are two minutes a compromised key stays live', + 'Doppler compensation is disabled for commanding: a replayed frame sent from elsewhere arrives off-frequency, so the bird will not hear it', + ], + correctIndex: 0, + explanation: + "Recorded traffic is the threat, not the key. Stage the spare, rotate after a contested window, and treat the unit's log as something you will be asked for. Four inputs probed in eight days; the report is now a pattern, and that is Priya's next shift.", + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed 09:52Z. Open: source of replayed frames (referred to Group CSIRT; KG-01 log and Q2 sealed). Debrief: command key rotated after any contested window; spare key staged before every commanding pass; KG-01 unit log exported to the incident record on any counter reject.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Anneke Visser at 08:40] +

+

+ "SAR-2 at 09:24 is commanding: HK-DUMP, then PLD-STATUS, both under KEY-2027-Q2. Q2b is in your store and on my shelf - staged, not loaded, since Friday. I watch the bird's command counter on every pass now. If it rejects anything I did not see you send, you will hear from me inside a minute." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'first-command': { + text: ` +

+ HK-DUMP acknowledged 09:25, counter 0x2F13. Payload status when you are ready. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'spot-the-knock': { + text: ` +

+ It is me. The bird just refused three frames - stale counter, all three inside fifteen seconds, and I did not see you send any of them. Your key reads Valid here. Look at your unit log before you tell me what it is. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'call-the-knock': { + text: ` +

+ Replay. Agreed. I am keying SAR-2 to Q2b now - done. Begin your rotation, confirm it, and send me the payload status under the new key. Do not touch the unit log. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'second-command': { + text: ` +

+ PLD-STATUS acknowledged 09:31 under Q2b. Whatever they recorded this morning is a recording of a key that no longer exists on my bird. Chain down at LOS; Priya wants a word about the old one. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'retain-the-evidence': { + text: ` +

+ Good. Seal the key and export the unit log to the record exactly as it reads - do not tidy it. Tomorrow I am going to put four incidents side by side, and that KG-01 line is the one that names the actor. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'close-and-debrief': { + text: ` +

+ Downlink, uplink, time, key. Someone has now tried every input this station has, one at a time, and written you a log entry each time they did. Tomorrow we read them together. Bring the record. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario22.ts b/src/campaigns/nats-eu/scenario22.ts new file mode 100644 index 00000000..52959a0f --- /dev/null +++ b/src/campaigns/nats-eu/scenario22.ts @@ -0,0 +1,947 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 22 - "Connecting the Dots" / Incident Attribution Report (Gray Zone arc 6/8) + * + * Five events in ten days, each on a different input, each written up as it + * happened and closed. Thursday afternoon Priya sits the operator down with + * the campaign record and the station audit log and asks for the one document + * nobody has written yet: the report that reads them together. The operator + * opens the record, reads the chain of carried-forward entries, flags the + * origin (the 13 April configuration export - every later event used a + * parameter in it), builds the timeline, flies a routine SAR-1 pass in the + * middle of it because the station is still a station, and then makes the + * attribution call. Then the parts of a report that leave the building: the + * geolocation referral (Charlie knows someone), continuity recommendations, + * the board summary, who is notified of what, and the close. + * + * Phase 18: the first scenario built on the campaign document store. The + * Campaign Record panel (sidebar) shows S17-S21's Working Documents - the + * player's own where they were completed in this browser, otherwise as filed + * - and `campaign-document-reviewed` latches on opening it. The audit log + * carries the arc's evidence forward as entries; the KG-01 unit log from S21 + * carries `requiresCampaignEvidence`, so if the player zeroized it there it is + * missing here, the `evidence-chain-intact` fact reads false, and the + * attribution decision has a different right answer: the report records the + * gap. Q5 storyline bridge: the referral beat is where Charlie mentions a + * friend who does geolocation for the military - the on-ramp to Campaign 4. + * + * Clock starts 2027-04-22 14:00:00 UTC. Pass (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 14:00:00Z): + * + * MERIDIAN-SAR-1: AOS T+30.02 (14:30:00Z, az 025), max el 23.8 deg at T+34.82 + * (14:34:49Z, 880 km), LOS T+39.57 (14:39:34Z, az 168), 9.6 min, + * southbound. Receive only; C/N >= 8 dB roughly 14:32:30 .. 14:37. + * + * Staged state (scenario-local clone of GW-01): RX modem 1 on 1370 MHz from + * yesterday's SAR-2 window - to be retuned to 1414. BUC muted; no uplink. + * + * NICE Framework Alignment: + * Primary Codes: + * - S0852: Skill in analyzing incident data / reporting (attribution) + * - T1405: Correlate incident data + * - T1606: Report incidents to stakeholders + * + * Supporting Codes: + * - T1427: Coordinate incident response with external parties + * - S0648: Skill in detecting anomalies + * - K0946: Knowledge of logging and audit practices + * - K0721: Knowledge of the ground segment as a target + * - S0593: Skill in handling incidents + * - S0421: Skill in operating communications equipment + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor network capacity and performance + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + * - K0741: Knowledge of alarm states and their meaning + */ + +/** MERIDIAN-SAR-1 (61701) at the S22 epoch: the 24 deg receive pass, southbound from az 025. */ +const SAR1_S22_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27112.58333333 .00001000 00000-0 10000-3 0 9999' as TleLine1, + tle2: '2 61701 98.4000 240.2500 0010000 90.0000 262.0000 15.55840000123454' as TleLine2, +}; + +const meridianSar1S22 = createMeridianSar1(SAR1_S22_TLE); + +/** GW-01 as yesterday left it: RX on SAR-2's video IF, BUC muted. Deep clone, never spread. */ +const galwayDots: GroundStationConfig = structuredClone(galwayGroundStation); +galwayDots.receivers![0].modems![0] = { + ...galwayDots.receivers![0].modems![0], + frequency: 1370 as MHz, +}; +galwayDots.rfFrontEnds[0].buc = { ...galwayDots.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario22Data: ScenarioData = { + id: 'nats-eu-scenario22', + url: 'nats-eu/scenarios/nats-eu-scenario22', + imageUrl: 'nats/22/card.png', + number: 22, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario21'], + title: 'Connecting the Dots', + subtitle: 'Incident Attribution Report (Gray Zone 6/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `14:00, Galway, Thursday. One receive pass at 14:30 and a report to write. Five incidents in ten days, each closed on its own record: a carrier and a spray, a persistent interferer, an uplink denial, a spoofed clock, a replayed command. Priya wants the document that reads them together - what links them, what it cost, who needs to know, and what changes.

The campaign record and the audit log have everything you need. Whether the last link is in them depends on what you kept on Wednesday.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO)', + 'Campaign Record (Working Documents, S17-S21)', + "Security Console (audit log with the arc's entries carried forward)", + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayDots, shetlandGroundStation], + satellites: [meridianSar1S22], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-22', + scenarioStartWallTime: '14:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-22?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Incident Attribution Report IAR-2027-0422-GW01', + description: 'Five incidents, one report: timeline, attribution, referral, continuity, board summary, notifications.', + }, + + // M6 - the log, with the arc's evidence carried forward as entries. The + // KG-01 unit log from S21 is conditional on what the player kept there. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'kg-01', name: 'KG-01 command crypto unit', role: 'Equipment', status: 'active' }, + { id: 'svc-legacy', name: 'Telemetry bridge (legacy, decommissioned 14 Jan, disabled 13 Apr)', role: 'Service Account', status: 'disabled' }, + ], + events: [ + { + id: 'evt-s17-spray', + timeS: 0, + timestampLabel: '13 Apr 01:58 UTC', + actor: 'svc-legacy', + action: 'Failed login (3 attempts, remote source) against a decommissioned account still active; account disabled 02:40', + category: 'auth', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-s17-export', + timeS: 0, + timestampLabel: '13 Apr 02:33 UTC', + actor: 'svc-legacy', + action: 'Configuration read: full station configuration exported (antenna, LNB LO, modem plan, contact schedule) - no change ticket', + category: 'config', + severity: 'critical', + isAnomaly: true, + }, + { + id: 'evt-s18-report', + timeS: 0, + timestampLabel: '14 Apr 11:05 UTC', + actor: 'op-duty', + action: 'Interference report IR-2027-0414-GW01 filed to ComReg: terrestrial carrier 11726 then 11690 MHz, 60 s on / 30 s off, following the station frequency plan', + category: 'config', + severity: 'info', + }, + { + id: 'evt-s19-denial', + timeS: 0, + timestampLabel: '16 Apr 13:23 UTC', + actor: 'svc-monitor', + action: "SAR-2 command NAK: uplink denied - carrier jammed at 14035 MHz (the reissued plan's TT&C uplink); hop set HOP-SAR2-04 activated 13:25, retired", + category: 'command', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-s20-skew', + timeS: 0, + timestampLabel: '19 Apr 05:10 UTC', + actor: 'svc-monitor', + action: 'GW-01 frame timestamps +240 us ahead of Rotterdam reference and increasing; GNSS spoof local to Galway 05:08-05:36, SH-02 clean', + category: 'config', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-s21-kg-log', + timeS: 0, + timestampLabel: '21 Apr 09:27 UTC', + actor: 'kg-01', + action: 'KG-01 unit log (sealed 21 Apr): 3 frames matching HK-DUMP re-sent from off-station source, counter 0x2F13 stale, rejected at SAR-2', + category: 'command', + severity: 'warning', + isAnomaly: true, + requiresCampaignEvidence: { scenarioId: 'nats-eu-scenario21', eventId: 'evt-kg-replay-log' }, + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '13:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-svc-poll-1400', timeS: 0, timestampLabel: '14:00 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'T1606'], + title: 'Read the Brief', + description: 'Open the brief: what an attribution report is for, what it may say, what it may not, and the one pass that has to be flown in the middle of writing it.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Report Scope Understood', + params: { + character: Character.SYSTEM, + question: 'What is an attribution report allowed to conclude?', + options: [ + 'What was observed, when, on which input, what each event needed to know, and what links them - a pattern is a finding; a name is a claim the station cannot make', + 'Who did it: the point of correlating five incidents is to put a name on the actor so the board can act', + 'Nothing: the station reports facts, and drawing a line between five separate records is speculation', + 'That the station was at fault: every event exploited a weakness, and the report exists to list them', + ], + correctIndex: 0, + explanation: + 'Five closed records say five things happened. The report says whether they are one thing. It can say "the same source, the same method, the same shopping list" if the evidence says so, and it must say where the evidence stops. Thirty minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Board read before the desk work. Confirm the active alarm state on GW-01 at 14:00.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 14:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the pass', + 'Security alarm - five unacknowledged anomalies carried forward in the audit log', + 'COMSEC alarm - KEY-2027-Q2 retired under seal and flagged until destroyed', + ], + correctIndex: 0, + explanation: + 'Empty sky, not a fault. Carried-forward log entries are history, not alarms; a sealed key is a record, not a warning. The station is quiet. The report is about why it was not.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'open-the-record', + nice: ['K0721', 'K0946'], + title: 'Open the Campaign Record', + description: 'Sidebar: Campaign Record. Every Working Document from the 13th to yesterday, as filed. Read them in order before you read anything else.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'campaign-document-reviewed', + description: 'Campaign Record Opened', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Record Counted', + params: { + character: Character.SYSTEM, + question: 'How many incidents does the record hold since the 12th, and on which inputs?', + options: [ + 'Five: a downlink carrier and a login spray (13th), persistent downlink interference (14th), an uplink denial (16th), a spoofed timing reference (19th), a replayed command (21st) - downlink, downlink, uplink, time, key', + 'Four: the two interference events are one incident, and the spray was a routine credential scan closed without action', + "Three: interference, spoofing and the replay; the spray and the export were an IT matter outside the station's record", + 'Six: the hop-set retirement on the 16th is its own incident because it cost a key', + ], + correctIndex: 0, + explanation: + 'Five records, five inputs, in order: what the station receives, what it transmits, what it keeps time by, what it commands with. Each one needed more access than the last, and each one was possible with what was exported on the 13th.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-chain', + nice: ['K0946', 'S0648'], + title: 'Read the Chain', + description: + "Security console: the arc's entries are carried forward in the log, in date order. Sign it off as read, and say what every entry after the 13th has in common with the export.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['open-the-record'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Security Console Open', + params: { tab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'audit-log-reviewed', + description: 'Carried-Forward Log Reviewed', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Common Thread', + params: { + character: Character.SYSTEM, + question: 'What does every entry after 13 April have in common with the configuration export at 02:33 that morning?', + options: [ + 'Each used something in it: the receive frequencies (14th), the reissued command carrier (16th), the timing dependency (19th), the command plan and window (21st). The export was the shopping list', + 'Nothing - the export was a read, not a change, and reads do not cause incidents', + 'The same account: svc-legacy is the actor on every later entry', + 'The same time of day: every event happened during the night shift when the station was unattended', + ], + correctIndex: 0, + explanation: + 'A configuration export tells someone what to jam, what to spoof, when to command and on which carrier. None of the later events guessed. That is the line that turns five records into one report - and it is why the export, not the carrier, is the origin.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'flag-the-origin', + nice: ['S0648', 'T1405'], + title: 'Flag the Origin', + description: 'Flag the 13 April export entry. It goes at the top of the timeline: everything after it was done with what it contained.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-chain'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Configuration Export Flagged', + params: { eventId: 'evt-s17-export' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Origin Written', + params: { + character: Character.SYSTEM, + question: 'What is the first line of the timeline?', + options: [ + '13 Apr 01:58 password spray on svc-legacy (decommissioned, still active); 02:33 full station configuration exported, no change ticket - every later event used a parameter in that export', + '13 Apr 02:25 terrestrial carrier on the SAR-1 downlink - the first attack, and the reason the station started keeping a record', + '12 Apr: incident INC-2027-0412-GW01 opened by Group CSIRT on a general warning', + '14 Jan: svc-legacy decommissioned but left active - the root cause, and where the report begins', + ], + correctIndex: 0, + explanation: + 'The carrier was the first thing they did to the station. The export was the first thing they took from it, and it is why the rest was possible. The January decommissioning is a finding, not an event; it goes in continuity, not the timeline.', + pointPenalty: 5, + documentSection: 'Timeline', + documentLine: + '13 Apr 01:58Z password spray on svc-legacy (decommissioned 14 Jan, still active; disabled 02:40Z). 02:33Z full station configuration exported by svc-legacy, no change ticket. Every subsequent event used a parameter contained in that export.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'build-the-timeline', + nice: ['T1405', 'K0946'], + title: 'Build the Timeline', + description: + 'Flag the uplink denial and the timing skew entries, then write the rest of the timeline in one line: four events, four inputs, each with its date, its parameter, and what it cost.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-origin'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Uplink Denial Entry Flagged', + params: { eventId: 'evt-s19-denial' }, + mustMaintain: true, + }, + { + type: 'security-event-acknowledged', + description: 'Timing Skew Entry Flagged', + params: { eventId: 'evt-s20-skew' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Timeline Written', + params: { + character: Character.SYSTEM, + question: 'Which line completes the timeline?', + options: [ + '14 Apr terrestrial interference 11726/11690 MHz following the plan (IR to ComReg); 16 Apr uplink denial on 14035 MHz, hop set spent; 19 Apr GNSS spoof local to Galway, 05:08-05:36, +2 us/s; 21 Apr replay of HK-DUMP at SAR-2, key intact, rotated out of cycle', + 'Four attacks defeated: notch filter, TRANSEC, holdover, key rotation - the station held every time and nothing was lost', + '14-21 Apr: escalating hostile activity by a state actor against NATS Europe, culminating in an attempted seizure of the command link', + '14 Apr interference; 16 Apr jamming; 19 Apr GPS outage; 21 Apr counter fault - four unrelated events in a bad week', + ], + correctIndex: 0, + explanation: + 'Dates, inputs, parameters, cost. "Defeated" is a mood, "state actor" is a claim, and "unrelated" is what the export line already disproved. The timeline carries the numbers; the attribution section carries the argument.', + pointPenalty: 5, + documentSection: 'Timeline', + documentLine: + '14 Apr terrestrial interference 11726 then 11690 MHz, 60 s on / 30 s off, following the station frequency plan (IR-2027-0414-GW01 to ComReg). 16 Apr uplink denial on the reissued 14035 MHz TT&C carrier; HOP-SAR2-04 spent. 19 Apr GNSS spoof local to GW-01 05:08-05:36Z, +2 us/s, SH-02 clean; SAR-1 flown in holdover. 21 Apr replay of HK-DUMP at SAR-2 09:26Z, counter stale, key intact; Q2 rotated to Q2b out of cycle.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-1', + description: 'The desk waits for the sky. Retune RX modem 1 to 1414 MHz and frame the analyzer on 1414 with a 40 MHz span for the 14:30 pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['build-the-timeline'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1414 MHz', + params: { centerFrequency: 1414e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 14:30:00 from azimuth 025, southbound. Park the tracker on the rise azimuth at 5 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 025 / El 5', + params: { azimuth: 25, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Program-track SAR-1 before 14:30 and confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar1', + nice: ['T0153', 'K0645'], + title: 'Operations Continue', + description: 'Lock the SAR-1 carrier and hold C/N above 8 dB through the high segment. A routine pass, flown clean, in the middle of the report - which is the point.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Continuity Read', + params: { + character: Character.SYSTEM, + question: 'Why fly a routine pass in the middle of writing the report?', + options: [ + 'Because the report is about a station that is still operating: continuity is the finding, and a pass flown clean today is a line in it', + 'To bait the adversary: a pass during the report may draw another event that can be recorded', + 'It should not be flown: the station is under investigation and every pass adds exposure until the report is out', + 'Because Rotterdam scheduled it and the schedule cannot be changed inside twenty-four hours', + ], + correctIndex: 0, + explanation: 'Ten days of pressure and the station has not missed a pass. That sentence is what the board reads first. Fly it, log it, go back to the desk.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'attribute-the-actor', + nice: ['S0852', 'T1405', 'S0648'], + title: 'Attribute the Pattern', + description: + 'Back at the desk. With the record open and the log signed off, make the call the report turns on: are these five events one thing, and how far does the evidence let you say so?', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar1'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'record-read', + type: 'campaign-document-reviewed', + description: 'Campaign record open', + params: {}, + mustMaintain: true, + }, + { + id: 'chain-read', + type: 'audit-log-reviewed', + description: 'Audit chain read on the Security console', + params: { requiresObservation: true, observationTab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Attribution', + params: { + character: Character.PRIYA_SHARMA, + prompt: + 'Five events, five inputs, ten days. I can write "one actor" if the evidence carries it, and I can write "we cannot say" if it does not. Which is it, and what makes it so?', + evidence: ['record-read', 'chain-read'], + decisionOptions: [ + { + label: + 'One actor, one method: five inputs probed in order of the access each needs, every one inside the parameters exported on the 13th - and the KG-01 log ties the command-link probe to the same off-station source', + correctWhen: { fact: 'evidence-chain-intact', is: true }, + consequence: { log: 'Attribution: single actor, single method, origin the 13 April configuration export; command-link probe tied by the KG-01 unit log' }, + }, + { + label: + 'One actor for the first four; the command-link event cannot be tied to them - the KG-01 unit log was destroyed with the key, so the report records the gap and why', + correctWhen: { fact: 'evidence-chain-intact', is: false }, + consequence: { + pointDelta: 5, + log: 'Attribution: single actor for four events; command-link probe unattributable - KG-01 unit log destroyed 21 Apr', + }, + feedback: 'That is the right call for the record you have. It is also the cost of Wednesday: the one line that named the source is the one that was zeroized.', + }, + { + label: 'Unrelated events: interference is common on the west coast, sprays are constant, the spoof was a GNSS anomaly and the replay a counter glitch', + feedback: + 'Each of those explanations was ruled out on its own day, in its own record: the carrier followed the plan, the spoof was local, the replay matched your own frame. Five coincidences inside one exported parameter set is not the simpler story.', + }, + { + label: 'An insider: only someone on the station could have known the frequency plan, the command carrier and the key schedule', + feedback: + 'The export gave the plan, the carrier and the schedule to whoever pulled it - from a remote source, through an account that should not have existed. Nobody on the station needed to know anything.', + }, + ], + explanation: + 'The chain is: export, then every input in turn using what the export contained. The command-link probe is the only event the station itself can tie to an off-station source, and only through the KG-01 log. With it, the report says one actor. Without it, the report says one actor for four events and an unattributable fifth - and says why.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'the-referral', + nice: ['T1427', 'K0721'], + title: 'Refer the Location', + description: + "The report can say where the interferer was not - not a spacecraft, not this station - and cannot say where it was. Write the referral, and take Charlie's call.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['attribute-the-actor'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Referral Written', + params: { + character: Character.SYSTEM, + question: 'Who can locate a terrestrial emitter that a single dish can only characterise, and what does the report say about it?', + options: [ + 'A geolocation team with two or more receivers and time- and frequency-difference processing - not a single dish. The report refers it to specialist assets and says so', + 'ComReg: the regulator locates interferers as part of every IR, so the report waits for their finding', + 'The station, given time: a 4 m dish scanning in azimuth at the next event will bearing-fix the source', + "Nobody: an emitter that follows the plan is under the adversary's control and moves; location is not a useful finding", + ], + correctIndex: 0, + explanation: + 'One receiver gives a bearing and a signature. A fix needs two or more, the time and frequency differences between them, and someone whose job that is. The report names the limit and the referral; it does not pretend to a capability the station lacks.', + pointPenalty: 5, + documentSection: 'Referral', + documentLine: + 'Geolocation of the terrestrial emitter(s) of 13-14 Apr referred to specialist assets (TDOA/FDOA); NATS Europe holds no such capability. Contact route via C. Brooks (site lead). Bearing, signature and duty cycle from IR-2027-0414-GW01 provided as the starting data.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'continuity-plan', + nice: ['S0593', 'K0645'], + title: 'Continuity of Operations', + description: 'Ten days of events produced ten days of changes to how the station runs. Write them down as recommendations, in the order they would have mattered.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-referral'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Recommendations Written', + params: { + character: Character.SYSTEM, + question: 'Which set of recommendations goes in the continuity section?', + options: [ + 'Decommissioned accounts disabled on the day, change tickets for any configuration export; spare key and hop set staged before every commanding pass; delta-T on every sweep with SH-02 as the cross-check; unit logs exported to the record on any anomaly', + 'Move commanding to Shetland, run the reference on holdover permanently, and stop exporting configuration altogether', + 'Replace the GPSDO, the KG-01 unit and the legacy telemetry bridge; the events exploited equipment, and new equipment closes them', + 'Suspend commercial passes until the emitter is located; the station cannot be defended while the source is unknown', + ], + correctIndex: 0, + explanation: + 'Each recommendation is one of the ten days, stated as a habit. None of them is new equipment and none of them stops the station working. That is what continuity means: the same station, run differently.', + pointPenalty: 5, + documentSection: 'Continuity', + documentLine: + 'Continuity: (1) decommissioned accounts disabled on the day; configuration exports require a change ticket. (2) Spare command key and hop set staged before every commanding pass; key rotated after any contested window. (3) GNSS vs REF delta-T on every dashboard sweep; SH-02 delta-T is the cross-check before any reference action. (4) Unit and monitoring logs exported to the incident record on any anomaly, never cleared. No change to the pass plan.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'board-summary', + nice: ['T1606', 'T1580'], + title: 'Brief the Board', + description: 'One paragraph for people who will not read the timeline: impact, exposure, cost, and the ask.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['continuity-plan'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Board Line Written', + params: { + character: Character.SYSTEM, + question: 'What does the board paragraph say?', + options: [ + 'Five deliberate events over ten days against every input the station has, enabled by one configuration export; no pass lost, one hop set and one key rotation spent, customer collects delivered; source unlocated and referred; four procedural changes already in force', + 'The station has been under sustained attack by a hostile state and requires immediate investment in hardened equipment and additional staff', + 'Several technical anomalies occurred during April; all were resolved by the operator on duty and no further action is required', + 'A security breach on 13 April exposed the station configuration; the full extent of the compromise is still unknown', + ], + correctIndex: 0, + explanation: + 'What happened, what it cost, what was delivered anyway, what is open, what has changed. Nothing the board cannot check against the timeline, nothing that asks them to take a guess on trust.', + pointPenalty: 5, + documentSection: 'Board', + documentLine: + "Board summary: five deliberate events 13-21 Apr against GW-01's downlink, uplink, timing reference and command link, enabled by an unauthorised configuration export on 13 Apr. No pass lost; all NMW collects delivered. Cost: one TRANSEC hop set, one out-of-cycle key rotation, two operator-hours per event. Source unlocated, referred to specialist assets. Four procedural changes in force from 22 Apr; no capital ask.", + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'notifications', + nice: ['T1427', 'S0593'], + title: 'Notify', + description: 'Who is told what, by whom. The regulator, the customer, Rotterdam, Group CSIRT - and who is not told by this station.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['board-summary'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Notifications Set', + params: { + character: Character.SYSTEM, + question: 'Who is notified, and of what?', + options: [ + 'ComReg: IR-2027-0414 extended with the 16 Apr denial. NMW (customer): impact statement, no collects lost. Rotterdam: the full report. Group CSIRT: the report plus the sealed KG-01 log and Q2. Law enforcement: by CSIRT, not by the station', + 'Everyone gets the full report: the regulator, the customer, Rotterdam, CSIRT and the police, at the same time, from the station', + 'Only Group CSIRT: the report is an internal security document and nothing leaves the company until CSIRT decides', + 'The customer and the regulator are not told: no collect was lost and no rule was broken, so there is nothing to report', + ], + correctIndex: 0, + explanation: + 'Each party gets what it needs and can act on. The regulator has an open IR to extend; the customer has a service to be told about; Rotterdam runs the constellation; CSIRT owns the case and the evidence, and the referral to the police is theirs to make. The station reports up and out, not sideways.', + pointPenalty: 5, + documentSection: 'Notifications', + documentLine: + 'Notifications 22 Apr: ComReg - IR-2027-0414-GW01 extended (16 Apr uplink denial, 14035 MHz). Nordic Maritime Watch - customer impact statement (no collects lost, one re-tasked to SH-02). Rotterdam constellation ops - full report. Group CSIRT - full report, sealed KG-01 log and KEY-2027-Q2, geolocation referral. Law-enforcement referral: Group CSIRT.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'close-the-record', + nice: ['T1606', 'K0946'], + title: 'Close the Record', + description: 'The report header: title, period, scope, finding, in one line that anyone opening the file reads first.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['notifications'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Header Written', + params: { + character: Character.SYSTEM, + question: 'Which line heads the report?', + options: [ + 'IAR-2027-0422-GW01: five events 13-21 Apr against GW-01 Galway (downlink x2, uplink, timing, command link), assessed as one actor and one method enabled by the 13 Apr configuration export; attribution to a named party not made; source referred for geolocation', + 'IAR-2027-0422-GW01: sustained hostile campaign by a state-level adversary against NATS Europe, April 2027', + 'IAR-2027-0422-GW01: summary of April anomalies at GW-01 Galway, all resolved', + 'IAR-2027-0422-GW01: security incident - station configuration compromised 13 Apr; consequences ongoing', + ], + correctIndex: 0, + explanation: + 'The header says what the report is, what it covers, what it found and where it stopped. "State-level" is a claim, "resolved" is a wish, and "compromised" without the rest is a headline. This one is a finding.', + pointPenalty: 5, + documentSection: 'Report', + documentLine: + 'IAR-2027-0422-GW01. Period 13-21 Apr 2027. Scope: GW-01 Galway, SH-02 Shetland, MERIDIAN-SAR-1/2 links. Finding: five deliberate events against every input of the station, assessed as a single actor using a single method - parameters obtained by the unauthorised configuration export of 13 Apr 02:33Z. Attribution to a named party not made. Terrestrial source referred for geolocation.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'close-and-debrief', + nice: ['T1580', 'K0645', 'K0946'], + title: 'Debrief', + description: 'The report is out. The line that goes in the record about the record: what this station learned about reading its own history.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['close-the-record'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Debrief Line', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line?', + options: [ + 'Five records closed on their own days were one incident that nobody read until the sixth day: the campaign record is reviewed at every shift change from now on, and any two anomalies inside a week are correlated before either is closed', + 'The station handled every event correctly and the report proves it; the procedures need no change', + 'The report should have been written on the 13th: a configuration export is an incident on its own and everything after it was avoidable', + 'The records should be kept by CSIRT, not the station: operators close incidents too early because they want to fly the next pass', + ], + correctIndex: 0, + explanation: + "Each day's call was right and each day's record was good. What was missing was the reading of them together, and that is a habit, not a procedure. The record is opened at every handover; two anomalies in a week are one question until proven otherwise.", + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed 15:40Z. Open: geolocation referral (specialist assets via C. Brooks); law-enforcement referral (Group CSIRT). Debrief: campaign record reviewed at every shift change; any two anomalies within seven days are correlated before either record is closed; this report is the template for the next one.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Priya Sharma at 13:40] +

+

+ "I am on the desk with you at 14:00. Bring nothing; the campaign record and the audit log are the whole brief. We write the report that reads the five together, you fly SAR-1 at 14:30 in the middle of it because that is what the report is about, and by 15:30 it leaves the building. Whether the last line is in it depends on what you kept yesterday." +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'open-the-record': { + text: ` +

+ Five records. Read them as one document and tell me the first thing you notice is not the carrier on the 13th. It is what was taken at 02:33 that morning. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'decode-sar1': { + text: ` +

+ SAR-1 frames clean, timestamps true, C/N where it should be. That is the eleventh consecutive pass since the 13th with nothing lost. Put it in the report; I would. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'attribute-the-actor': { + text: ` +

+ That is the finding, and it is exactly as far as the evidence goes and no further. I will write it as you said it. Now the parts of a report that leave the building. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'the-referral': { + text: ` +

+ The referral is right; we cannot fix an emitter from one dish. For what it is worth, I know someone who does exactly that for the military - two receivers, time differences, the whole thing. I will make a call. If they want a contractor who has seen this from the receiving end, I know one of those too. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'close-and-debrief': { + text: ` +

+ It is out. Regulator, customer, Rotterdam, my desk. Five days of right calls and one document that made them one thing. The next station on their list will get this report before they get the carrier. Well kept - all of it. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario23.ts b/src/campaigns/nats-eu/scenario23.ts new file mode 100644 index 00000000..3d167ab5 --- /dev/null +++ b/src/campaigns/nats-eu/scenario23.ts @@ -0,0 +1,1040 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 23 - "Dark Passes" / Degraded Manual Operations (Gray Zone arc 7/8) + * + * The report went out on Thursday. Saturday night the answer comes back, all + * at once. SAR-2 has a payload thermal problem and Rotterdam needs PLD-SAFE up + * on the 21:22 pass - the single tasking that matters tonight. At 21:14 three + * things happen inside a minute: the GNSS vs REF delta-T starts walking again + * (spoofer, Galway), SAR-2 executes a debris-avoidance burn and the station's + * element set goes stale, and Anneke says the carrier will be sat on the + * moment the window opens. Eight minutes to AOS. + * + * The lesson is prioritisation under several simultaneous faults (13.5): + * not every alarm is the pass. The ephemeris is the pass - without it there is + * nothing to point at. The reference walk is the timestamps - holdover takes + * ten seconds and can wait until the tracker is fixed. The jammer is not on + * the air yet, and a hop set is spent on a called denial, not on a forecast. + * Then it all happens in the window: HK-DUMP ACKs on the fixed carrier, + * PLD-SAFE NAKs "uplink denied", the operator calls it with the reference in + * holdover and the key Valid, hops, and PLD-SAFE goes up through the jamming. + * + * Phase 18: two decisions. `triage` is graded on the clock - at 21:14 + * timing-drifting is true and uplink-jammed is false, so "ephemeris first" is + * right and "hop set first" is wrong; a player who dawdles into the window + * finds the second option graded right instead. `call-the-denial` is S19's + * call again with the reference free-running, which is the trap: the denial + * is not the holdover. + * + * Clock starts 2027-04-24 21:00:00 UTC. Passes (0 deg horizon, Galway, + * scripts/author-passes.mjs, epoch 21:00:00Z): + * + * MERIDIAN-SAR-2: AOS T+22.03 (21:22:01Z, az 022), max el 32.8 deg at T+26.83 + * (21:26:50Z, 663 km), LOS T+31.58 (21:31:35Z, az 177), 9.5 min, + * southbound. The commanding pass; window 21:22:21 .. 21:31:15 + * (T+1341 .. T+1875). Post-burn orbit. + * MERIDIAN-SAR-1: AOS T+39.97 (21:39:58Z, az 004), max el 27.0 deg, LOS T+49.45. + * In the sky, not tasked. + * + * The surge, on the mission clock: + * T+840 (21:14:00Z) settings.gnssThreat spoof begins, GW-01 only, 2 us/s, to end. + * T+840 (21:14:00Z) settings.spaceEvents SAR2-DAM: SAR-2 moves onto the post-burn + * set; the station keeps predicting from the pre-burn set (initialTle, + * mean anomaly -0.03 deg) until the operator loads the update. + * T+1470 (21:24:30Z) settings.interferenceEvents sar2-uplink-jam-2: transponder + * path on the 14035 MHz command carrier, 2 MHz, to 21:31:30Z. + * + * Staged state (scenario-local clone of GW-01): TX modem 1 on 1405 MHz - to be + * retuned to 1435; RX modem 1 on 1414 - to be retuned to 1370. BUC muted. + * KEY-2027-Q2b live; hop set HOP-SAR2-05 in the COMSEC store, inactive. + * + * NICE Framework Alignment: + * Primary Codes: + * - S0671: Skill in prioritizing tasks under contingency conditions + * - T1277: Perform contingency operations + * - S0424: Skill in operating under degraded conditions + * + * Supporting Codes: + * - S0593: Skill in handling incidents + * - K0926: Knowledge of signal jamming tools and techniques + * - K0752: Knowledge of system vulnerabilities + * - T1138: Update ephemeris / orbital data + * - K0925: Knowledge of wireless technologies and their security + * - T1567: Configure system hardware, software, peripheral equipment + * - S0675: Skill in configuring RF transmit equipment within safe limits + * - K1032: Knowledge of satellite-based communication systems + * - S0421: Skill in operating communications equipment + * - K0645: Knowledge of standard operating procedures + * - K0741: Knowledge of alarm states and their meaning + * - T0153: Monitor network capacity and performance + * - T1580: Report service status to stakeholders + */ + +/** MERIDIAN-SAR-2 (61702) at the S23 epoch, post-burn: the 33 deg commanding pass, southbound from az 022. */ +const SAR2_S23_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27114.87500000 .00001000 00000-0 10000-3 0 9991' as TleLine1, + tle2: '2 61702 98.4000 342.0000 0010000 90.0000 292.2500 15.63040000123452' as TleLine2, +}; + +/** The pre-burn set the station is still predicting from at 21:00 (mean anomaly -0.03 deg). */ +const SAR2_S23_PRE_BURN_TLE2 = '2 61702 98.4000 342.0000 0010000 90.0000 292.2200 15.63040000123452'; + +/** MERIDIAN-SAR-1 (61701) at the S23 epoch: a 27 deg pass at 21:40, untasked. */ +const SAR1_S23_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27114.87500000 .00001000 00000-0 10000-3 0 9990' as TleLine1, + tle2: '2 61701 97.2000 329.7500 0010000 90.0000 220.0000 15.59010000123451' as TleLine2, +}; + +const meridianSar1S23 = createMeridianSar1(SAR1_S23_TLE); +const meridianSar2S23 = createMeridianSar2(SAR2_S23_TLE); + +/** GW-01 as the day shift left it: RX on SAR-1's video IF, TX on SAR-1's command IF, BUC muted. Deep clone, never spread. */ +const galwayDark: GroundStationConfig = structuredClone(galwayGroundStation); +galwayDark.receivers![0].modems![0] = { + ...galwayDark.receivers![0].modems![0], + frequency: 1414 as MHz, +}; +galwayDark.rfFrontEnds[0].buc = { ...galwayDark.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario23Data: ScenarioData = { + id: 'nats-eu-scenario23', + url: 'nats-eu/scenarios/nats-eu-scenario23', + imageUrl: 'nats/23/card.png', + number: 23, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario22'], + title: 'Dark Passes', + subtitle: 'Degraded Manual Operations (Gray Zone 7/8)', + duration: '40 min', + missionType: 'Security Operations', + description: `21:00, Galway, Saturday. SAR-2 has a payload thermal excursion and Rotterdam needs PLD-SAFE up on the 21:22 pass. One command. That is the shift.

The report went out on Thursday with their method in it. Priya thinks they read it. If everything they have tried once arrives at the same time tonight, the only question is which of it can lose the pass - and in what order you deal with the rest.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front End (13100 MHz LNB LO, 12600 MHz BUC LO) + HPA', + 'GPS-disciplined 10 MHz reference (GPS Timing panel)', + 'Pass Schedule with ephemeris inbox', + 'TT&C Commanding Console with TRANSEC waveform (Security console)', + 'Spectrum Analyzer', + 'QPSK 3/4 RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayDark, shetlandGroundStation], + satellites: [meridianSar1S23, meridianSar2S23], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-24', + scenarioStartWallTime: '21:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-23?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Surge Log SL-2027-0424-GW01', + description: 'The night of 24 April: what arrived at 21:14, in what order it was dealt with, and whether PLD-SAFE went up.', + }, + + // M2/M5 - the commanding pass. uplinkFrequencyHz couples the console to + // the jammer below: fixed-mode commands NAK 'uplink-jammed' while it is in + // its envelope and TRANSEC is not synced. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61702, + windowStartS: 1341, + windowEndS: 1875, + requireDopplerComp: true, + requireValidKey: true, + uplinkFrequencyHz: 14035e6, + commands: [ + { id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + { id: 'PLD-STATUS', label: 'Payload status request' }, + ], + }, + + // M7 - HOP-SAR2-05, staged since Wednesday. + transec: { + groundStationId: 'GW-01', + hopChannelsHz: [14031e6, 14033e6, 14035e6, 14037e6, 14039e6], + requireKey: true, + }, + + // M8 - the spoofer, back, from 21:14 to the end of the shift. GW-01 only. + gnssThreat: { + groundStationIds: ['GW-01'], + spoofStartS: 840, + offsetDriftUsPerS: 2, + }, + + // M4 - the burn. SAR-2 moves onto the post-burn set at 21:14; the + // station predicts from the pre-burn set until the update is loaded. + spaceEvents: [ + { + id: 'SAR2-DAM', + satelliteNoradId: 61702, + maneuverAtS: 840, + label: 'MERIDIAN-SAR-2 debris-avoidance manoeuvre executed 21:14Z; post-burn element set issued', + newTle: { tle1: SAR2_S23_TLE.tle1 as string, tle2: SAR2_S23_TLE.tle2 as string }, + initialTle: { tle1: SAR2_S23_TLE.tle1 as string, tle2: SAR2_S23_PRE_BURN_TLE2 }, + }, + ], + + // The jammer: on the command carrier at the satellite from 21:24:30, two + // minutes into the window, for the rest of it. + interferenceEvents: [ + { + id: 'sar2-uplink-jam-2', + satelliteNoradId: 61702, + frequency: 14035e6, + bandwidth: 2e6, + power: 5, + polarization: 'H', + startTime: 1470, + duration: 420, + periodSeconds: 420, + onSeconds: 420, + }, + ], + + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'kg-01', name: 'KG-01 command crypto unit', role: 'Equipment', status: 'active' }, + ], + events: [ + { + id: 'evt-iar-issued', + timeS: 0, + timestampLabel: '22 Apr 15:40 UTC', + actor: 'op-duty', + action: 'Incident attribution report IAR-2027-0422-GW01 issued (Rotterdam, ComReg, NMW, Group CSIRT)', + category: 'config', + severity: 'info', + }, + { + id: 'evt-hopset-05-staged', + timeS: 0, + timestampLabel: '21 Apr 10:15 UTC', + actor: 'op-charlie', + action: 'TRANSEC hop set HOP-SAR2-05 staged to COMSEC store (inactive); SAR-2 keyed at Rotterdam', + category: 'command', + severity: 'info', + }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '20:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { + id: 'evt-pld-thermal', + timeS: 0, + timestampLabel: '20:58 UTC', + actor: 'svc-monitor', + action: 'SAR-2 telemetry: payload thermal excursion +6 degC above limit; Rotterdam requests PLD-SAFE on the 21:22 GW-01 window', + category: 'command', + severity: 'warning', + }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'S0671'], + title: 'Read the Brief', + description: 'Open the shift brief: one command that has to go up tonight, everything they have tried once, and the order things lose a pass in.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Priority Understood', + params: { + character: Character.SYSTEM, + question: 'What is the single tasking tonight, and what does "highest priority" mean for everything else?', + options: [ + 'PLD-SAFE up on SAR-2 inside 21:22:21-21:31:15. Everything else is dealt with in the order it can cost that command - and anything that cannot cost it waits', + 'A clean pass on SAR-2 with every alarm cleared before AOS; a command on a degraded station is not sent', + "Both passes, SAR-2 and SAR-1, flown to the normal standard; the thermal excursion is Rotterdam's problem", + 'Protecting the station: if the surge comes, the uplink stays cold and the command goes on the next window', + ], + correctIndex: 0, + explanation: + 'One command, one window, and a bird that is warming up. Priority means every other thing on the board is ranked by whether it can stop that command going up, and only that. Twenty-two minutes to AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Board read. Confirm the active alarm state on GW-01 before the uplink goes anywhere near the air.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 21:00?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear for the pass', + 'Payload thermal alarm - SAR-2 is over limit and the board carries the spacecraft warning', + "Ephemeris alarm - SAR-2's element set is stale and the pass cannot be predicted", + ], + correctIndex: 0, + explanation: + "Empty sky, not a fault. The spacecraft's thermal problem is in the log and on Rotterdam's screen, not on this station's alarm panel, and the element set is current - for another fourteen minutes.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'arm-the-uplink', + nice: ['S0421', 'K0773', 'T1567'], + title: 'Arm the Uplink', + description: + "TX modem 1 is on SAR-1's 1405 MHz command IF. Retune it for SAR-2's 14035 MHz TT&C uplink through the 12600 MHz BUC LO, and engage uplink Doppler compensation before anything else happens.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX 1435 MHz', + params: { modemNumber: 1, frequency: 1435e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-2', + description: 'Retune RX modem 1 to 1370 MHz and frame the analyzer on 1370 with a 40 MHz span.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['arm-the-uplink'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1370 MHz', + params: { centerFrequency: 1370e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'stage-the-hop-set', + nice: ['K0925', 'K0645'], + title: 'Stage the Hop Set', + description: 'Security console: HOP-SAR2-05 is in the store and SAR-2 is keyed at Rotterdam. Read the TRANSEC card and leave it exactly as it is.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Security Console Open', + params: { tab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Hop Set Staged, Not Loaded', + params: { + character: Character.SYSTEM, + question: 'Priya expects the jammer back tonight and the set is staged at both ends. Why is the key still not loaded at 21:05?', + options: [ + 'A hop set is spent the moment it is used on the air. It goes on for a called denial inside the window, not for a forecast - and the first command of the pass still goes on the fixed carrier', + 'Because loading it drops the fixed carrier: the modem cannot hold a key and a fixed waveform at the same time', + 'Because Rotterdam has not keyed SAR-2 yet - the store entry says staged, and staged means one end only', + 'It should be loaded now: a jammer that is expected is a jammer that is already denying the carrier', + ], + correctIndex: 0, + explanation: + "S19's lesson, unchanged by a forecast. If they do not jam, the set stays for the next time; if they do, you hop on the call with the store one click away. What is different tonight is that everything else is going to be happening at once.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'read-the-surge', + nice: ['S0648', 'K0741'], + title: 'Read the Surge', + description: + '21:14. The GNSS vs REF delta-T is walking, the Pass Schedule tab says EPHEMERIS STALE against SAR-2, and Anneke is on the line. Read the delta-T on the GPS Timing tab and say what is on the board.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['stage-the-hop-set'], + conditions: [ + { + type: 'gpsdo-time-offset-exceeds', + description: 'Delta-T Read Walking on GPS Timing', + params: { minOffsetUs: 20, requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Board Read', + params: { + character: Character.SYSTEM, + question: 'What is on the board at 21:14, and what is not?', + options: [ + 'A GNSS spoof (delta-T walking, constellation healthy) and a stale element set (SAR-2 burned at 21:14). Not on the board: any jamming - the carrier is clear and the window is eight minutes away', + 'A GNSS outage, a tracking fault and a jammer - three hardware failures at once, and the station should be declared unavailable', + "A GNSS spoof only; the ephemeris notice is routine and the jammer is Priya's guess", + 'A jammer on the command carrier; the timing and ephemeris notices are side effects of it', + ], + correctIndex: 0, + explanation: + 'Two things are true and one is expected. The spoof and the burn are on the panels now; the jammer is a warning about the future. Deal with what is on the board, in the order it can cost the pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'triage', + nice: ['S0671', 'T1277', 'S0593'], + title: 'Triage', + description: 'Read the delta-T on GPS Timing and the stale flag on the Pass Schedule tab, then tell Charlie the order: what first, what second, what waits.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-surge'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'offset-seen', + type: 'gpsdo-time-offset-exceeds', + description: 'Delta-T read on GPS Timing', + params: { minOffsetUs: 20, requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + id: 'ephemeris-seen', + type: 'tab-active', + description: 'Pass Schedule read (SAR-2 EPHEMERIS STALE)', + params: { tab: 'pass-schedule', requiresObservation: true, observationTab: 'pass-schedule' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'The Order', + params: { + character: Character.CHARLIE_BROOKS, + prompt: 'Three things and eight minutes. I am not going to tell you the order. What goes first, what goes second, and what waits for a reason to happen?', + evidence: ['offset-seen', 'ephemeris-seen'], + decisionOptions: [ + { + label: + 'Ephemeris first - without it there is no pass to point at. Then holdover: ten seconds, and it stops the timestamps walking. The hop set waits for a called denial inside the window', + correctWhen: { + all: [ + { fact: 'timing-drifting', is: true }, + { fact: 'uplink-jammed', is: false }, + ], + }, + consequence: { log: 'Triage: ephemeris, then holdover, hop set held for a called denial' }, + }, + { + label: + 'Holdover first - the reference walk corrupts everything downstream, including the pass prediction. Then the ephemeris, then load the hop set so the first command goes up hopping', + feedback: + 'The reference walk corrupts timestamps; it does not move the bird. The stale element set does. And a hop set loaded before any denial is a hop set spent on a forecast - the first command goes on the fixed carrier, as it did on the 16th.', + }, + { + label: 'Hop set first - the carrier is about to be sat on and the key takes the longest to bring up. Ephemeris and holdover after AOS', + correctWhen: { fact: 'uplink-jammed', is: true }, + consequence: { log: 'Triage: hop set first with the carrier already denied' }, + feedback: + "The carrier is clear and the window is minutes away. Keying the set now spends it on Priya's forecast, and while you are on the Security console the tracker is still pointing at where the bird was before it burned.", + }, + { + label: 'Stand down - three simultaneous faults is a station that cannot be trusted with a command tonight. Tell Rotterdam PLD-SAFE goes on the next window', + feedback: + 'None of the three is a fault on this station: the reference is fine, the tracker is fine, the uplink is fine. Each is an input being lied to, and each has a ten-second answer. The payload does not have a next window.', + }, + ], + explanation: + 'Rank by what loses the pass. A stale element set loses it outright, so the update goes first. A walking reference loses nothing tonight but the timestamps, so holdover goes second and takes ten seconds. A jammer that is not on the air yet costs nothing until it is - and the answer to it is one click away when it does.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'load-the-ephemeris', + nice: ['T1138', 'K1032'], + title: 'Load the Post-Burn Elements', + description: + 'Pass Schedule tab: the SAR-2 entry reads EPHEMERIS STALE. Load the update. The station recomputes the pass and program-track will slew to where the bird actually is.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['triage'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ephemeris-updated', + description: 'Post-Burn Ephemeris Loaded', + params: { eventId: 'SAR2-DAM' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Burn Read', + params: { + character: Character.SYSTEM, + question: 'A debris-avoidance burn eight minutes before a pass you have to command on. What does the pre-burn set get you at AOS?', + options: [ + 'A tracker pointing where the bird would have been: a few seconds along-track, which at 660 km is a beamwidth - the beacon never shows and the window opens on nothing', + 'A late acquisition: the bird arrives a few seconds after the prediction and program-track catches it up on its own', + 'Nothing different: a burn that small moves the pass timing by under a second', + 'A wrong azimuth at the horizon, corrected by the step-track layer once the beacon is in the beam', + ], + correctIndex: 0, + explanation: + 'Along-track error at LEO range is pointing error. Program-track does not search; it points where the set says. Load the post-burn set and the slew is immediate.', + pointPenalty: 5, + documentSection: 'Surge', + documentLine: + '21:14Z surge: GNSS vs REF delta-T walking +2 us/s (constellation healthy - spoof, GW-01); SAR-2 debris-avoidance burn 21:14Z, element set stale. Triage: ephemeris first, holdover second, hop set held. Post-burn set loaded from the Pass Schedule inbox.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'go-to-holdover', + nice: ['S0593', 'K0752'], + title: 'Stop Trusting GNSS', + description: 'GPS Timing tab: GNSS switch down. Ten seconds. The reference free-runs on the OCXO for the rest of the night; the frozen offset goes in the log.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['load-the-ephemeris'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-reference-mode-set', + description: 'Reference in Holdover', + params: { referenceMode: 'holdover' }, + mustMaintain: true, + maintainDuration: 20, + }, + { + type: 'status-check', + description: 'Holdover Timed', + params: { + character: Character.SYSTEM, + question: 'Why was holdover second and not first, when the delta-T was the first thing you saw?', + options: [ + 'Because the walk costs timestamps, not the pass: it can run for four minutes and cost a millisecond, while four minutes of stale ephemeris costs the acquisition', + 'Because the GPSDO needs the post-burn set to compute holdover - the reference and the ephemeris share the same clock', + "It should have been first: a walking reference makes the ACU's pass prediction wrong too", + 'Because holdover takes longer to engage than an ephemeris load and had to be started while the update was computing', + ], + correctIndex: 0, + explanation: + 'Same cost, different clocks. The spoof charges by the second and the bill is small; the stale set charges once and the bill is the pass. When both are on the board, the one that loses the pass goes first.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'AOS 21:22:01 from azimuth 022, southbound - on the post-burn set. Park the tracker on the rise azimuth at 5 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['go-to-holdover'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 022 / El 5', + params: { azimuth: 22, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'acquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-2', + description: 'Program-track SAR-2 on the post-burn set and confirm the 1397 MHz beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-2-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'chain-up', + nice: ['S0675', 'S0421', 'K0645'], + title: 'Chain Up in Order', + description: 'Window open at 21:22:21. Carrier into the muted BUC, unmute, HPA. Two minutes.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'first-command', + nice: ['T1567', 'K1032'], + title: 'Housekeeping Dump', + description: + 'Send HK-DUMP on the fixed carrier and confirm the ACK. The bird is up, the set is right, the reference is free-running: prove the link before the command that matters.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up'], + conditions: [ + { + type: 'command-acknowledged', + description: 'HK-DUMP Acknowledged', + params: { commandId: 'HK-DUMP' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Link Proven', + params: { + character: Character.SYSTEM, + question: 'HK-DUMP ACKed with the reference in holdover. What did that prove about the holdover?', + options: [ + 'That a free-running OCXO is a perfectly good 10 MHz for the uplink: Doppler compensation, the modem clock and the BUC LO all held. Only the timestamps carry the offset', + 'That the spoofer has stopped: an ACK is only possible with the reference re-disciplined', + 'Nothing - the ACK came back on the transponded downlink, which does not use the station reference', + 'That the holdover error is still under the 40 us spec limit; past it the bird would have refused the frame', + ], + correctIndex: 0, + explanation: + 'Same as Monday: stable, not true, is what the RF chain needs. The command went up on a reference that was lied to for four minutes and then told to stop listening. That is a dark pass, and it works.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-the-denial', + nice: ['S0648', 'K0926', 'K0751'], + title: 'Call the Denial', + description: + 'From 21:24:30 PLD-SAFE comes back NAK: uplink denied. The reference is in holdover, the key is Valid, Doppler is engaged. Read the console and the reference, then call it - and do not blame the holdover.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['first-command'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'console-seen', + type: 'uplink-doppler-comp-enabled', + description: 'TT&C console read', + params: { requiresObservation: true, observationTab: 'commanding' }, + mustMaintain: false, + }, + { + id: 'reference-seen', + type: 'gpsdo-reference-mode-set', + description: 'Reference read on GPS Timing (holdover)', + params: { referenceMode: 'holdover', requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Why the NAK', + params: { + character: Character.ANNEKE_VISSER, + prompt: 'PLD-SAFE did not reach me. Your key is Valid here, your reference is on holdover, and the payload is at plus seven. Six minutes of window. What is it?', + evidence: ['console-seen', 'reference-seen'], + decisionOptions: [ + { + label: 'Uplink denial on the fixed carrier - the jammer is back. Key HOP-SAR2-05 both ends, hop, resend. The reference stays in holdover', + correctWhen: { + all: [ + { fact: 'uplink-jammed', is: true }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Uplink denial called on the SAR-2 command carrier; HOP-SAR2-05 requested at both ends; reference held in holdover' }, + }, + { + label: "The holdover reference has drifted the carrier off the bird's receiver - put GNSS back and resend on the fixed carrier", + correctWhen: { fact: 'uplink-jammed', is: false }, + consequence: { log: 'NAK attributed to the holdover reference; GNSS re-selected under an active spoof' }, + feedback: + 'HK-DUMP ACKed on the same reference four minutes ago, and an OCXO drifts nanoseconds an hour. Putting GNSS back hands the reference to the spoofer again and does nothing for a carrier that is being sat on.', + }, + { + label: 'Key problem - Q2b has been on the air since Wednesday. Begin a rotation and resend under the next set', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { log: 'Command-key rotation begun on a Valid key during a denial' }, + feedback: 'Both ends read Valid and a key rejection is its own reason on the console. A rotation now spends two of your six minutes on nothing.', + }, + { + label: 'Stand down - PLD-SAFE goes up on the next window from Shetland', + feedback: 'Shetland has no command key and the payload is at plus seven. There is no next window for this command. The hop set was staged for exactly this minute.', + }, + ], + explanation: + 'Every reason the console can give for itself is clean, and the reference already proved itself on HK-DUMP. The carrier is being denied at the satellite. The holdover is not the fault; it is the thing that is still working.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'go-to-hopping', + nice: ['K0925', 'T1567', 'S0421'], + title: 'Go to Hopping', + description: 'Security console: Load Hop-Set Key (HOP-SAR2-05), waveform mode to Frequency hopping, wait for SYNC LOCKED. Anneke keys SAR-2 on the call.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-denial'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'transec-mode-set', + description: 'Waveform Mode: Frequency Hopping', + params: { transecMode: 'hopping' }, + mustMaintain: true, + }, + { + type: 'transec-sync-locked', + description: 'Hop-Sync Locked', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Second Set Read', + params: { + character: Character.SYSTEM, + question: 'This is the second hop set spent on this carrier in eight days. What does that say about the next commanding pass?', + options: [ + 'That the fixed carrier on 14035 is known and will be denied again: stage HOP-SAR2-06 before the next window and expect to hop on the first NAK, not the third command', + 'That hopping should be the default from AOS on every pass from now on - the sets are cheap compared with a lost window', + 'That the command carrier should move to a new frequency so the fixed waveform can be trusted again', + 'Nothing - each denial is its own event and the sets are consumed one at a time regardless', + ], + correctIndex: 0, + explanation: + 'Two denials on the same carrier is a pattern, not two events. The response is still a called hop - but staged closer, expected sooner, and written into the pre-pass sweep. Hopping from AOS burns a set on every pass that is not denied.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'ride-through', + nice: ['T1277', 'S0424', 'K1032'], + title: 'Keep the Tasking Alive', + description: 'Resend PLD-SAFE under TRANSEC, with the reference in holdover and the tracker on the post-burn set. That is the whole shift in one ACK.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['go-to-hopping'], + conditions: [ + { + type: 'command-acknowledged', + description: 'PLD-SAFE Acknowledged (hopping, holdover, post-burn)', + params: { commandId: 'PLD-SAFE' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Dark Pass Read', + params: { + character: Character.SYSTEM, + question: 'PLD-SAFE ACKed. Three inputs were being lied to or denied at that moment. Which of them did the command depend on?', + options: [ + 'None of them as they were: the tracker was on a set they did not control, the reference was on an oscillator they could not reach, and the carrier was on a sequence they did not have', + 'The GNSS reference - the command went up on a spoofed clock and the timestamp will have to be corrected', + 'The fixed carrier - the hop set rides on it and a denied carrier means a denied hop', + 'The pre-burn element set - program-track was still on it and caught the bird by luck', + ], + correctIndex: 0, + explanation: + 'A dark pass is one where every automatic input has been taken away and the pass still happens - because each input was replaced with something the adversary could not touch. That is what the last eight days were for.', + pointPenalty: 5, + documentSection: 'Window', + documentLine: + 'Window 21:22:21-21:31:15Z: HK-DUMP ACK 21:23 fixed carrier, reference in holdover. PLD-SAFE NAK 21:25 uplink denied (14035 MHz jammed at the spacecraft), key Valid, Doppler on. Called denial; HOP-SAR2-05 keyed both ends, hopping, SYNC LOCKED. PLD-SAFE ACK 21:27 under TRANSEC. Payload safed.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'safe-and-debrief', + nice: ['S0421', 'T1580', 'K0645'], + title: 'Safe the Uplink and Debrief', + description: 'LOS 21:31:35. HPA off, BUC muted, carrier off - and the line that goes in the log about a night when everything they had arrived at once.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['ride-through'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Debrief Line', + params: { + character: Character.SYSTEM, + question: 'What is the debrief line?', + options: [ + 'Three inputs lost at once and one command up: the order was ephemeris, holdover, hop - by what loses the pass, not by what alarmed first. HOP-SAR2-06 staged; the reference stays in holdover until a probe says the sky is honest', + 'The station survived a coordinated attack; commanding passes are suspended until the source is found', + 'Everything worked because every fix had been rehearsed once; no change to procedure', + 'The surge proves the report was a mistake: publishing the method invited the response', + ], + correctIndex: 0, + explanation: + 'Each of the three had been met once, alone. Tonight was the exam: all three, eight minutes, one command. The order is the lesson, and the report was not a mistake - it is why the next set was already staged.', + pointPenalty: 5, + documentSection: 'Debrief', + documentLine: + 'Closed 21:45Z. Open: spoofer active at close (reference in holdover, probe scheduled 22:30); uplink jammer on 14035 MHz (IR extension); HOP-SAR2-05 spent, HOP-SAR2-06 to be staged before the next window. Debrief: simultaneous faults ranked by what loses the pass - ephemeris, then reference, then a hop on a called denial. PLD-SAFE up 21:27Z; payload safed.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Priya Sharma at 20:40] +

+

+ "The report went out Thursday with their method in it, and I would bet they have read it. Anneke needs PLD-SAFE on the 21:22 pass - the payload is warming. If they come tonight they will come with everything at once, because one at a time did not work. Nothing they have done needs a new answer. It needs the answers in the right order." +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + objectives: { + 'read-the-surge': { + text: ` +

+ It is me, and it is all of it. SAR-2 burned at 21:14 for a debris pass - post-burn set is in your inbox now. Your timestamps started walking the same minute. And I would plan for the carrier to be sat on the moment the window opens. Eight minutes. Tell me the order. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + triage: { + text: ` +

+ That is the order. Ephemeris, because without it there is no pass. Holdover, because it is ten seconds. The hop set waits for a reason. Go. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'first-command': { + text: ` +

+ HK-DUMP acknowledged 21:23 on a reference you are not trusting and a set you loaded six minutes ago. Send PLD-SAFE. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'call-the-denial': { + text: ` +

+ Denial. Keying SAR-2 to HOP-SAR2-05 - done. Load yours, go to hopping, and put PLD-SAFE through it. The payload is at plus seven. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'ride-through': { + text: ` +

+ PLD-SAFE acknowledged 21:27 under TRANSEC. Payload safing - temperature already turning over. Whatever else they did tonight, they did not get that one. Chain down at LOS. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'safe-and-debrief': { + text: ` +

+ Everything they had, all at once, and the one command that mattered went up. That is not luck; that is eight days of doing each thing once until the order was obvious. Stage the next set. There is a storm coming Tuesday and I do not think they are finished. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario24.ts b/src/campaigns/nats-eu/scenario24.ts new file mode 100644 index 00000000..10defaa6 --- /dev/null +++ b/src/campaigns/nats-eu/scenario24.ts @@ -0,0 +1,1287 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, createMeridianSar3, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 24 - "North Atlantic Storm" / Campaign Capstone (Gray Zone arc 8/8) + * + * Tuesday, 03:00, an Atlantic low over Galway and the operator is incident + * commander for the network: two stations, three birds, one hour. The rain + * band is on GW-01 at 03:01 and sleet follows at 03:40; SAR-1 has to be + * collected from Galway in the lull at 03:20 with the terrestrial carrier + * back in its band; SAR-2 burns at 03:15 and has to be collected from Shetland + * at 03:31 on the post-burn set, with the spoofer on Shetland's reference + * this time and Galway as the clean cross-check; and SAR-3's commanding pass + * at 03:52 goes up through the sleet on a heated feed, gets its first ACK, and + * is denied on the second - the call, the hop, and PLD-SAFE through the + * jamming. Then the network is safed and the incident log closes the arc. + * + * Every mechanic of the campaign, once, in the order the network needs it: + * contact plan (M3), weather and feed heater (S14), terrestrial interference + * and the notch (S17/S18), ephemeris update (S7/S23), GNSS spoof and holdover + * (S20), commanding with Doppler and key (S3/S4), TRANSEC ride-through + * (S19/S23), the incident record (S22). Two decisions: the denial call on + * SAR-3, and none of the others - by now the calls are procedure. The + * capstone grades whether the procedure survives all of it at once. + * + * Clock starts 2027-04-27 03:00:00 UTC. Passes (0 deg horizon, + * scripts/author-passes.mjs, epoch 03:00:00Z): + * + * MERIDIAN-SAR-1 @ GW-01: AOS T+19.98 (03:19:58Z, az 005), max el 30.2 deg at T+24.74 + * (03:24:44Z, 714 km), LOS T+29.47 (03:29:28Z, az 217), southbound. + * NMW collect, in the lull between the rain band and the sleet. + * MERIDIAN-SAR-2 @ SH-02: AOS T+31.02 (03:31:01Z, az 139), max el 35.1 deg at T+35.99 + * (03:35:59Z, 688 km), LOS T+41.02 (03:41:01Z, az 347), northbound. + * NMW collect on the post-burn set. + * MERIDIAN-SAR-3 @ GW-01: AOS T+52.01 (03:52:00Z, az 023), max el 28.4 deg at T+56.81 + * (03:56:49Z, 754 km), LOS T+61.59 (04:01:35Z, az 173), southbound. + * Commanding; window 03:52:21 .. 04:01:15 (T+3141 .. T+3675). + * + * On the mission clock: + * T+60 (03:01) severe rain on GW-01, 30 mm/h, to 03:16. + * T+900 (03:15) SAR-2 conjunction-avoidance burn; station set stale (initialTle). + * T+1290 (03:21:30) terrestrial carrier 11690 MHz (1410 IF) in SAR-1's band at Galway, + * 60 s on / 30 s off, to 03:28:30. + * T+1500 (03:25) GNSS spoof on SH-02, 2 us/s, to the end. GW-01 is clean. + * T+2400 (03:40) sleet on GW-01 to 04:10 - feed heater before it. + * T+3300 (03:55) transponder-path jammer on SAR-3's 14065 MHz command carrier, to 04:01:40. + * + * Staged state (scenario-local clones): GW-01 TX modem 1 on 1405 MHz (retune to + * 1465 for SAR-3), RX modem 1 on 1414 (SAR-1; later 1340 for SAR-3), BUC muted. + * SH-02 RX modem 1 on 1414 (retune to 1370 for SAR-2). HOP-SAR3-01 staged. + * + * NICE Framework Alignment: + * Primary Codes: + * - S0807: Skill in managing multiple simultaneous operations + * - T1606: Report incidents to stakeholders + * - T0531: Coordinate contingency operations across sites + * + * Supporting Codes: + * - S0671: Skill in prioritizing tasks under contingency conditions + * - T1277: Perform contingency operations + * - S0593: Skill in handling incidents + * - K0926: Knowledge of signal jamming tools and techniques + * - K0752: Knowledge of system vulnerabilities + * - K0925: Knowledge of wireless technologies and their security + * - T1138: Update ephemeris / orbital data + * - K0689: Knowledge of contact scheduling + * - T0129: Coordinate contact plans across stations + * - S0421: Skill in operating communications equipment + * - K1032: Knowledge of satellite-based communication systems + * - T1567: Configure system hardware, software, peripheral equipment + * - S0675: Skill in configuring RF transmit equipment within safe limits + * - K0645: Knowledge of standard operating procedures + * - K0741: Knowledge of alarm states and their meaning + * - T0153: Monitor network capacity and performance + * - T0431: Monitor timing reference systems + * - K0737: Knowledge of filtering and interference mitigation + * - T1580: Report service status to stakeholders + */ + +/** MERIDIAN-SAR-1 (61701) at the S24 epoch: the 30 deg Galway collect, southbound from az 005. */ +const SAR1_S24_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27117.12500000 .00001000 00000-0 10000-3 0 9991' as TleLine1, + tle2: '2 61701 97.2000 58.2500 0010000 90.0000 298.0000 15.61320000123457' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) at the S24 epoch, post-burn: the 35 deg Shetland collect, northbound from az 139. */ +const SAR2_S24_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27117.12500000 .00001000 00000-0 10000-3 0 9992' as TleLine1, + tle2: '2 61702 98.0000 292.0000 0010000 90.0000 193.5000 15.51040000123457' as TleLine2, +}; + +/** The pre-burn set the station is still predicting from at 03:00 (mean anomaly -0.03 deg). */ +const SAR2_S24_PRE_BURN_TLE2 = '2 61702 98.0000 292.0000 0010000 90.0000 193.4700 15.51040000123457'; + +/** MERIDIAN-SAR-3 (61703) at the S24 epoch: the 28 deg Galway commanding pass, southbound from az 023. */ +const SAR3_S24_TLE: MeridianTle = { + tle1: '1 61703U 27031A 27117.12500000 .00001000 00000-0 10000-3 0 9993' as TleLine1, + tle2: '2 61703 98.0000 83.7500 0010000 90.0000 175.7500 15.59860000123453' as TleLine2, +}; + +const meridianSar1S24 = createMeridianSar1(SAR1_S24_TLE); +const meridianSar2S24 = createMeridianSar2(SAR2_S24_TLE); +const meridianSar3S24 = createMeridianSar3(SAR3_S24_TLE); + +/** GW-01 as the evening left it: RX on SAR-1's video IF, TX on SAR-1's command IF, BUC muted. Deep clone, never spread. */ +const galwayStorm: GroundStationConfig = structuredClone(galwayGroundStation); +galwayStorm.receivers![0].modems![0] = { + ...galwayStorm.receivers![0].modems![0], + frequency: 1414 as MHz, +}; +galwayStorm.rfFrontEnds[0].buc = { ...galwayStorm.rfFrontEnds[0].buc, isMuted: true }; + +/** SH-02 as the evening left it: RX on SAR-1's video IF. Deep clone, never spread. */ +const shetlandStorm: GroundStationConfig = structuredClone(shetlandGroundStation); +shetlandStorm.receivers![0].modems![0] = { + ...shetlandStorm.receivers![0].modems![0], + frequency: 1414 as MHz, +}; + +export const natsEuScenario24Data: ScenarioData = { + id: 'nats-eu-scenario24', + url: 'nats-eu/scenarios/nats-eu-scenario24', + imageUrl: 'nats/24/card.png', + number: 24, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['nats-eu-scenario23'], + title: 'North Atlantic Storm', + subtitle: 'Campaign Capstone (Gray Zone 8/8)', + duration: '60 min', + missionType: 'Security Operations', + description: `03:00, Galway, Tuesday. An Atlantic low is on the coast: rain band now, sleet by 03:40. Three birds in an hour across both stations - SAR-1 collected from Galway in the lull, SAR-2 from Shetland on a set that is about to change, SAR-3 commanded from Galway through the sleet. Charlie is on the phone and you are incident commander for the network.

Everything they have done in the last two weeks, they can do again tonight. Everything you have done, you can do again too. The plan first.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker with feed heater', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker', + 'Ku-Band RF Front Ends (13100 MHz LNB LO, 12600 MHz BUC LO) + HPA at Galway', + 'GPS-disciplined 10 MHz references (GPS Timing panels, both sites)', + 'Contact Plan (both sites), Pass Schedule with ephemeris inbox', + 'TT&C Commanding Console with TRANSEC waveform (Security console)', + 'Spectrum Analyzers with IF notch filters', + 'QPSK 3/4 RX Modems with Video Decoders', + ], + settings: { + isSync: true, + groundStations: [galwayStorm, shetlandStorm], + satellites: [meridianSar1S24, meridianSar2S24, meridianSar3S24], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-27', + scenarioStartWallTime: '03:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-24?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'Incident Log IL-2027-0427-NATS-EU', + description: 'The network on the night of 27 April: three passes, two sites, everything at once - and the handover that closes the record.', + }, + + // S14 - the low. A rain band on Galway for the first quarter hour, a lull + // for the SAR-1 collect, sleet from 03:40 through the SAR-3 window. + weatherEvents: [ + { id: 'gw-rain-band', groundStationId: 'GW-01', type: 'rain', severity: 'severe', startTime: 60, duration: 900, linkMarginDegradation: 7, rainRateMmPerHour: 30 }, + { id: 'gw-sleet', groundStationId: 'GW-01', type: 'hail', severity: 'severe', startTime: 2400, duration: 1800, linkMarginDegradation: 10 }, + ], + + // M3 - three contacts, two sites. Every P1 must be assigned for the plan + // to validate; SAR-1 and SAR-3 can only be flown from Galway tonight, + // SAR-2 only from Shetland. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 1, + contacts: [ + { id: 'C-SAR1-GW', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Galway, 30 deg) - NMW collect', priority: 1, windowStartS: 1199, windowEndS: 1768, stationId: 'GW-01' }, + { + id: 'C-SAR2-SH', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Shetland, 35 deg) - NMW collect', + priority: 1, + windowStartS: 1861, + windowEndS: 2461, + stationId: 'SH-02', + }, + { + id: 'C-SAR3-GW', + satelliteNoradId: 61703, + label: 'MERIDIAN-SAR-3 (Galway, 28 deg) - command contact', + priority: 1, + windowStartS: 3121, + windowEndS: 3695, + stationId: 'GW-01', + }, + ], + }, + + // M4 - the burn. SAR-2 moves onto the post-burn set at 03:15; the station + // predicts from the pre-burn set until the update is loaded. + spaceEvents: [ + { + id: 'SAR2-CAM2', + satelliteNoradId: 61702, + maneuverAtS: 900, + label: 'MERIDIAN-SAR-2 conjunction-avoidance manoeuvre executed 03:15Z; post-burn element set issued', + newTle: { tle1: SAR2_S24_TLE.tle1 as string, tle2: SAR2_S24_TLE.tle2 as string }, + initialTle: { tle1: SAR2_S24_TLE.tle1 as string, tle2: SAR2_S24_PRE_BURN_TLE2 }, + }, + ], + + // M8 - the spoofer, on Shetland this time. Galway reads +0.0. + gnssThreat: { + groundStationIds: ['SH-02'], + spoofStartS: 1500, + offsetDriftUsPerS: 2, + }, + + // M2/M5 - the SAR-3 commanding pass at Galway, coupled to the jammer below. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61703, + windowStartS: 3141, + windowEndS: 3675, + requireDopplerComp: true, + requireValidKey: true, + uplinkFrequencyHz: 14065e6, + commands: [ + { id: 'HK-DUMP', label: 'Housekeeping telemetry dump' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + { id: 'PLD-STATUS', label: 'Payload status request' }, + ], + }, + + // M7 - HOP-SAR3-01, staged since Sunday. + transec: { + groundStationId: 'GW-01', + hopChannelsHz: [14061e6, 14063e6, 14065e6, 14067e6, 14069e6], + requireKey: true, + }, + + // The adversary: the terrestrial carrier in SAR-1's band during the + // Galway collect, and the jammer on SAR-3's command carrier in the window. + interferenceEvents: [ + { + id: 'gw-carrier-sar1', + frequency: 11690e6, + bandwidth: 1e6, + power: 60, + polarization: 'V', + startTime: 1290, + duration: 420, + periodSeconds: 90, + onSeconds: 60, + path: 'terrestrial', + emitter: { latitude: 53.12, longitude: -9.32 }, + }, + { + id: 'sar3-uplink-jam', + satelliteNoradId: 61703, + frequency: 14065e6, + bandwidth: 2e6, + power: 5, + polarization: 'H', + startTime: 3300, + duration: 400, + periodSeconds: 400, + onSeconds: 400, + }, + ], + + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you) - incident commander', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'kg-01', name: 'KG-01 command crypto unit', role: 'Equipment', status: 'active' }, + ], + events: [ + { + id: 'evt-storm-warning', + timeS: 0, + timestampLabel: '26 Apr 18:00 UTC', + actor: 'op-charlie', + action: 'Met Eireann orange warning: Atlantic low, heavy rain 02:30-03:30, sleet from 03:30, west coast', + category: 'config', + severity: 'warning', + }, + { + id: 'evt-hopset-sar3-staged', + timeS: 0, + timestampLabel: '25 Apr 10:20 UTC', + actor: 'op-charlie', + action: 'TRANSEC hop set HOP-SAR3-01 staged to COMSEC store (inactive); SAR-3 keyed at Rotterdam', + category: 'command', + severity: 'info', + }, + { + id: 'evt-login-duty', + timeS: 0, + timestampLabel: '02:55 UTC', + actor: 'op-duty', + action: 'Console login (incident commander, network)', + category: 'auth', + severity: 'info', + }, + { + id: 'evt-sh-skew', + timeS: 1620, + timestampLabel: '03:27 UTC', + actor: 'svc-monitor', + action: 'SH-02 frame timestamps +240 us ahead of Rotterdam reference and increasing; GW-01 in line', + category: 'config', + severity: 'warning', + isAnomaly: true, + }, + ], + }, + }, + objectives: [ + { + id: 'take-command', + nice: ['S0807', 'K0645'], + title: 'Take Command', + description: 'Open the brief: the low, three birds in an hour across two sites, and the one thing an incident commander does before touching anything.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'First Act', + params: { + character: Character.SYSTEM, + question: 'Three passes, two sites, a storm and an adversary. What does the incident commander do first?', + options: [ + 'The plan: which bird from which site, in what order, so that every P1 window has a station and no station has two - then the sites, then the passes', + 'The uplink: SAR-3 is the only command contact and the key must be checked before anything else', + 'The weather: heater on at Galway now, before the rain, so the whole hour is flown on a dry feed', + 'The adversary: load the hop set and take both references to holdover so nothing they do tonight lands', + ], + correctIndex: 0, + explanation: + 'The plan is the thing everything else hangs off, and it is the only thing that can be wrong for the whole hour at once. The heater is on before the sleet, not the rain; the hop set is spent on a called denial; the references are trusted until one lies. Twenty minutes to the first AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'plan-the-network', + nice: ['K0689', 'T0129', 'S0807'], + title: 'Plan the Network', + description: 'Contact Plan: SAR-1 and SAR-3 to Galway, SAR-2 to Shetland. Three P1 contacts, no overlaps, the plan reads DECONFLICTED.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['take-command'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-1 Collect on GW-01', + params: { contactId: 'C-SAR1-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-2 Collect on SH-02', + params: { contactId: 'C-SAR2-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-3 Command Contact on GW-01', + params: { contactId: 'C-SAR3-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Plan Reasoned', + params: { + character: Character.SYSTEM, + question: "Why does SAR-2 go to Shetland when Galway is the incident commander's own console?", + options: [ + 'Because Galway has SAR-1 setting at 03:29 and SAR-3 rising at 03:52 with sleet between them, and Shetland has SAR-2 at 35 degrees in clear air; the plan puts each window on the site that can close it', + 'Because Shetland has no command key and must take every receive-only contact so Galway is free to command', + 'Because the spoofer is expected on Galway and a collect flown from Shetland is safe from it', + 'It should not: the commander flies every pass from the console in front of them and Shetland is a backup', + ], + correctIndex: 0, + explanation: + 'Two sites are one network when the plan says which window is whose. Galway has two contacts and weather; Shetland has one and clear sky. Nothing about the adversary is in the plan yet - the plan is geometry and weather, and it is right before anything else happens.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'galway-dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Storm Sweep', + description: 'Board read at Galway with the rain band arriving. Confirm the active alarm state before the first pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['plan-the-network'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at 03:02?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm; the rain is a weather event on the ACU, not a fault on the chain', + 'No active alarms - all systems nominal, board clear for the pass', + 'Rain fade alarm - the receive chain has lost margin and the SAR-1 collect should move to Shetland', + 'Feed heater alarm - the heater is off in precipitation and the board wants it on', + ], + correctIndex: 0, + explanation: + 'Empty sky, not a fault. Thirty millimetres an hour is on the ACU weather panel, and it ends before SAR-1 rises. The heater is for the sleet at 03:40, and the board does not nag about it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'galway-rx-ready', + nice: ['S0421', 'K0773'], + title: 'Galway Ready for SAR-1', + description: 'RX modem 1 on 1414 MHz, analyzer centred on 1414 with a 40 MHz span. The carrier that followed the plan on the 14th will be in this band if it comes.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-dashboard-sweep'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1414 MHz', + params: { centerFrequency: 1414e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'GW-01 Analyzer Span 40 MHz', + params: { span: 40e6, frequencyTolerance: 5e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'shetland-baseline', + nice: ['T0431', 'S0421'], + title: 'Shetland Baseline', + description: 'Select SH-02. GPS Timing: locked, GNSS tracking, delta-T zero. Retune its RX modem 1 to 1370 MHz for SAR-2. Read the reference now; it will matter at 03:25.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['galway-rx-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'gpsdo-locked', + description: 'SH-02 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-gnss-locked', + description: 'SH-02 GNSS Tracking (4+ satellites)', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'acquire-sar1-in-the-lull', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-1 in the Lull', + description: 'Back to GW-01. The rain band clears at 03:16; SAR-1 rises 03:19:58 from azimuth 005. Program-track it and confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['shetland-baseline'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'notch-the-carrier', + nice: ['K0737', 'S0593', 'T0153'], + title: 'Notch the Carrier', + description: + "From 03:21:30 the carrier is back at 1410 MHz IF, in SAR-1's band, same cadence as the 14th. Notch 1 on 1410, 2 MHz, 30 dB, and hold C/N above 7 dB through the high segment.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1-in-the-lull'], + conditions: [ + { + type: 'notch-filter-configured', + description: 'Notch at 1410 MHz IF on the Carrier', + params: { + notchCenterFrequency: 1410, + notchCenterFrequencyTolerance: 1, + notchBandwidth: 2, + notchBandwidthTolerance: 1, + notchDepth: 30, + notchDepthTolerance: 10, + notchIndex: 1, + }, + mustMaintain: true, + }, + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 7 dB', + params: { modemNumber: 1, minCNRatio: 7, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Carrier Read', + params: { + character: Character.SYSTEM, + question: 'Same frequency as the 14th, same cadence, same bearing. What is different tonight about how it is handled?', + options: [ + 'Nothing in the handling - notch it, keep the collect, log it against the open IR. What is different is that it is one of four things happening, and it is the one the plan already has an answer for', + 'It is escalated immediately: the third appearance of the same emitter is a criminal matter and the collect stops', + 'The collect moves to Shetland: a known interferer in the band means Galway cannot be trusted for SAR-1', + 'The notch is not applied: with the rain gone the margin is enough and a notch costs signal', + ], + correctIndex: 0, + explanation: + "A known event gets its known answer and no more of the commander's attention than that. The collect is delivered, the IR gets a line, and the console is free for the things that do not have an answer yet.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'load-the-ephemeris', + nice: ['T1138', 'K1032'], + title: 'Load the Post-Burn Elements', + description: "SAR-2 burned at 03:15; the Pass Schedule tab has read EPHEMERIS STALE since. Load the update before Shetland's 03:31 AOS.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['notch-the-carrier'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ephemeris-updated', + description: 'SAR-2 Post-Burn Ephemeris Loaded', + params: { eventId: 'SAR2-CAM2' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'shetland-spot-the-walk', + nice: ['S0648', 'T0431'], + title: 'Shetland: Spot the Walk', + description: "Rotterdam sees Shetland's timestamps drifting. Select SH-02 and read its GNSS vs REF delta-T on GPS Timing. Galway is the cross-check tonight.", + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['load-the-ephemeris'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'gpsdo-time-offset-exceeds', + description: 'SH-02 Delta-T Read Walking on GPS Timing', + params: { minOffsetUs: 20, requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Roles Reversed', + params: { + character: Character.SYSTEM, + question: 'Shetland walking, Galway reading zero, both constellations healthy. What is it, and where?', + options: [ + "A GNSS spoof, local to Shetland - the same signature as the 19th with the sites swapped. Galway's zero is the proof it is local, and Galway's reference is the one still trusted", + 'A GNSS spoof on both sites: the walk starts at Shetland and Galway will follow inside a minute', + 'A Shetland GPSDO fault: Fiona should power-cycle the unit before the SAR-2 pass', + 'A Rotterdam reference problem: two stations cannot both be wrong, so the comparison is', + ], + correctIndex: 0, + explanation: + 'Two receivers, two skies, one of them lied to. The 19th taught the cross-check from Galway; tonight it is read from Galway. Holdover at Shetland, fly SAR-2 on the oscillator, and Galway keeps GNSS because Galway is honest.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'shetland-holdover', + nice: ['S0593', 'K0752'], + title: 'Shetland: Stop Trusting GNSS', + description: 'SH-02 GPS Timing: GNSS switch down. The Shetland reference free-runs through the SAR-2 collect; Galway stays on GNSS.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-spot-the-walk'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'gpsdo-reference-mode-set', + description: 'SH-02 Reference in Holdover', + params: { referenceMode: 'holdover' }, + mustMaintain: true, + maintainDuration: 20, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'acquire-sar2-from-shetland', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-2 from Shetland', + description: 'SAR-2 rises 03:31:01 from azimuth 139 at Shetland, northbound, on the post-burn set. Program-track it and confirm the beacon on RX analysis.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-holdover'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-2-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-the-collect', + nice: ['T0153', 'K0740'], + title: 'Decode the Collect', + description: 'SH-02 RX modem locked on SAR-2, C/N above 8 dB through the high segment, reference in holdover. The NMW collect, delivered.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['acquire-sar2-from-shetland'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Collect Logged', + params: { + character: Character.SYSTEM, + question: 'Which line goes in the incident log for the first half hour?', + options: [ + '03:01-03:16 rain band GW-01. 03:15 SAR-2 burn, post-burn set loaded 03:29. 03:20-03:29 SAR-1 collected GW-01 under the 1410 MHz carrier (notched, C/N > 7). 03:25 spoof on SH-02 reference, GW-01 clean; SH-02 holdover. 03:31 SAR-2 collected SH-02 on the oscillator. Two collects delivered', + 'Half an hour of attacks on both sites; both collects at risk; incident escalated to Rotterdam', + 'Rain, a burn, a jammer and a spoof, all handled; nothing to report to the customer', + 'SAR-1 and SAR-2 collected as planned; weather and interference noted', + ], + correctIndex: 0, + explanation: + 'Times, sites, what happened, what was done, what was delivered. Half the hour is written before the half that matters most, because the commander who writes as they go is the one who can hand over.', + pointPenalty: 5, + documentSection: 'Incident Log', + documentLine: + '03:01-03:16Z rain band GW-01 (30 mm/h). 03:15Z SAR-2 conjunction-avoidance burn; post-burn set loaded 03:29Z. 03:20-03:29Z SAR-1 NMW collect GW-01 under terrestrial carrier 11690 MHz (1410 IF), notch 1410/2/30, C/N > 7 dB - delivered. 03:25Z GNSS spoof on SH-02 reference (+2 us/s), GW-01 clean; SH-02 to holdover 03:30Z. 03:31-03:41Z SAR-2 NMW collect SH-02 on the oscillator - delivered.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'protect-the-feed', + nice: ['S0421', 'T0431'], + title: 'Protect the Feed', + description: 'Back to GW-01. Sleet from 03:40, SAR-3 at 03:52. Feed heater on now, before the first flake.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-the-collect'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'feed-heater-enabled', + description: 'GW-01 Feed Heater Enabled', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Heater Timing', + params: { + character: Character.SYSTEM, + question: 'Why was the heater not switched on at 03:00 with the rain, when it costs nothing to run?', + options: [ + 'Rain does not stick and a heated feed in rain is just a warm feed; sleet does. The heater is timed to the sleet and on before it, because ice that has formed takes minutes to melt and the pass will not wait', + "It should have been: the commander's first act was the plan, and the heater is part of the plan", + 'Because a heated feed adds noise temperature, and the SAR-1 collect needed every tenth of a decibel in the rain', + 'Because the heater draws from the same supply as the HPA and cannot run during a commanding pass', + ], + correctIndex: 0, + explanation: + 'Right thing, right time. On at 03:36 for sleet at 03:40 is the whole skill; on at 03:00 is harmless but tells the log nothing about whether you knew when the sleet was coming.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'arm-galway-for-sar3', + nice: ['S0421', 'K0773', 'T1567'], + title: 'Arm Galway for SAR-3', + description: + "SAR-3's 14065 MHz TT&C uplink through the 12600 MHz BUC LO: TX modem 1 to 1465 MHz, uplink Doppler compensation engaged. SAR-3 video 11760 MHz: RX modem 1 to 1340 MHz.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['protect-the-feed'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'GW-01 TX 1465 MHz', + params: { modemNumber: 1, frequency: 1465e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1340 MHz', + params: { modemNumber: 1, frequency: 1340e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'acquire-sar3', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-3 Through the Sleet', + description: 'SAR-3 rises 03:52:00 from azimuth 023. Program-track it on a heated feed and confirm the 1315 MHz beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['arm-galway-for-sar3'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-3', + params: { noradId: 61703 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-3 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-3-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'chain-up-and-command', + nice: ['S0675', 'T1567', 'K1032'], + title: 'Chain Up and Command', + description: 'Window open at 03:52:21. Carrier into the muted BUC, unmute, HPA, and HK-DUMP on the fixed carrier. One clean ACK before the one that matters.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar3'], + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'command-acknowledged', + description: 'HK-DUMP Acknowledged', + params: { commandId: 'HK-DUMP' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'call-the-denial', + nice: ['S0648', 'K0926', 'K0751'], + title: 'Call the Denial', + description: + 'From 03:55 PLD-SAFE comes back NAK: uplink denied. Read the console and the key, then call it. You have done this twice; do it in the sleet with two other things on the board.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up-and-command'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + id: 'console-seen', + type: 'uplink-doppler-comp-enabled', + description: 'TT&C console read', + params: { requiresObservation: true, observationTab: 'commanding' }, + mustMaintain: false, + }, + { + id: 'key-seen', + type: 'tx-key-status', + description: 'Command key read on TX Chain', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Why the NAK', + params: { + character: Character.ANNEKE_VISSER, + prompt: + 'PLD-SAFE did not reach SAR-3. Key Valid at my end, window open six more minutes, and I have Shetland on holdover and Galway in sleet on my screen. What is it?', + evidence: ['console-seen', 'key-seen'], + decisionOptions: [ + { + label: 'Uplink denial on the fixed carrier - key HOP-SAR3-01 both ends, hop, resend. The sleet and Shetland have nothing to do with it', + correctWhen: { + all: [ + { fact: 'uplink-jammed', is: true }, + { fact: 'crypto-intact', is: true }, + ], + }, + consequence: { log: 'Uplink denial called on the SAR-3 command carrier; HOP-SAR3-01 requested at both ends' }, + }, + { + label: "Rain fade on the uplink - the sleet is taking the carrier below the bird's threshold. Raise HPA back-off and resend", + correctWhen: { fact: 'weather-attenuation-dominant', is: true }, + consequence: { log: 'NAK attributed to weather on the uplink' }, + feedback: + 'HK-DUMP ACKed in the same sleet two minutes ago on the same power. Ku uplink fade at 28 degrees in sleet is a decibel or two; the NAK reason on the console is not "weak", it is "jammed".', + }, + { + label: 'Key problem - the SAR-3 key has never been used under TRANSEC. Begin a rotation and resend', + correctWhen: { fact: 'crypto-intact', is: false }, + consequence: { log: 'Command-key rotation begun on a Valid key during a denial' }, + feedback: 'Both ends read Valid and a key rejection is its own reason on the console. Two of six minutes gone.', + }, + { + label: 'Stand down - the network has delivered two collects and the third bird can be commanded tomorrow', + feedback: 'The payload command is the P1 contact on the plan you wrote at 03:02. The hop set was staged for this minute.', + }, + ], + explanation: + 'Every reason the console can give for itself is clean, the weather is on the downlink margin and the uplink has power to spare, and the bird is not hearing you. Same call as the 16th and the 24th. The point of the capstone is that it is the same call.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'hop-and-deliver', + nice: ['K0925', 'T1277', 'S0424'], + title: 'Hop and Deliver', + description: + 'Security console: Load Hop-Set Key (HOP-SAR3-01), Frequency hopping, SYNC LOCKED. Then PLD-SAFE under TRANSEC, through the sleet, on a heated feed, on a network with one reference in holdover.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-denial'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'transec-mode-set', + description: 'Waveform Mode: Frequency Hopping', + params: { transecMode: 'hopping' }, + mustMaintain: true, + }, + { + type: 'transec-sync-locked', + description: 'Hop-Sync Locked', + params: {}, + mustMaintain: true, + }, + { + type: 'command-acknowledged', + description: 'PLD-SAFE Acknowledged (hopping)', + params: { commandId: 'PLD-SAFE' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'safe-the-network', + nice: ['S0421', 'K0645'], + title: 'Safe the Network', + description: 'LOS 04:01:35. Galway: HPA off, BUC muted, carrier off. Shetland stays in holdover until a probe says its sky is honest.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['hop-and-deliver'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'incident-log-and-handover', + nice: ['T1606', 'T1580', 'S0807'], + title: 'Incident Log and Handover', + description: 'The second half of the hour goes in the log, and then the handover: what is open, what is staged, and what the day shift walks into.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['safe-the-network'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Second Half Logged', + params: { + character: Character.SYSTEM, + question: 'Which line goes in the incident log for the second half hour?', + options: [ + '03:36 GW-01 feed heater on; sleet from 03:40. 03:52 SAR-3 acquired GW-01, HK-DUMP ACK fixed carrier 03:53. 03:55 PLD-SAFE NAK uplink denied (14065 MHz jammed), key Valid; called denial; HOP-SAR3-01 keyed both ends, SYNC LOCKED; PLD-SAFE ACK under TRANSEC 03:57. Safed at LOS 04:01. Three contacts delivered', + "SAR-3 commanded through jamming and sleet; the adversary's most aggressive night yet defeated by the incident commander", + 'SAR-3 pass completed with one anomaly; hop set used; no customer impact', + '03:52-04:01 SAR-3 command contact; PLD-SAFE delayed by interference and sent on the second attempt', + ], + correctIndex: 0, + explanation: + 'Times, what came back, what was done, what was delivered - the same line as every night since the 13th. "Defeated" is a mood and "delayed by interference" hides the NAK reason. Three contacts delivered is the sentence the board reads.', + pointPenalty: 5, + documentSection: 'Incident Log', + documentLine: + '03:36Z GW-01 feed heater on; sleet 03:40-04:10Z. 03:52Z SAR-3 acquired GW-01 through sleet; HK-DUMP ACK 03:53Z fixed carrier. 03:55Z PLD-SAFE NAK "uplink denied - carrier jammed" (14065 MHz at the spacecraft), key Valid, Doppler on; called denial; HOP-SAR3-01 keyed both ends, hopping, SYNC LOCKED; PLD-SAFE ACK 03:57Z under TRANSEC. Chain safed at LOS 04:01Z. Three P1 contacts delivered.', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Handover Written', + params: { + character: Character.SYSTEM, + question: 'What is the handover line?', + options: [ + 'Open: SH-02 reference in holdover (spoofer active at close; probe at 04:30), jammer on 14065 (IR extension), terrestrial carrier third appearance (IR extension), HOP-SAR3-01 spent (HOP-SAR3-02 to stage). Staged: nothing new to learn - every event tonight had a procedure, and the procedures held', + 'Open: everything. The network was under sustained attack and the day shift should expect the same', + 'Open: nothing. Three contacts delivered, all anomalies resolved, station nominal', + 'Open: the adversary. Until the source is located the network cannot be considered secure and commanding should be suspended', + ], + correctIndex: 0, + explanation: + 'The handover is a list of what is still true and what is staged for it. Two weeks ago every one of these would have been a first. Tonight they were all procedure, done in the order the network needed, with a storm on top. That is the campaign.', + pointPenalty: 5, + documentSection: 'Handover', + documentLine: + 'Handover 04:15Z. Open: SH-02 reference in holdover (spoofer active at close; probe scheduled 04:30Z); uplink jammer on 14065 MHz and terrestrial carrier 11690 MHz (IR-2027-0414-GW01 extended, third appearance); HOP-SAR3-01 spent, HOP-SAR3-02 to be staged before the next SAR-3 window. Delivered: SAR-1 (GW-01), SAR-2 (SH-02), SAR-3 command contact incl. PLD-SAFE (GW-01). No procedure change: every event had one, and they held together.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 02:40] +

+

+ "Orange warning on the coast, three birds in an hour, and you are incident commander for the network tonight - Fiona is on SH-02, Anneke is on the constellation, I am on the phone. Plan first. Everything they have done to us in two weeks, assume they do again, all at once, in the sleet. Everything you have done, you can do again too." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'plan-the-network': { + text: ` +

+ That is the plan I would have written. SAR-1 and SAR-3 are yours, SAR-2 is Fiona's. Now go and see what the weather has done to my station. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'notch-the-carrier': { + text: ` +

+ Fiona here. Your carrier is on my analyzer too, faintly - same bearing as the 14th, so it is Galway-side. My band is clean and my reference reads zero. For now. +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'load-the-ephemeris': { + text: ` +

+ Post-burn set loaded on both stations, thank you. SAR-2 will rise at Shetland where the new set says, not where the old one did. Fiona has the pass in two minutes. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'shetland-spot-the-walk': { + text: ` +

+ It is on me this time. Delta-T walking, nine satellites, GOOD - and you read zero. Say the word and the switch goes down; I know the drill from your record. +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'decode-the-collect': { + text: ` +

+ SAR-2 collect in from Shetland, frames clean, timestamps carrying Fiona's frozen offset exactly as logged. Two of three. SAR-3 in seventeen minutes, and the sleet is on your coast. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'protect-the-feed': { + text: ` +

+ Heater on at 03:36 for sleet at 03:40. I did not have to say it. That is the difference between the 2nd of April and tonight. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'call-the-denial': { + text: ` +

+ Denial. Keying SAR-3 to HOP-SAR3-01 - done. Load yours, hop, and put PLD-SAFE through it. Third time; I am not worried. +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'hop-and-deliver': { + text: ` +

+ PLD-SAFE acknowledged under TRANSEC, through sleet, with one station on holdover and a carrier in the other one's band. Three contacts. Whatever they planned for tonight, it was not this. +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'incident-log-and-handover': { + text: ` +

+ Handover received. Get some sleep. One more thing: my friend called back about the emitter - the two-receiver people. They read Priya's report and they have a question for you, and it is not about the emitter. It is whether you would come and do this from their side for a while. I said you would call them. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario25.ts b/src/campaigns/nats-eu/scenario25.ts new file mode 100644 index 00000000..22e1c013 --- /dev/null +++ b/src/campaigns/nats-eu/scenario25.ts @@ -0,0 +1,442 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import { DRILL_BRIEF, DRILL_EQUIPMENT, drillGalway, drillSatellites } from './drills'; + +/** + * nats-eu Scenario 25: "Site Orientation" (Drill 1/4, phase 18 G) + * + * Priya Sharma's post-incident security orientation for the Galway crew: a + * walk through the station with the question sheet the Gray Zone fortnight + * wrote. Knowledge, not hands - every stop is a status-check anchored to the + * console it is about. The drill closes the threat-model module the arc + * assumed rather than taught (1.1 segments, 1.2 lifetime, 1.3 decision + * ownership, 1.4 the soft target, 1.6 effects, 1.7 consequence), the + * monitor-and-control path and why OT is not IT (6.1-6.3), the antenna + * pattern that let a ground emitter into a dish pointed at the sky (3.2), and + * what about a shift is sensitive outside the fence (12.5). + * + * NICE Framework Alignment: + * Primary Codes: + * - K1032: Knowledge of satellite-based communication systems + * - K0721: Knowledge of threats to information systems + * - K0915: Knowledge of network architecture principles and practices + * Supporting Codes: + * - K0645: Knowledge of standard operating procedures + * - K0689: Knowledge of network architecture + * - K0926: Knowledge of signal jamming tools and techniques + * - K0741: Knowledge of operational impacts of system outages + * - K0682: Knowledge of cybersecurity threats + * - K0741: Knowledge of system availability measures + */ +export const natsEuScenario25Data: ScenarioData = { + id: 'nats-eu-scenario25', + url: 'nats-eu/scenarios/nats-eu-scenario25', + imageUrl: 'nats/17/card.png', + number: 25, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario16'], + title: 'Site Orientation', + subtitle: 'Security Drill 1/4', + duration: '15 min', + missionType: 'Security Drill', + description: `09:00, Galway, a quiet Wednesday after a loud fortnight. Group Security has written a site orientation sheet out of everything the Gray Zone incidents taught, and every operator walks it once with the consoles in front of them.

Ten stops. What the station is part of, what it does across a satellite's life, who owns which decision, why the ground is the soft target, what an attacker is trying to do to a link and what is lost when they succeed, where the monitor-and-control path runs and why it is not ordinary IT, how a ground emitter got into a dish pointed at the sky, and what about a shift must not leave the fence.`, + equipment: DRILL_EQUIPMENT, + settings: { + isSync: true, + groundStations: [drillGalway()], + satellites: drillSatellites(), + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-28', + scenarioStartWallTime: '09:00:00', + missionBriefUrl: DRILL_BRIEF(25), + workingDocument: { + title: 'Orientation Sheet GW-01 / 28 April', + description: 'Ten stops, one line each. Signed off by the operator walking it.', + }, + // The Security console is a stop on the walk: a quiet morning's log, nothing to flag. + security: { + accounts: [ + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + ], + events: [ + { + id: 'evt-poll', + timeS: 0, + timestampLabel: '08:40 UTC', + actor: 'svc-monitor', + action: 'M&C poll: ACU, RF front end, modems, GPSDO', + category: 'command', + severity: 'info', + }, + { id: 'evt-login', timeS: 0, timestampLabel: '08:55 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-sheet', timeS: 0, timestampLabel: '09:00 UTC', actor: 'op-charlie', action: 'Orientation sheet issued to duty operator', category: 'access', severity: 'info' }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Read the Orientation Sheet', + description: 'Open the sheet: the ten stops, the diagram of the segments, the flow baseline, and the incident list from the fortnight the questions come from.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [{ type: 'mission-brief-opened', description: 'Orientation Sheet Opened', params: { boxId: 'mission-brief' }, mustMaintain: false }], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'the-four-segments', + nice: ['K1032'], + title: 'Stop 1 - The Segments', + description: 'Space, link, ground, user. Galway is one of them; the imagery customer in Rotterdam is another; the thing that goes wrong most often is in between.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'status-check', + description: 'Segments Named', + params: { + character: Character.SYSTEM, + question: 'Stop 1. Which of these puts GW-01, MERIDIAN-SAR-1, the Ku-band downlink and the imagery analysts in Rotterdam into the right segments?', + options: [ + 'Ground segment: GW-01 and Rotterdam constellation ops. Space segment: SAR-1. Link segment: the Ku-band downlink and the uplink. User segment: the analysts who consume the imagery', + 'Ground segment: GW-01 only. Space segment: SAR-1 and the downlink. User segment: Rotterdam. There is no separate link segment', + 'Everything on the Earth is the user segment; everything above it is the space segment; the link is part of both', + 'Ground segment: GW-01 and the analysts. Space segment: SAR-1 and Rotterdam constellation ops, since they fly the bird', + ], + correctIndex: 0, + explanation: + 'Logged. Four segments, and the operator sits in one of them. Rotterdam flies the bird from the ground; the analysts are users of the product, not of the station. The link segment is where the fortnight happened: the carrier, the replay, the denial were all attacks on the link, seen from the ground.', + pointPenalty: 5, + documentLine: 'Stop 1: segments - ground (GW-01, Rotterdam ops), space (MERIDIAN), link (Ku up/down), user (imagery analysts).', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'across-the-lifetime', + nice: ['K1032'], + title: 'Stop 2 - Across the Lifetime', + description: 'A ground station is not one job. What it does for a satellite changes from launch to disposal, and the fortnight touched three of those phases.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-four-segments'], + conditions: [ + { + type: 'status-check', + description: 'Lifetime Phases Named', + params: { + character: Character.SYSTEM, + question: 'Stop 2. Which list puts the station’s jobs in the order a satellite needs them?', + options: [ + 'Launch and early orbit (first contact, first telemetry), commissioning (payload checkout), routine operations (passes, products, state of health), anomaly response, end of life (disposal commanding)', + 'Routine operations first, then commissioning once the customer is happy, then launch support for the next bird', + 'Commissioning, launch, routine operations, disposal - the station is not involved in early orbit', + 'Routine operations only; launch, commissioning and disposal belong to the constellation operator, not to a ground station', + ], + correctIndex: 0, + explanation: + 'Logged. Galway spends most of its life in routine operations and anomaly response - the two the fortnight was made of - but the station is also the first thing a new bird talks to and the last thing an old one hears.', + pointPenalty: 5, + documentLine: 'Stop 2: lifetime - LEOP, commissioning, routine ops, anomaly response, end of life.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'who-decides', + nice: ['K0645'], + title: 'Stop 3 - Who Decides', + description: 'The fortnight had a dozen decisions in it. Each one had an owner, and the owner was not always the person with the console in front of them.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['across-the-lifetime'], + conditions: [ + { + type: 'status-check', + description: 'Decision Ownership Named', + params: { + character: Character.SYSTEM, + question: 'Stop 3. Rotate the command key out of cycle; hold a pass to protect evidence; zeroize a key; declare an incident to the regulator. Who owns each?', + options: [ + 'Rotation: the operator proposes, constellation ops (Anneke) concurs at the far end. Holding a pass: the site lead (Charlie). Zeroize: nobody below CSIRT (Priya) without a compromise finding. Regulator: Group, never the site', + 'All four are the operator’s: whoever has the console makes the call and reports it afterwards', + 'All four are the site lead’s; the operator executes and CSIRT is informed', + 'Rotation and zeroize are the operator’s; holding a pass is Rotterdam’s; the regulator is the customer’s', + ], + correctIndex: 0, + explanation: + 'Logged. The operator owns what happens on the console in the next minute and proposes everything else. The night a key was nearly zeroized on principle is the reason this stop exists: a decision with a cost the site cannot undo belongs to the level that can.', + pointPenalty: 5, + documentLine: + 'Stop 3: decision ownership - operator (console, proposes), site lead (holds), constellation ops (concurs), CSIRT (zeroize, compromise), Group (regulator).', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-soft-target', + nice: ['K0721'], + title: 'Stop 4 - The Soft Target', + description: 'Nobody attacked MERIDIAN in April. Everything that happened, happened to the ground, and there is a reason for that.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['who-decides'], + conditions: [ + { + type: 'status-check', + description: 'Soft Target Explained', + params: { + character: Character.SYSTEM, + question: 'Stop 4. Why is the ground segment the target, and not the satellite?', + options: [ + 'The ground is reachable: it has people, accounts, vendors, a network with an outside edge, and RF that anyone with a dish can hear or key into. The bird has none of those, and everything that commands it goes through the ground anyway', + 'Satellites are better built than ground stations; the ground is where the cheap equipment is', + 'The bird is out of range of any attacker; only a nation with a launch capability can reach the space segment', + 'The ground is not the target - the fortnight was aimed at MERIDIAN and Galway was collateral', + ], + correctIndex: 0, + explanation: + 'Logged. A failed-login burst, a contractor account, a config export, a carrier from 24 km away, a replay of our own frames: every one of them needed nothing in orbit. The ground is where the attack surface is because the ground is where everything else is.', + pointPenalty: 5, + documentLine: 'Stop 4: the ground is the soft target - people, accounts, vendors, an outside edge, open RF; the bird is reached through it.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-effects', + nice: ['K0926', 'K0721'], + title: 'Stop 5 - The Effects', + description: 'Deny, degrade, disrupt, deceive, exploit. The fortnight had one of each. Match them.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-soft-target'], + conditions: [ + { + type: 'status-check', + description: 'Effects Matched', + params: { + character: Character.SYSTEM, + question: + 'Stop 5. The 11690 carrier under SAR-1; the spoofed GNSS time; the uplink denial on SAR-3; the replayed command frames; the exported configuration. Which effect was each?', + options: [ + 'Carrier: degrade. Spoofed time: deceive. Uplink denial: deny. Replayed frames: exploit (an attempt to use our own command path). Config export: exploit (information taken for later)', + 'Carrier: deny. Spoofed time: disrupt. Uplink denial: degrade. Replayed frames: deceive. Config export: deny', + 'All five were denial - every one of them cost the customer a product', + 'Carrier: deceive (it looked like the satellite). Spoofed time: exploit. Uplink denial: deny. Replayed frames: degrade. Config export: disrupt', + ], + correctIndex: 0, + explanation: + 'Logged. The effect is what the attacker gets, not what it looked like on the analyzer. A carrier that dents the video degrades; a reference that lies deceives; a jammed uplink denies; a replayed frame and an exported config are both exploitation - using our own system, or knowledge of it, against us.', + pointPenalty: 5, + documentLine: 'Stop 5: effects - carrier = degrade, spoof = deceive, uplink jam = deny, replay = exploit, config export = exploit.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-consequence', + nice: ['K0741'], + title: 'Stop 6 - What Is Lost', + description: 'A pass is a number on the board. The consequence of losing it is not. Frame it the way the customer would.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-effects'], + conditions: [ + { + type: 'status-check', + description: 'Consequence Framed', + params: { + character: Character.SYSTEM, + question: 'Stop 6. SAR-2’s 02:40 pass is lost to the carrier. What is the consequence, stated the way the report should state it?', + options: [ + 'One SAR-2 collect over the North Atlantic not delivered: the imagery product for that orbit is missing, the next opportunity is the following pass, and the customer’s decision that needed it is made without it. C/N is how we saw it; the product is what was lost', + 'C/N dropped below 8 dB for four minutes', + 'The station failed to meet its service level for the night', + 'Nothing was lost - the next pass will collect the same scene', + ], + correctIndex: 0, + explanation: + 'Logged. C/N is the symptom on our side of the wall. The consequence lives on the customer’s side: a product not delivered, a decision made without it, a window that does not come back. The report says that, and then says when the next one is.', + pointPenalty: 5, + documentLine: 'Stop 6: consequence framing - state the product not delivered and the decision affected, then the next opportunity; C/N is the symptom.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-m-and-c-path', + nice: ['K0915', 'K0689'], + title: 'Stop 7 - The Monitor-and-Control Path', + description: + 'Open the Security console, sign off the log as reviewed, and follow the path from the workstation you are sitting at to the dish. Everything the fortnight did to the station went along it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-consequence'], + conditions: [ + { type: 'tab-active', description: 'Security Console Open', params: { tab: 'security-console' }, mustMaintain: false }, + { type: 'audit-log-reviewed', description: 'Audit Log Reviewed', params: {}, mustMaintain: false }, + { + type: 'status-check', + description: 'M&C Path Traced', + params: { + character: Character.SYSTEM, + question: 'Stop 7. Trace the monitor-and-control path from the operator workstation to the antenna.', + options: [ + 'Workstation (M&C client) -> M&C network segment -> the ACU, the RF front end controller, the modems and the GPSDO, each on its own management port. The Security console watches that segment; the crypto unit sits beside it with its own path', + 'Workstation -> corporate network -> internet -> vendor cloud -> ACU', + 'Workstation -> the modem only; the ACU and the front end are stand-alone and are configured at the rack', + 'There is no path: every unit is configured by hand at the rack and the console only displays', + ], + correctIndex: 0, + explanation: + 'Logged. One segment carries every command that moves the dish, tunes the LNB, sets the modems and disciplines the reference. That is why the audit log is the most useful screen on the station and why the boundary of that segment is the thing to defend.', + pointPenalty: 5, + documentLine: 'Stop 7: M&C path - workstation -> M&C segment -> ACU / RF front end / modems / GPSDO management ports; console watches the segment.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'not-ordinary-it', + nice: ['K0741', 'K0915', 'K0689'], + title: 'Stop 8 - Not Ordinary IT', + description: 'Two questions on the same stop: why the units on the M&C segment cannot be treated like office laptops, and where the boundaries around that segment are.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-m-and-c-path'], + conditions: [ + { + type: 'status-check', + description: 'OT Difference Named', + params: { + character: Character.SYSTEM, + question: 'Stop 8a. Why is the ACU not patched, scanned and rebooted the way an office laptop is?', + options: [ + 'Availability first: it runs a pass at 02:20 whether or not a patch is out; its firmware is vendor-qualified against the hardware, so an unplanned update is a risk to the mission, not a fix; and an agent scanning it can stall a control loop. Changes go in maintenance windows, from verified images, and are logged', + 'It is too old to patch; the vendor stopped supporting it', + 'It is air-gapped, so it cannot be attacked and does not need patching', + 'It is patched exactly like a laptop; the difference is only that it runs Linux', + ], + correctIndex: 0, + explanation: + 'Logged. Operational equipment is judged by whether the pass happens. Security on it is windows, verified images, logging and a boundary, not an agent and a reboot on Tuesday.', + pointPenalty: 5, + documentLine: 'Stop 8a: OT is not IT - availability first, vendor-qualified firmware, changes in windows from verified images, logged.', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Boundaries Named', + params: { + character: Character.SYSTEM, + question: 'Stop 8b. Where are the boundaries around the M&C segment, and what crosses them?', + options: [ + 'Between M&C and the corporate network: a firewall that allows only the monitoring feed out and nothing in. Between corporate and the internet: the site edge, with vendor access only through a jump host on a ticket. Nothing on the M&C segment reaches the internet directly', + 'There is one boundary, at the internet; inside the site everything can talk to everything', + 'The boundary is the modem: RF is outside, everything with an Ethernet port is inside and trusted', + 'There are no boundaries; the site relies on the operator noticing', + ], + correctIndex: 0, + explanation: + 'Logged. Two boundaries, each with a short list of what crosses it. A vendor session that arrives on the M&C segment from the internet has crossed both without appearing on either list - which is the fourth drill.', + pointPenalty: 5, + documentLine: 'Stop 8b: boundaries - M&C | corporate (monitoring out only), corporate | internet (jump host, ticketed vendor access).', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-pattern', + nice: ['K1032', 'K0926'], + title: 'Stop 9 - The Pattern', + description: 'Open RX Analysis. On 12 April a carrier from a hill 24 km south-west showed on a dish pointed at 30 degrees elevation, north. Say why.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['not-ordinary-it'], + conditions: [ + { type: 'tab-active', description: 'RX Analysis Open', params: { tab: 'rx-analysis' }, mustMaintain: false }, + { + type: 'status-check', + description: 'Pattern Explained', + params: { + character: Character.SYSTEM, + question: 'Stop 9. A 4 m dish pointed at el 30 north received a ground emitter 24 km to the south-west at about -90 dBm. How?', + options: [ + 'Through a sidelobe. The mainlobe is a degree wide, but the pattern does not fall to nothing outside it: the sidelobe envelope sits tens of dB below the peak in every direction, and 60 dBm of EIRP over 24 km leaves enough for that to matter. Nulls between sidelobes are narrow; no direction is deaf', + 'Through the mainlobe: the emitter was on the same bearing as the satellite', + 'It was not received by the dish at all; the carrier came in on the LNB cable', + 'Ground emitters are always received at full gain because they are closer than the satellite', + ], + correctIndex: 0, + explanation: + 'Logged. Mainlobe, sidelobes, nulls. The gain outside the mainlobe is small but it is not zero, and a strong emitter nearby does not need much. That is also why the 9 m dish in the Signal Hunter campaign hears a fixed-wireless site, and why the correlator, which only sees what the satellites see, does not.', + pointPenalty: 5, + documentLine: + 'Stop 9: pattern - mainlobe ~1 deg, sidelobe envelope tens of dB down in every direction, narrow nulls; a strong nearby emitter enters through a sidelobe.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'outside-the-fence', + nice: ['K0682', 'K0721'], + title: 'Stop 10 - Outside the Fence', + description: + 'The config export, the contact schedule, a photo of the dish at dawn. What about a shift is sensitive once it leaves the site, and what does the station itself give away?', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-pattern'], + conditions: [ + { + type: 'status-check', + description: 'OPSEC and Emissions Named', + params: { + character: Character.SYSTEM, + question: 'Stop 10. Which of these is the operations-security list for a Galway shift?', + options: [ + 'The contact schedule (when the dish will be pointed where), the frequency plan and configuration, key change dates, staffing and vendor visits - none of it posted, mailed unencrypted or discussed off site. And the station emits: a dish pointed at the sky, a BUC keyed up, a beacon locked are all observable from outside, so a pass is a public event to anyone watching', + 'Only the crypto keys are sensitive; the schedule and the frequency plan are published to the customer anyway', + 'Nothing: the station is fenced and the network is segmented, so information leaving the site is not a risk', + 'The operators’ names and shifts only; equipment details are public in the vendor’s brochure', + ], + correctIndex: 0, + explanation: + 'Logged. The carrier arrived at the culmination of a pass whose time somebody knew. The schedule is targeting information; the config export is a map; and the dish itself tells the hill when we are busy. The list is short, and the discipline is to keep it that way.', + pointPenalty: 5, + documentLine: + 'Stop 10: OPSEC - schedule, frequency plan/config, key dates, staffing/vendor visits stay inside; the station’s own emissions and pointing are observable.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/nats-eu/scenario26.ts b/src/campaigns/nats-eu/scenario26.ts new file mode 100644 index 00000000..45094624 --- /dev/null +++ b/src/campaigns/nats-eu/scenario26.ts @@ -0,0 +1,347 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { MHz } from '@app/types'; +import { DRILL_BRIEF, DRILL_EQUIPMENT, drillGalway, drillSatellites } from './drills'; + +/** + * nats-eu Scenario 26: "Baseline Check" (Drill 2/4, phase 18 G) + * + * The pre-pass configuration check against the recorded baseline (4.6 the + * baseline as a control, 4.7 detecting drift, 8.4 the check itself) and the + * access confirmation that gates the first transmit (7.4). + * + * Staged state: overnight the vendor maintenance account set RX modem 1 to + * 1410 MHz. The daybook baseline says 1414 (the SAR-1 video IF). The audit + * log has the change, flagged as a config anomaly, between routine entries. + * The operator reads the modem, reads the log, and calls it: the decision is + * graded on the config-drifted fact, which is true while a config-category + * anomaly is unacknowledged - so the call comes before the flags, and the + * restore comes before the flags too. Then the access request for the + * morning pass is confirmed before anything is armed. + * + * NICE Framework Alignment: + * Primary Codes: + * - T1567: Implement system security configurations + * - S0838: Skill in identifying anomalous activities + * - K0645: Knowledge of standard operating procedures + * Supporting Codes: + * - T0431: Verify configuration against the baseline + * - S0648: Skill in detecting anomalies + * - S0842: Skill in recording operational events + */ +const galwayDrifted = drillGalway(); +galwayDrifted.receivers![0].modems![0] = { + ...galwayDrifted.receivers![0].modems![0], + frequency: 1410 as MHz, // Baseline is 1414: the SAR-1 video IF +}; + +export const natsEuScenario26Data: ScenarioData = { + id: 'nats-eu-scenario26', + url: 'nats-eu/scenarios/nats-eu-scenario26', + imageUrl: 'nats/17/card.png', + number: 26, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario25'], + title: 'Baseline Check', + subtitle: 'Security Drill 2/4', + duration: '15 min', + missionType: 'Security Drill', + description: `05:30, Galway, Thursday. SAR-1 is on the board at 06:10 and the pre-pass sweep starts where it always starts now: the recorded baseline in one hand, the live configuration in the other, and the audit log between them.

Something does not match. The log says who changed it and when. The drill is the check itself - what a baseline is for, how drift shows, what you do with it, and what you confirm before the first transmit of the morning.`, + equipment: DRILL_EQUIPMENT, + settings: { + isSync: true, + groundStations: [galwayDrifted], + satellites: drillSatellites(), + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-29', + scenarioStartWallTime: '05:30:00', + missionBriefUrl: DRILL_BRIEF(26), + workingDocument: { + title: 'Pre-pass Sweep GW-01 / 29 April', + description: 'Baseline check, drift found, action taken, access confirmed.', + }, + security: { + accounts: [ + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'svc-vendor', name: 'Vendor maintenance (ACU/modem)', role: 'Maintenance', status: 'active' }, + ], + events: [ + { id: 'evt-login-night', timeS: 0, timestampLabel: '22:04 UTC', actor: 'op-charlie', action: 'Console login', category: 'auth', severity: 'info' }, + { id: 'evt-logout-night', timeS: 0, timestampLabel: '22:41 UTC', actor: 'op-charlie', action: 'Console logout', category: 'auth', severity: 'info' }, + { + id: 'evt-vendor-login', + timeS: 0, + timestampLabel: '03:09 UTC', + actor: 'svc-vendor', + action: 'Console login (maintenance account)', + category: 'auth', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-cfg-freq', + timeS: 0, + timestampLabel: '03:12 UTC', + actor: 'svc-vendor', + action: 'Set receiver 1 frequency: 1414 -> 1410 MHz (no change ticket)', + category: 'config', + severity: 'warning', + isAnomaly: true, + }, + { id: 'evt-vendor-logout', timeS: 0, timestampLabel: '03:14 UTC', actor: 'svc-vendor', action: 'Console logout', category: 'auth', severity: 'info' }, + { id: 'evt-login-day', timeS: 0, timestampLabel: '05:28 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Read the Sweep Sheet', + description: + 'Open the sheet: the recorded baseline for GW-01 (RX modem 1 at 1414 MHz / 36 MHz / QPSK 3/4), the change-control rule, and the access request for the 06:10 pass.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [{ type: 'mission-brief-opened', description: 'Sweep Sheet Opened', params: { boxId: 'mission-brief' }, mustMaintain: false }], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'the-baseline', + nice: ['T1567', 'K0645'], + title: 'What the Baseline Is For', + description: 'Before touching anything: what the recorded baseline is, and why it is a security control rather than a convenience.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'status-check', + description: 'Baseline Purpose Stated', + params: { + character: Character.SYSTEM, + question: 'The daybook holds a recorded configuration for every unit on the station. What is it for?', + options: [ + 'It is the known-good state: what the station should look like when nobody has touched it. Every difference between it and the live configuration is either a change somebody authorised and logged, or drift - and drift is how a compromise shows up before anything else does', + 'It is a convenience for restoring settings after a power cut', + 'It is the vendor’s factory default, kept for warranty purposes', + 'It is the customer’s frequency plan; the station may differ from it as needed', + ], + correctIndex: 0, + explanation: + 'Logged. A baseline is only a control if it is checked. The sweep compares the live station against it every shift, and anything that differs has to have a ticket behind it.', + pointPenalty: 5, + documentLine: 'Baseline: the recorded known-good configuration; every difference is an authorised change or drift.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'check-against-baseline', + nice: ['T0431', 'S0838'], + title: 'Check the Receiver', + description: 'Read RX modem 1 on RX Analysis against the baseline on the sheet, then open the audit log and find out who last touched it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-baseline'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX modem 1 read: 1410 MHz (baseline 1414)', + params: { frequency: 1410e6, frequencyTolerance: 0.5e6, modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { type: 'audit-log-reviewed', description: 'Audit Log Reviewed', params: {}, mustMaintain: false }, + { + type: 'status-check', + description: 'Drift Identified', + params: { + character: Character.SYSTEM, + question: 'What differs from the baseline, and what does the audit log say about it?', + options: [ + 'RX modem 1 is at 1410 MHz; the baseline says 1414. The log shows the vendor maintenance account logged in at 03:09, changed it at 03:12 with no change ticket, and logged out. Bandwidth, modulation and FEC match the baseline', + 'Nothing differs; 1410 is within the modem’s tuning tolerance of 1414', + 'RX modem 1 is at 1410 MHz and the log shows the site lead changed it at 22:04', + 'The bandwidth is wrong; the frequency is fine', + ], + correctIndex: 0, + explanation: + 'Logged. Four megahertz is not tolerance; it is the difference between locking the SAR-1 video and not. The log tells you who, when and that no ticket exists - which is the whole finding.', + pointPenalty: 5, + documentLine: 'Drift: RX modem 1 at 1410 MHz vs baseline 1414; changed 03:12 by svc-vendor, no change ticket.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'call-the-drift', + nice: ['S0838', 'S0648', 'K0645'], + title: 'Call the Drift', + description: + 'With the modem read on the console and the log entry in front of you, make the call. Do not flag anything yet: the call is graded on the state of the log as it stands.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['check-against-baseline'], + conditions: [ + { + id: 'modem-read', + type: 'rx-modem-frequency-set', + description: 'RX modem 1 reading 1410 MHz on the console', + params: { frequency: 1410e6, frequencyTolerance: 0.5e6, modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Drift or Change', + params: { + character: Character.SYSTEM, + prompt: 'Charlie, by text: "Modem 1 - is that a change or is that drift, and what are you doing about it before 06:10?"', + evidence: ['modem-read'], + decisionOptions: [ + { + label: + 'Drift, unauthorised: no ticket, wrong account for a frequency change, wrong hour. Restore 1414 from the baseline, flag the login and the change, disable the vendor account until someone explains it, and put it in the sweep log for Group Security', + correctWhen: { fact: 'config-drifted', is: true }, + consequence: { log: 'RX modem 1 drift called as unauthorised change by svc-vendor; restore and hold ordered' }, + }, + { + label: 'No drift: the log has nothing unacknowledged against the configuration. Proceed with the sweep', + correctWhen: { fact: 'config-drifted', is: false }, + feedback: + 'There is a config-category entry with no ticket sitting unflagged in the log and the modem is four megahertz off the baseline. That is drift by definition.', + }, + { + label: 'Accept it as the new baseline - a vendor account made the change, so the vendor must have had a reason. Update the daybook to 1410', + feedback: + 'A baseline that updates itself to whatever the station currently says is not a baseline. The vendor account is not authorised to retune a receiver, there is no ticket, and 1410 does not lock the video. The reason has to come from a person with a name.', + consequence: { pointDelta: -5, log: 'Baseline overwritten to 1410 without authorisation' }, + }, + { + label: 'Reset the modem to factory defaults and reconfigure from scratch to be safe', + feedback: 'Factory defaults are not the baseline either, and a reset wipes the modem’s own log of the change. Restore the recorded value and keep the evidence.', + }, + ], + explanation: + 'The finding has three parts and the answer has three parts: restore what the baseline says, keep the record of who changed it, and stop the account that did it until the change has an owner. Accepting drift is how a baseline stops being one.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'restore-the-baseline', + nice: ['T1567', 'T0431'], + title: 'Restore the Baseline', + description: 'RX modem 1 back to 1414 MHz, from the sheet, not from memory.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-drift'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX modem 1 at 1414 MHz', + params: { frequency: 1414e6, frequencyTolerance: 0.5e6, modemNumber: 1 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'flag-the-change', + nice: ['S0838', 'S0842'], + title: 'Flag the Entries', + description: 'Flag the vendor login and the frequency change in the audit log so the sweep record carries them.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['restore-the-baseline'], + conditions: [ + { type: 'security-event-acknowledged', description: 'Vendor Login Flagged', params: { eventId: 'evt-vendor-login' }, mustMaintain: true }, + { type: 'security-event-acknowledged', description: 'Frequency Change Flagged', params: { eventId: 'evt-cfg-freq' }, mustMaintain: true }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'hold-the-account', + nice: ['T1567', 'K0645'], + title: 'Hold the Vendor Account', + description: 'Disable the vendor maintenance account on the Security console until the change has a name and a ticket behind it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-change'], + conditions: [{ type: 'access-control-set', description: 'svc-vendor Disabled', params: { accountId: 'svc-vendor', accountStatus: 'disabled' }, mustMaintain: true }], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'confirm-access', + nice: ['K0645', 'K1032'], + title: 'Confirm the Access Request', + description: 'Before the first transmit of the morning: what authorises GW-01 to take the 06:10 pass, and what confirms it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['hold-the-account'], + conditions: [ + { + type: 'status-check', + description: 'Access Confirmed', + params: { + character: Character.SYSTEM, + question: 'The sheet lists an access request for the 06:10 SAR-1 pass. What do you confirm before keying anything, and against what?', + options: [ + 'That the request (SAR-2027-0429-011) names GW-01, SAR-1, the 06:08-06:17 window and the frequencies on the plan; that Rotterdam constellation ops has confirmed it in the contact schedule; and that the confirmation is for today, not a copy of yesterday’s. No confirmation, no transmit', + 'That the pass is on the board - the board is the authorisation', + 'That the customer wants the imagery; the station’s licence covers the rest', + 'Nothing on a receive pass; access requests only matter when the BUC is keyed', + ], + correctIndex: 0, + explanation: + 'Logged. Satellite access is requested and confirmed for a station, a bird, a window and a set of frequencies, and the confirmation comes from whoever owns the bird. The board shows what we plan to do; the request is what we are allowed to do.', + pointPenalty: 5, + documentLine: 'Access: SAR-2027-0429-011 confirmed for GW-01 / SAR-1 / 06:08-06:17 by Rotterdam; frequencies per plan.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'log-the-sweep', + nice: ['S0842', 'K0645'], + title: 'Log the Sweep', + description: 'One line for the daybook and one for Group Security. The sweep is not done until it is written.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['confirm-access'], + conditions: [ + { + type: 'status-check', + description: 'Sweep Logged', + params: { + character: Character.SYSTEM, + question: 'Which entry closes the sweep?', + options: [ + '05:30 sweep: RX modem 1 found at 1410 vs baseline 1414 (changed 03:12 by svc-vendor, no ticket). Restored 1414 05:41, entries flagged, svc-vendor disabled pending explanation. Access SAR-2027-0429-011 confirmed. Referred to Group Security', + '05:30 sweep complete, no issues', + 'Modem retuned to 1414. Vendor to be asked about it', + 'Sweep done; baseline updated to reflect current settings', + ], + correctIndex: 0, + explanation: 'Logged. Found, restored, flagged, held, confirmed, referred - with times. The next shift can read that and know exactly where the station stands.', + pointPenalty: 5, + documentLine: 'Sweep closed 05:45: drift restored, entries flagged, svc-vendor held, access confirmed, referred to Group Security.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/nats-eu/scenario27.ts b/src/campaigns/nats-eu/scenario27.ts new file mode 100644 index 00000000..a33beed1 --- /dev/null +++ b/src/campaigns/nats-eu/scenario27.ts @@ -0,0 +1,359 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import { DRILL_BRIEF, DRILL_EQUIPMENT, drillGalway, drillSatellites } from './drills'; + +/** + * nats-eu Scenario 27: "Removable Media" (Drill 3/4, phase 18 G) + * + * A vendor technician on site has mounted a USB stick on the M&C workstation + * and staged an ACU firmware image from it. The drill is the transfer rule + * (6.5), trusting what was installed (6.8) and, underneath both, why the ACU + * is not a laptop (6.2). + * + * The decision is graded on config-drifted: the firmware stage is a + * config-category anomaly and stays unacknowledged until after the call, so + * the fact reads true when the call is made. The evidence for the call is the + * media entry flagged (an access-category entry, which config-drifted does + * not read). The wrong "let it install" branch injects the reboot it would + * have caused. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0803: Knowledge of supply chain risk management principles and practices + * - K0721: Knowledge of threats to information systems + * - K0820: Knowledge of supply chain risks + * Supporting Codes: + * - S0838: Skill in identifying anomalous activities + * - K0741: Knowledge of system availability measures + * - T1567: Implement system security configurations + * - S0842: Skill in recording operational events + */ +export const natsEuScenario27Data: ScenarioData = { + id: 'nats-eu-scenario27', + url: 'nats-eu/scenarios/nats-eu-scenario27', + imageUrl: 'nats/17/card.png', + number: 27, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario26'], + title: 'Removable Media', + subtitle: 'Security Drill 3/4', + duration: '15 min', + missionType: 'Security Drill', + description: `08:00, Galway, Friday. The ACU vendor’s technician is on site for the annual service and has been in the equipment room since 07:30. The audit log has two entries from the last twenty minutes that were not on the service plan: a USB device mounted on the M&C workstation, and an ACU firmware image staged from it.

Nothing has installed yet. The drill is what you do in the next ten minutes: the transfer rule, how a firmware image earns trust, and why the machine that moves the dish gets none of the shortcuts a laptop gets.`, + equipment: DRILL_EQUIPMENT, + settings: { + isSync: true, + groundStations: [drillGalway()], + satellites: drillSatellites(), + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-04-30', + scenarioStartWallTime: '08:00:00', + missionBriefUrl: DRILL_BRIEF(27), + workingDocument: { + title: 'Security Event GW-01 / 30 April', + description: 'Removable media on the M&C workstation. What was found, what was held, what was verified.', + }, + security: { + accounts: [ + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'vend-tech', name: 'Vendor technician (on site, escorted)', role: 'Maintenance', status: 'active' }, + ], + events: [ + { id: 'evt-login-day', timeS: 0, timestampLabel: '05:58 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { + id: 'evt-tech-login', + timeS: 0, + timestampLabel: '07:31 UTC', + actor: 'vend-tech', + action: 'Console login (escorted maintenance, ticket MNT-0430)', + category: 'auth', + severity: 'info', + }, + { id: 'evt-acu-read', timeS: 0, timestampLabel: '07:36 UTC', actor: 'vend-tech', action: 'ACU diagnostics read (per MNT-0430)', category: 'command', severity: 'info' }, + { + id: 'evt-usb', + timeS: 0, + timestampLabel: '07:41 UTC', + actor: 'vend-tech', + action: 'USB mass-storage device mounted on M&C workstation GW-MC-01 (no media ticket)', + category: 'access', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-fw-stage', + timeS: 0, + timestampLabel: '07:44 UTC', + actor: 'vend-tech', + action: 'ACU controller firmware image staged from removable media: acu-4.2.1-vendor.bin (unsigned, not in MNT-0430)', + category: 'config', + severity: 'critical', + isAnomaly: true, + }, + { id: 'evt-ntp', timeS: 0, timestampLabel: '07:45 UTC', actor: 'svc-monitor', action: 'Reference sync check OK', category: 'command', severity: 'info' }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Read the Service Plan', + description: 'Open the plan: ticket MNT-0430 (diagnostics read, fan replacement, no firmware), the transfer rule for the enclave, and the vendor’s signed-release process.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [{ type: 'mission-brief-opened', description: 'Service Plan Opened', params: { boxId: 'mission-brief' }, mustMaintain: false }], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'review-the-log', + nice: ['S0838', 'K0803'], + title: 'Find What Is Off the Plan', + description: 'Open the Security console and read the morning against MNT-0430. Two entries are not on it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { type: 'audit-log-reviewed', description: 'Audit Log Reviewed', params: {}, mustMaintain: false }, + { + type: 'status-check', + description: 'Off-plan Entries Identified', + params: { + character: Character.SYSTEM, + question: 'Which entries in the log are outside ticket MNT-0430, and which rule does each break?', + options: [ + '07:41 USB mass storage mounted on the M&C workstation (breaks the transfer rule: nothing enters the enclave on removable media without a media ticket and a scan) and 07:44 an unsigned ACU firmware image staged from it (breaks change control: no firmware in the ticket, no signed release)', + 'Only the 07:44 firmware stage; the USB mount is routine for a vendor visit', + 'The 07:36 diagnostics read - the vendor should not have console access at all', + 'None: the technician is escorted and on a ticket, so everything they do is covered by it', + ], + correctIndex: 0, + explanation: + 'Logged. An escort and a ticket cover what the ticket says. The stick and the image are two separate rules broken in three minutes, and the second one has not finished happening yet.', + pointPenalty: 5, + documentLine: '07:41 USB mounted on GW-MC-01 (no media ticket); 07:44 unsigned ACU firmware acu-4.2.1-vendor.bin staged (not in MNT-0430).', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-the-media', + nice: ['K0803', 'K0721', 'S0838'], + title: 'Call It', + description: + 'Flag the media entry so the record shows you saw it. Leave the firmware entry until the call is made - the call is graded on the log as it stands. Then decide what happens to the image.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-the-log'], + conditions: [ + { id: 'usb-flagged', type: 'security-event-acknowledged', description: 'USB mount flagged', params: { eventId: 'evt-usb' }, mustMaintain: true }, + { + type: 'decision', + description: 'What Happens to the Image', + params: { + character: Character.SYSTEM, + prompt: 'Charlie, from the equipment room: "He says it is the service release and it takes four minutes. Yes or no, and what do you want done with the stick?"', + evidence: ['usb-flagged'], + decisionOptions: [ + { + label: + 'No. Hold the staged image, take the workstation off the M&C segment, keep the stick as it is, and suspend the technician’s account. The image installs only after its hash matches the vendor’s signed release, obtained through the enclave transfer path, under a ticket that names it. Group Security gets the log', + correctWhen: { fact: 'config-drifted', is: true }, + consequence: { log: 'ACU firmware stage held; GW-MC-01 isolated; media retained; vend-tech suspended pending verification' }, + }, + { + label: 'Nothing is pending against the configuration - the log is clean. Let the service continue', + correctWhen: { fact: 'config-drifted', is: false }, + feedback: + 'An unsigned firmware image is staged on the ACU controller and the entry is sitting unflagged in the log. That is a pending configuration change with nobody’s name on it.', + }, + { + label: 'Yes - it is the vendor’s own technician with the vendor’s own release. Let it install, acknowledge the entries, note it in the ticket afterwards', + feedback: + 'The technician’s badge does not sign the file. An image that arrived on a stick, is not in the ticket, and carries no signature is exactly what a supply-chain compromise looks like from the inside - and the ACU would have rebooted on it.', + consequence: { + pointDelta: -10, + auditEvent: { + id: 'evt-acu-reboot', + timestampLabel: '08:07 UTC', + actor: 'vend-tech', + action: 'ACU controller rebooted on unverified image acu-4.2.1-vendor.bin', + category: 'config', + severity: 'critical', + isAnomaly: true, + }, + log: 'ACU firmware installed from unverified media', + }, + }, + { + label: 'Wipe and reimage the workstation now, destroy the stick, and send the technician off site', + feedback: + 'Wiping the workstation and destroying the stick removes the only evidence of what was on them. Hold, isolate, keep - then let CSIRT decide what gets wiped.', + consequence: { pointDelta: -5, log: 'Workstation wiped and media destroyed before analysis' }, + }, + ], + explanation: + 'The right answer is a hold, not a verdict. The image might be exactly what he says it is; the station cannot know that yet, and the machine it is staged on moves the dish. Verification is a hash against a signed release through the proper path, not a technician’s word.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'verify-the-image', + nice: ['K0820', 'K0803'], + title: 'How the Image Earns Trust', + description: 'The technician is sure it is the service release. Say what would make the station sure.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-media'], + conditions: [ + { + type: 'status-check', + description: 'Verification Path Stated', + params: { + character: Character.SYSTEM, + question: 'What makes acu-4.2.1-vendor.bin trustworthy enough to install on the ACU controller?', + options: [ + 'A release the vendor publishes with a signature and a hash; the file obtained through the enclave transfer path (not the stick); the hash checked against the published one on the isolated workstation; and a change ticket naming the version, the window and the rollback. Any link missing, it does not install', + 'The technician’s assurance plus a virus scan of the stick', + 'The file name matching the version in the vendor’s release notes', + 'Nothing can make it trustworthy; the ACU should never be updated', + ], + correctIndex: 0, + explanation: + 'Logged. Trusting what was installed means being able to say where it came from and prove it is what it claims to be. A signed release, a controlled path, a hash match and a ticket are that proof; a badge and a scan are not.', + pointPenalty: 5, + documentLine: 'Verification: vendor-signed release, obtained via enclave transfer path, hash matched on isolated host, installed under a ticket with rollback.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'flag-the-firmware', + nice: ['S0838', 'S0842'], + title: 'Flag the Firmware Stage', + description: 'Now flag the firmware entry so the record carries the hold.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['verify-the-image'], + conditions: [{ type: 'security-event-acknowledged', description: 'Firmware Stage Flagged', params: { eventId: 'evt-fw-stage' }, mustMaintain: true }], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'hold-the-technician', + nice: ['T1567', 'K0645'], + title: 'Suspend the Technician’s Account', + description: 'The escorted account is disabled until the image is verified and the ticket amended. The fan can wait.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-firmware'], + conditions: [{ type: 'access-control-set', description: 'vend-tech Disabled', params: { accountId: 'vend-tech', accountStatus: 'disabled' }, mustMaintain: true }], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-transfer-rule', + nice: ['K0803', 'K0645'], + title: 'The Right Way In', + description: 'The vendor does need to get firmware onto the station sometimes. Say how it is supposed to happen.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['hold-the-technician'], + conditions: [ + { + type: 'status-check', + description: 'Transfer Rule Stated', + params: { + character: Character.SYSTEM, + question: 'How does a file get into the M&C enclave?', + options: [ + 'Through the transfer station: a media ticket raised in advance, the file scanned and hash-checked on the isolated kiosk, moved across by the one-way path onto site-owned media, and the whole thing logged with two names on it. Personal or vendor media never mounts on an M&C host', + 'On any stick, as long as the workstation’s antivirus scans it first', + 'By email to the operator, who copies it across', + 'Vendors bring their own media; the site trusts the vendor', + ], + correctIndex: 0, + explanation: 'Logged. The rule exists so that the question "what is on this stick" never has to be answered on the machine that moves the dish. Slow by design.', + pointPenalty: 5, + documentLine: 'Transfer rule: media ticket, isolated kiosk scan + hash, one-way path to site media, two-name log; no foreign media on M&C hosts.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'why-not-a-laptop', + nice: ['K0741', 'K0721'], + title: 'Why Not Just Scan It', + description: 'The technician’s objection: every office in the country scans a stick and moves on. Say why this room is different.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-transfer-rule'], + conditions: [ + { + type: 'status-check', + description: 'OT Reasoning Stated', + params: { + character: Character.SYSTEM, + question: 'Why is "scan the stick on the workstation and install" not acceptable here when it is everywhere else?', + options: [ + 'Because the workstation is on the segment that controls the antenna, and the ACU runs firmware nothing on that segment can inspect afterwards. A scanner catches what it knows; a modified controller image is not on its list. On operational equipment the control is the path the file took, not a scan at the end of it', + 'Because the antivirus on the workstation is out of date', + 'Because the vendor is not trusted by the site', + 'It is acceptable; the drill is about paperwork', + ], + correctIndex: 0, + explanation: + 'Logged. An office laptop can be wiped tomorrow. A controller running a bad image points a four-metre dish wherever the image says, on the next pass, with nobody watching.', + pointPenalty: 5, + documentLine: 'OT reasoning: M&C hosts control the antenna; controller firmware cannot be inspected after install; the control is the transfer path, not a scan.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'log-the-event', + nice: ['S0842', 'K0645'], + title: 'Log the Event', + description: 'One entry for Group Security with the times, the hold, and what verification is waiting on.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['why-not-a-laptop'], + conditions: [ + { + type: 'status-check', + description: 'Event Logged', + params: { + character: Character.SYSTEM, + question: 'Which entry closes the event?', + options: [ + '07:41 USB mounted on GW-MC-01 by vend-tech (no media ticket); 07:44 unsigned acu-4.2.1-vendor.bin staged on ACU (not in MNT-0430). 08:05 stage held, GW-MC-01 isolated, media retained, vend-tech suspended. Install pending hash match against signed release via transfer path. Group Security notified', + 'Vendor tried to install firmware; told him no', + 'Firmware installed after the technician confirmed it was the service release', + 'Event closed; workstation reimaged; media destroyed', + ], + correctIndex: 0, + explanation: 'Logged. Times, entries, hold, what is waiting on what. The service resumes when the image has a signature and a ticket, and not before.', + pointPenalty: 5, + documentLine: 'Closed 08:10: stage held, GW-MC-01 isolated, media retained, vend-tech suspended; install pending verified release; Group Security notified.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/nats-eu/scenario28.ts b/src/campaigns/nats-eu/scenario28.ts new file mode 100644 index 00000000..4c50c575 --- /dev/null +++ b/src/campaigns/nats-eu/scenario28.ts @@ -0,0 +1,369 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import { DRILL_BRIEF, DRILL_EQUIPMENT, drillGalway, drillSatellites } from './drills'; + +/** + * nats-eu Scenario 28: "Vendor Session" (Drill 4/4, phase 18 G) + * + * A vendor remote-access session has opened on the M&C segment on a + * Saturday evening with no ticket, and a flow the network has never carried + * appears beside it. The drill is the flow baseline (6.7 - what normal + * looks like, so that this is visibly not it), the boundary it crossed (6.3), + * the path it could reach (6.1), and vendor access done properly (6.4). + * + * The decision is graded on audit-anomaly-present: three anomalies are in the + * log, the evidence for the call is the session entry flagged, and the two + * flow entries stay unflagged until after the call, so the fact reads true + * when it is made. The wrong "let them finish" branch injects the config + * change the session would have made. + * + * NICE Framework Alignment: + * Primary Codes: + * - T1084: Identify anomalous network activity + * - T1228: Maintain baseline system security + * - K0689: Knowledge of network architecture + * - K0721: Knowledge of threats to information systems + * Supporting Codes: + * - S0648: Skill in detecting anomalies + * - S0838: Skill in identifying anomalous activities + * - K0915: Knowledge of network architecture principles and practices + * - T1567: Implement system security configurations + * - S0842: Skill in recording operational events + */ +export const natsEuScenario28Data: ScenarioData = { + id: 'nats-eu-scenario28', + url: 'nats-eu/scenarios/nats-eu-scenario28', + imageUrl: 'nats/17/card.png', + number: 28, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario27'], + title: 'Vendor Session', + subtitle: 'Security Drill 4/4', + duration: '15 min', + missionType: 'Security Drill', + description: `21:30, Galway, Saturday. Nothing on the board until the 23:50 pass. The Security console has three new entries since 21:12: a vendor remote-access session on the maintenance account, an outbound flow from the M&C segment to a host the flow baseline has never seen, and a burst of ACU parameter reads from the session.

No ticket, no maintenance window, no phone call. The drill is the flow baseline that makes this visible, the boundary it should not have crossed, what a session on that segment can reach, and what a vendor session is supposed to look like when it is real.`, + equipment: DRILL_EQUIPMENT, + settings: { + isSync: true, + groundStations: [drillGalway()], + satellites: drillSatellites(), + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-05-01', + scenarioStartWallTime: '21:30:00', + missionBriefUrl: DRILL_BRIEF(28), + workingDocument: { + title: 'Security Event GW-01 / 1 May', + description: 'Vendor remote session, unticketed. What was seen, what was cut, what was preserved.', + }, + security: { + accounts: [ + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'svc-vendor', name: 'Vendor remote support (ACU/modem)', role: 'Maintenance', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + ], + events: [ + { + id: 'evt-poll-1', + timeS: 0, + timestampLabel: '21:00 UTC', + actor: 'svc-monitor', + action: 'M&C poll: ACU, RF front end, modems, GPSDO (baseline flow set)', + category: 'command', + severity: 'info', + }, + { + id: 'evt-ntp', + timeS: 0, + timestampLabel: '21:05 UTC', + actor: 'svc-monitor', + action: 'Reference sync check OK (NTP, baseline flow)', + category: 'command', + severity: 'info', + }, + { id: 'evt-login', timeS: 0, timestampLabel: '21:10 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { + id: 'evt-vpn', + timeS: 0, + timestampLabel: '21:12 UTC', + actor: 'svc-vendor', + action: 'Remote-access session opened from 185.199.74.20 (no change ticket; outside maintenance window)', + category: 'access', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-flow', + timeS: 0, + timestampLabel: '21:14 UTC', + actor: 'svc-vendor', + action: 'New outbound flow: M&C segment -> 185.199.74.20:8443 (not in flow baseline)', + category: 'access', + severity: 'critical', + isAnomaly: true, + }, + { + id: 'evt-acu-burst', + timeS: 0, + timestampLabel: '21:15 UTC', + actor: 'svc-vendor', + action: 'ACU parameter read burst (212 reads in 40 s) from remote session', + category: 'command', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-poll-2', + timeS: 0, + timestampLabel: '21:20 UTC', + actor: 'svc-monitor', + action: 'M&C poll: ACU, RF front end, modems, GPSDO (baseline flow set)', + category: 'command', + severity: 'info', + }, + ], + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Read the Flow Baseline', + description: 'Open the sheet: the M&C flow baseline (what talks to what, on which port, how often), the vendor access procedure, and the maintenance window for the week.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [{ type: 'mission-brief-opened', description: 'Flow Baseline Opened', params: { boxId: 'mission-brief' }, mustMaintain: false }], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'read-the-baseline', + nice: ['T1228', 'S0648'], + title: 'What Normal Looks Like', + description: 'The flow baseline is a short list. Say what is on it and why that makes tonight’s new flow visible.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'status-check', + description: 'Flow Baseline Stated', + params: { + character: Character.SYSTEM, + question: 'What is on the M&C flow baseline for GW-01, and what does a baseline like that buy you?', + options: [ + 'Workstation and monitoring service to the ACU, RF front end, modems and GPSDO on their management ports; NTP to the site reference; the monitoring feed out to corporate through the firewall. Nothing outbound to the internet. A list that short means any flow not on it is an event on its own, before anyone reads what it carried', + 'Everything the workstation has ever connected to; the baseline grows as the network is used', + 'The list of hosts the firewall blocks', + 'There is no baseline for the M&C segment; it is monitored by the corporate SOC', + ], + correctIndex: 0, + explanation: + 'Logged. Baselining normal is what makes abnormal cheap to see. An operational network has a dozen flows and they do not change between maintenance windows, so a thirteenth is the finding, whatever it carried.', + pointPenalty: 5, + documentLine: 'Flow baseline: M&C hosts -> ACU/RFFE/modems/GPSDO mgmt ports; NTP; monitoring out via firewall; no internet egress. Any other flow is an event.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'review-the-log', + nice: ['S0838', 'T1084'], + title: 'Read the Log Against the Baseline', + description: 'Open the Security console. Three entries since 21:12 are not on the baseline; the two polls are.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-baseline'], + conditions: [{ type: 'audit-log-reviewed', description: 'Audit Log Reviewed', params: {}, mustMaintain: false }], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'call-the-session', + nice: ['K0721', 'K0689', 'S0838'], + title: 'Call the Session', + description: 'Flag the session entry so the record shows you saw it, leave the flow entries until the call is made, then decide.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-the-log'], + conditions: [ + { id: 'session-flagged', type: 'security-event-acknowledged', description: 'Remote session flagged', params: { eventId: 'evt-vpn' }, mustMaintain: true }, + { + type: 'decision', + description: 'What Happens to the Session', + params: { + character: Character.SYSTEM, + prompt: 'Charlie, by phone: "Vendor says it is proactive support - they saw an alert on their side and jumped on. What are you doing with it?"', + evidence: ['session-flagged'], + decisionOptions: [ + { + label: + 'Cutting it. Disable the vendor account now, which drops the session; flag the flow and the read burst; preserve the log and the firewall record; notify Group Security tonight. If the vendor has a real alert they can raise a ticket and come in through the jump host in a window, with someone watching', + correctWhen: { fact: 'audit-anomaly-present', is: true }, + consequence: { log: 'Unticketed vendor remote session cut; svc-vendor disabled; flows flagged; Group Security notified' }, + }, + { + label: 'Nothing is unflagged in the log - there is no open anomaly. Let it run', + correctWhen: { fact: 'audit-anomaly-present', is: false }, + feedback: + 'Two anomalies are sitting unflagged in the log: a flow the baseline has never carried and a read burst from the session that made it. That is an open event by any reading.', + }, + { + label: 'Let them finish - it is the vendor, on the vendor account, doing vendor things. Ask for the ticket number on Monday', + feedback: + 'A session with no ticket, outside the window, from an address the baseline has never seen, reading the ACU 212 times in 40 seconds is not support. It is either a vendor ignoring the procedure or somebody using the vendor’s credentials; from this console those look the same, and the answer to both is the same.', + consequence: { + pointDelta: -10, + auditEvent: { + id: 'evt-acu-cfg', + timestampLabel: '21:41 UTC', + actor: 'svc-vendor', + action: 'ACU tracking parameters changed from remote session (no ticket)', + category: 'config', + severity: 'critical', + isAnomaly: true, + }, + log: 'Vendor remote session allowed to continue; ACU configuration changed remotely', + }, + }, + { + label: 'Pull the site’s internet uplink at the edge router until Monday', + feedback: + 'That cuts the session and also cuts Rotterdam, the monitoring feed, Group Security’s path in, and the 23:50 pass coordination. The account is the switch that removes exactly the thing that should not be there.', + consequence: { pointDelta: -5, log: 'Site internet uplink pulled at the edge' }, + }, + ], + explanation: + 'The procedure exists so that a vendor session is a thing the station starts, not a thing that happens to it. Cut the account, keep the evidence, tell CSIRT, and let the vendor come back the right way.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'cut-the-session', + nice: ['T1567', 'K0645'], + title: 'Disable the Vendor Account', + description: 'On the Security console, set the vendor remote support account to disabled. The session drops with it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['call-the-session'], + conditions: [{ type: 'access-control-set', description: 'svc-vendor Disabled', params: { accountId: 'svc-vendor', accountStatus: 'disabled' }, mustMaintain: true }], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'flag-the-flows', + nice: ['S0838', 'S0842'], + title: 'Flag the Flow and the Burst', + description: 'Flag the outbound flow and the ACU read burst so the record carries all three.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['cut-the-session'], + conditions: [ + { type: 'security-event-acknowledged', description: 'Outbound Flow Flagged', params: { eventId: 'evt-flow' }, mustMaintain: true }, + { type: 'security-event-acknowledged', description: 'ACU Read Burst Flagged', params: { eventId: 'evt-acu-burst' }, mustMaintain: true }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-boundary', + nice: ['K0689', 'K0721'], + title: 'The Boundary It Crossed', + description: 'A session from the internet reached the M&C segment. Say where it should have been stopped, and what a real vendor session looks like.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-flows'], + conditions: [ + { + type: 'status-check', + description: 'Boundary and Procedure Stated', + params: { + character: Character.SYSTEM, + question: 'Where should the session have been stopped, and how does vendor access work when it is done properly?', + options: [ + 'At the site edge: nothing inbound reaches the M&C segment directly. A real session comes through the jump host in the corporate zone, on a ticket that names the window and the units, with the account enabled for that window only, the session recorded, and an operator on the console while it runs', + 'At the ACU itself; the controller should refuse remote logins', + 'Nowhere - vendor support has to be able to reach the equipment at any time', + 'At the operator: the console should have asked before allowing the session', + ], + correctIndex: 0, + explanation: + 'Logged. The boundary is a place, the procedure is a shape, and tonight’s session matched neither. That the account was enabled at all on a Saturday night is the finding for Monday.', + pointPenalty: 5, + documentLine: 'Boundary: no inbound to M&C; vendor access via corporate jump host, ticketed window, account enabled for the window only, recorded, operator present.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'what-it-could-reach', + nice: ['K0915', 'K0689'], + title: 'What It Could Reach', + description: 'From where the session sat, what was within reach - and what was not.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['the-boundary'], + conditions: [ + { + type: 'status-check', + description: 'Reach Assessed', + params: { + character: Character.SYSTEM, + question: 'A session on the M&C segment with the vendor account: what could it touch?', + options: [ + 'Everything on the monitor-and-control path: the ACU (pointing, tracking, limits), the RF front end controller (LNB LO, BUC), the modems, the GPSDO settings, and the workstation it came in through. Not the crypto unit, which has its own path and its own keys, and not the RF itself. The read burst says it was mapping the ACU', + 'Only the ACU diagnostics page the vendor account is scoped to', + 'Nothing - the vendor account is read-only', + 'The whole site, including the crypto unit and the customer’s data', + ], + correctIndex: 0, + explanation: + 'Logged. The M&C path is one segment and a vendor account is rarely scoped as tightly as its ticket. Knowing what was reachable is what tells CSIRT what to check on Monday: every unit on the path, against its baseline.', + pointPenalty: 5, + documentLine: 'Reach: full M&C path (ACU, RFFE, modems, GPSDO, workstation); not the crypto unit. Read burst consistent with mapping the ACU.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'log-the-event', + nice: ['S0842', 'K0645'], + title: 'Log and Notify', + description: 'One entry for Group Security tonight, with the times, the cut, and what is preserved.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['what-it-could-reach'], + conditions: [ + { + type: 'status-check', + description: 'Event Logged', + params: { + character: Character.SYSTEM, + question: 'Which entry closes the event?', + options: [ + '21:12 vendor remote session (svc-vendor, 185.199.74.20, no ticket, outside window); 21:14 outbound flow to same host not in baseline; 21:15 ACU read burst. 21:38 svc-vendor disabled, session dropped; entries flagged; audit and firewall logs preserved. Group Security notified 21:40; every unit on the M&C path to be checked against baseline before the 23:50 pass', + 'Vendor session cut; vendor to be asked about it Monday', + 'Vendor support session completed; no action', + 'Internet uplink pulled; site offline until Monday', + ], + correctIndex: 0, + explanation: 'Logged. Seen, cut, preserved, notified, and the next action named with a time. The drill ends where the second one began: the baseline check.', + pointPenalty: 5, + documentLine: 'Closed 21:45: svc-vendor disabled, session dropped, entries flagged, logs preserved, Group Security notified; M&C baseline check before 23:50.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/nats-eu/scenario3.ts b/src/campaigns/nats-eu/scenario3.ts new file mode 100644 index 00000000..8c495f44 --- /dev/null +++ b/src/campaigns/nats-eu/scenario3.ts @@ -0,0 +1,775 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, IfFrequency } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 3 - "Two-Way Street" / First Commanding Window + * + * New mechanic: M2 LEO uplink ops (TT&C tab). Until now GW-01 has only + * listened. This is the first time the station transmits at a LEO bird, which + * introduces the thing GEO work never taught: the uplink Doppler shift moves + * several hundred kHz across a seven-minute pass, so the command carrier has to + * be pre-compensated or the spacecraft receiver never sees it. + * + * Phase 16 rewrite: the whole uplink shift. Reference and BUC lock checked, + * the transmit modem tuned to the command IF (the crew left it on their test + * carrier), Doppler compensation engaged, then the transmit chain brought up in + * the invariant order as three separate steps - carrier into a MUTED BUC, unmute, + * HPA - the window confirmed, the command sent and acknowledged, and the chain + * brought down in the reverse order. Enabling the HPA while the BUC has no + * drive trips HPA_NOISE_AMPLIFICATION and fails the mission; that is why the + * order is three objectives and not one. + * + * Clock: sim starts 2027-03-15 13:45:00 UTC (the brief freezes it until read). + * MERIDIAN-SAR-1: AOS 14:03:10 (5 deg mask), max el 28.0 at 14:06:45, LOS + * 14:10:18. Command window 14:03:40 .. 14:10:00 = windowStartS 1120 .. + * windowEndS 1500 from mission start. Uplink Doppler at 14.0 GHz: +/- 325 kHz. + * + * Key status starts Valid and `requireValidKey` is off here - COMSEC key + * handling is scenario 4's lesson, deliberately not stacked on top of this one. + * + * Staged state (scenario-local clone of GW-01): BUC muted (safe), TX modem 1 + * on the acceptance crew's 1400 MHz carrier instead of the 1405 MHz command IF. + * + * NICE Framework Alignment: + * Primary Codes: + * - T1567: Conduct satellite command and control operations + * - K1032: Knowledge of satellite-based communication systems + * - K0773: Knowledge of telemetry, tracking and commanding principles + * + * Supporting Codes: + * - S0421: Skill in operating network equipment + * - K0645: Knowledge of standard operating procedures + * - T0431: Check system hardware availability, functionality, integrity + * - K0740: Knowledge of system performance indicators + * - T1580: Report service status to stakeholders + */ + +/** GW-01 before its first uplink: BUC muted, TX modem on the wrong IF. Deep clone, never spread. */ +const galwayFirstUplink: GroundStationConfig = structuredClone(galwayGroundStation); +galwayFirstUplink.rfFrontEnds[0].buc = { ...galwayFirstUplink.rfFrontEnds[0].buc, isMuted: true }; +const stagedModem = galwayFirstUplink.transmitters![0].modems![0]; +stagedModem.ifSignal = { ...stagedModem.ifSignal, frequency: 1400e6 as IfFrequency }; + +export const natsEuScenario3Data: ScenarioData = { + id: 'nats-eu-scenario3', + url: 'nats-eu/scenarios/nats-eu-scenario3', + imageUrl: 'nats/3/card.png', + number: 3, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario2'], + title: 'Two-Way Street', + subtitle: 'First Commanding Window', + duration: '30-35 min', + missionType: 'Commanding', + description: `GW-01 is accepted for receive. Today it earns the other half of its licence: transmit.

Anneke Visser at MERIDIAN constellation ops needs a recorder playback command on the bird this pass, and the constellation's flight rules say the command has to originate from a station that has demonstrated a clean uplink. That is you, in about eighteen minutes.

Two things GEO never made you think about. The bird is closing at seven kilometres a second, so your 14 GHz carrier arrives at the spacecraft hundreds of kilohertz off frequency unless you compensate for it. And the transmit chain has an order: carrier, then BUC, then amplifier, and the mirror of that on the way down. An HPA with nothing behind it amplifies its own noise into the feed, and that ends the shift.`, + equipment: ['4m Ku-Band LEO Tracking Antenna', 'Ku-Band BUC (12600 MHz LO) + HPA', 'TT&C Commanding Console', 'QPSK 3/4 Transmit Modem'], + settings: { + isSync: true, + groundStations: [galwayFirstUplink], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:45:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-3?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'GW-01 Command Log', + description: 'One line per uplink: window, command, result, and the state the chain was left in.', + }, + + // M2 - uplink ops. Window bounded to the SAR-1 pass; Doppler compensation + // required for an ACK; COMSEC deliberately not required yet (scenario 4). + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61701, + windowStartS: 1120, + windowEndS: 1500, + requireDopplerComp: true, + requireValidKey: false, + commands: [ + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + { id: 'OBC-WDT-RESET', label: 'Reset OBC watchdog' }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the Commanding Brief', + description: 'Open the shift brief and confirm the command, the target, and the window.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Commanding Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Uplink Doppler Understood', + params: { + character: Character.SYSTEM, + question: 'MERIDIAN-SAR-1 is in a 360 km orbit closing at roughly 7 km/s. What does that do to your 14005 MHz command carrier as the spacecraft sees it?', + options: [ + 'Shifts it by hundreds of kilohertz - the spacecraft hears it off frequency unless you pre-compensate.', + 'Nothing - Doppler only affects the downlink, and the spacecraft receiver is locked to its own reference.', + 'Attenuates it - the frequency is unchanged, but the fast closing rate costs a few dB of link margin.', + ], + correctIndex: 0, + explanation: + 'Correct. Downlink Doppler you can chase with AFC; uplink Doppler you have to predict and pre-compensate, because the spacecraft cannot tell you it is off frequency. Eighteen minutes to the window.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: PRE-PASS - REFERENCE, UPLINK PLAN, DOPPLER + // ============================================================ + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference and BUC Lock', + description: 'A transmitted frequency is only as good as the reference behind it. GPSDO locked, BUC phase-locked to the external 10 MHz.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'tune-tx-modem', + nice: ['K0773', 'S0421'], + title: 'Tune the Transmit Modem to the Command IF', + description: + 'BUC LO is 12600 MHz, low-side: RF = IF + LO. The SAR-1 telecommand receiver listens on 14005 MHz. Work out the IF and set transmit modem 1 to it; the crew left it on 1400 MHz.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-frequency-set', + description: 'TX Modem 1 at 1405 MHz', + params: { modemNumber: 1, frequency: 1405e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Uplink IF Arithmetic', + params: { + character: Character.SYSTEM, + question: 'BUC LO 12600 MHz, low-side. The spacecraft receiver is on 14005 MHz. What IF does the transmit modem need?', + options: [ + '1405 MHz - RF minus LO, and the spectrum is NOT inverted on this side', + '1400 MHz - what the crew left it on, and the BUC LO makes up the difference', + '1414 MHz - same as the receive IF, and the spectrum is inverted on both sides', + '11195 MHz - LO minus RF, and the spectrum is inverted on this side too', + ], + correctIndex: 0, + explanation: + 'Low-side BUC: RF = IF + LO, so IF = 14005 - 12600 = 1405 MHz. The receive LNB is high-side and inverts; the transmit BUC is low-side and does not. Five megahertz off and the spacecraft never hears you.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'review-pass-schedule', + nice: ['K1032', 'T1567'], + title: 'Confirm the Command Window', + description: 'Read the pass off the Pass Schedule tab and confirm when the command window is open.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['tune-tx-modem'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Window Read', + params: { + character: Character.SYSTEM, + question: 'When can REC-PLAYBACK be sent?', + options: [ + '14:03:40 to 14:10:00 - inside the pass, after the bird has cleared the mask', + 'Any time before 14:10:18 - the spacecraft buffers commands until it is over the site', + '14:03:10 to 14:10:18 - the whole pass above the mask, from AOS to LOS', + '14:06:45 only - at culmination, when the bird is at its shortest range', + ], + correctIndex: 0, + explanation: + 'The window opens thirty seconds after AOS and closes at 14:10:00. Outside it the spacecraft receiver is off the beam or below the mask and the console reports NAK - out of window.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'enable-doppler-comp', + nice: ['T1567', 'K0773'], + title: 'Enable Uplink Doppler Compensation', + description: + 'On the TT&C console, engage uplink Doppler compensation before the pass. It slews the transmit carrier against the predicted range rate so the spacecraft receiver sees 14005 MHz throughout.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-pass-schedule'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'TT&C Console Open', + params: { tab: 'commanding' }, + mustMaintain: false, + }, + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEATS 1-2: TX CHAIN UP IN ORDER, THEN ACQUIRE + // ============================================================ + { + id: 'carrier-into-muted-buc', + nice: ['S0421', 'K0645'], + title: 'Carrier Up, BUC Muted', + description: 'Step one of three. Put transmit modem 1 on the air into the MUTED BUC. Nothing radiates yet; the drive is there for the next step.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['enable-doppler-comp'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Still Muted', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Why the Carrier First', + params: { + character: Character.SYSTEM, + question: 'Why does the modem carrier go up before the BUC is unmuted and long before the HPA is enabled?', + options: [ + 'So the amplifier always has drive behind it: an HPA on an empty BUC amplifies its own noise', + 'So the modem has time to settle: a carrier that is still drifting will not hold the BUC phase lock', + 'So the BUC can unmute: it will not accept the unmute command without a carrier present at its input', + 'It does not matter: the interlocks handle the order and block the HPA until there is drive', + ], + correctIndex: 0, + explanation: + 'Drive before amplifier on the way up, amplifier before drive on the way down. An HPA enabled on an empty BUC amplifies its own noise floor into the feed. There is no interlock; the order is you.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'unmute-buc', + nice: ['S0421', 'K0645'], + title: 'Unmute the BUC', + description: 'Step two of three. With the carrier present, unmute the BUC. The HPA stays disabled until this is done.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['carrier-into-muted-buc'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'HPA Still Disabled', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'enable-hpa', + nice: ['S0421', 'K0740'], + title: 'Enable the HPA', + description: 'Step three of three. Enable the HPA output and confirm it is running with its 10 dB back-off, not overdriven.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['unmute-buc'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'hpa-not-overdriven', + description: 'HPA Within Operating Limits', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'track-for-commanding', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: + 'Program-track the bird before 14:03. You cannot command what you are not pointed at - the 4m Ku beam is under half a degree wide. Confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['enable-hpa'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 3: THE COMMAND WINDOW + // ============================================================ + { + id: 'confirm-window-open', + nice: ['T1567', 'K1032'], + title: 'Wait for the Window', + description: 'Bird is up, chain is radiating. On the TT&C console confirm the window reads OPEN before you send anything, and know what a NAK means.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['track-for-commanding'], + conditions: [ + { + type: 'tab-active', + description: 'TT&C Console Open', + params: { tab: 'commanding' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'NAK Understood', + params: { + character: Character.SYSTEM, + question: 'A command sent at 14:03:20 comes back NAK. What does the Detail column most likely say, and what do you do?', + options: [ + 'Out of window - wait for 14:03:40, then send the command again', + 'No Doppler compensation - re-engage it, then send again by 14:10:00', + 'Key invalid - rotate the key before 14:10:00, then send it again', + 'Spacecraft fault - abandon the pass and log the NAK at 14:03:20', + ], + correctIndex: 0, + explanation: 'Twenty seconds early. The spacecraft rejects anything outside its command window; the console tells you why. A NAK is information, not a failure.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'send-the-command', + nice: ['T1567', 'K0773', 'K1032'], + title: 'Send the Playback Command', + description: + 'In the TT&C console send REC-PLAYBACK and wait for the acknowledgement. The window closes at 14:10:00 - outside it the spacecraft is over the horizon and the command is rejected.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['confirm-window-open'], + conditions: [ + { + type: 'command-acknowledged', + description: 'REC-PLAYBACK Acknowledged', + params: { commandId: 'REC-PLAYBACK' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'confirm-execution', + nice: ['T1567', 'K1032'], + title: 'Confirm Execution', + description: 'ACK in the log. Read it properly: what the acknowledgement does and does not tell you, while you still have the bird.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['send-the-command'], + conditions: [ + { + type: 'signal-detected', + description: 'Still on the Bird', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'ACK Read', + params: { + character: Character.SYSTEM, + question: 'REC-PLAYBACK shows ACK received. What does that prove?', + options: [ + 'The spacecraft received and executed it - it answered on frequency, in the window, on the next frame', + 'The command left the antenna - the HPA saw drive, the BUC was unmuted, and the carrier went out', + 'The recorder is now empty - the playback ran, the buffer cleared, and the frame confirms it', + 'The HPA reached full power - the drive was there, the back-off held, and the carrier was heard', + ], + correctIndex: 0, + explanation: + "An ACK is the spacecraft talking back: right frequency, right window, command accepted. What the recorder does afterwards is Rotterdam's telemetry to read, not yours.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 4: TX CHAIN DOWN IN ORDER, THEN LOG + // ============================================================ + { + id: 'disable-hpa', + nice: ['S0421', 'K0645'], + title: 'HPA Down First', + description: 'Command acknowledged. Secure the chain in the mirror order: disable the HPA output while the BUC is still driving it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['confirm-execution'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Shutdown Order Confirmed', + params: { + character: Character.SYSTEM, + question: 'Securing the uplink after the pass: what comes down first?', + options: ['The HPA. Kill the amplifier before you remove its drive.', 'The modem. Stop the carrier first, then the amplifier.', 'Either - the interlocks handle it.'], + correctIndex: 0, + explanation: + 'Correct, and it is the mirror of bringing it up: drive before amplifier on the way up, amplifier before drive on the way down. An HPA is never left running on noise.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'mute-buc', + nice: ['S0421', 'K0645'], + title: 'Silence the BUC', + description: 'Amplifier off. Now mute the BUC.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['disable-hpa'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'buc-muted', + description: 'BUC Muted', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'carrier-down', + nice: ['S0421', 'K0645'], + title: 'Carrier Down', + description: 'Last: take transmit modem 1 off the air. The chain is cold in the same order it went hot, reversed.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['mute-buc'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'log-the-window', + nice: ['T1580', 'K0645'], + title: 'Log the Window', + description: 'Close the command log for the pass: what was sent, what came back, and how the chain was left.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['carrier-down'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Command Log Entry', + params: { + character: Character.SYSTEM, + question: 'What goes in the command log for the 14:03 window?', + options: [ + 'REC-PLAYBACK sent in window with Doppler comp, ACK received; chain secured HPA-BUC-carrier, cold', + 'REC-PLAYBACK sent in window with Doppler comp, ACK received; chain left radiating for the next pass', + 'REC-PLAYBACK sent at 14:03:20, NAK out of window; chain secured HPA-BUC-carrier, cold', + 'REC-PLAYBACK sent in window, ACK not logged; chain secured carrier-BUC-HPA, cold', + ], + correctIndex: 0, + explanation: 'What went up, what came back, and that the amplifier is off. The next operator reads this before touching the chain.', + pointPenalty: 5, + documentSection: 'Uplinks', + documentLine: + '14:03Z window MERIDIAN-SAR-1: REC-PLAYBACK sent 1405 MHz IF / 14005 MHz with uplink Doppler compensation, ACK received. Chain secured HPA -> BUC mute -> carrier down; TX cold.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:44] +

+

+ "Accepted for receive as of yesterday's card. Today the station earns the other half of its licence. Rotterdam has a command for SAR-1 on the 14:03 pass and their flight rules say it comes from a station with a demonstrated clean uplink. That is us, or it is nobody until the next window. One thing: the HPA never goes live without drive behind it. I have watched a site cook an amplifier that way and I would rather not watch it twice. I am in the RF room." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'tune-tx-modem': { + text: ` +

+ Crew left the transmit modem where they left the receive one - on their test carrier. The BUC is low-side, so no inversion on this path: IF plus LO. Get it to 1405 or the bird hears nothing at all. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'enable-doppler-comp': { + text: ` +

+ Doppler cuts both ways. The bird hears us shifted too, so the modem pre-compensates on the way up or the command ends up in the wrong bin. It is a toggle on the TT&C console. It is also the whole lesson. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'carrier-into-muted-buc': { + text: ` +

+ Three steps, in order, and you do each one on its own. Carrier first, into a muted BUC. Nothing goes out of the feed yet. That is the point. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'unmute-buc': { + text: ` +

+ Now the BUC. Unmute it with the carrier behind it. The amplifier is still off - look at the switch, not the badge, before you go on. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'enable-hpa': { + text: ` +

+ Amplifier last. Ten dB of back-off is where it lives on routine work; if the meter says overdriven you have done something to the BUC gain that you should not have. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'confirm-window-open': { + text: ` +

+ [Text message from Anneke Visser, MERIDIAN Ops, at 14:02] +

+

+ "Galway, Rotterdam. Recorder is at seventy-one percent. Window for SAR-1 opens 14:03:40 on your side; anything earlier comes back NAK and that is the spacecraft, not you. Send when it reads open." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'send-the-command': { + text: ` +

+ "Window open. REC-PLAYBACK when ready. I am watching the telemetry frame." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'confirm-execution': { + text: ` +

+ "ACK on the next frame, recorder is spinning. That is a clean uplink, Galway. Flight rules satisfied; you are on the commanding roster from today." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'disable-hpa': { + text: ` +

+ Done. Now down, in the mirror. Amplifier first. Never, ever, the carrier first. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'carrier-down': { + text: ` +

+ BUC muted, carrier off. Chain is cold and it is cold in the right order. That is the whole of transmit discipline and most people take a year to learn it. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'log-the-window': { + text: ` +

+ Log it. Whoever has the chain next reads that line before they touch a switch, so write what they need: what went up, what came back, and that the amplifier is off. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario4.ts b/src/campaigns/nats-eu/scenario4.ts new file mode 100644 index 00000000..53d30019 --- /dev/null +++ b/src/campaigns/nats-eu/scenario4.ts @@ -0,0 +1,816 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 4 - "Keys to the Bird" / Command-Link COMSEC + * + * New mechanic: M5 command-link authentication and key operations. Scenario 3 + * proved GW-01 can reach the spacecraft; this one adds the requirement that the + * spacecraft can prove the command came from NATS. `requireValidKey` is on, so + * a command sent on a key that is mid-rotation is rejected - the operator has to + * complete the scheduled rotation before the window closes. + * + * Two keys live on this station and the scenario teaches both: + * - the command-link authentication key on the TT&C console (CommandingManager: + * Valid / Pending Rotation / Zeroized, rotated in two steps), and + * - the traffic key on the payload crypto (CryptoModule: encrypts the uplink, + * decrypts the downlink; Valid / Mismatch / Expired, re-keyed from the TX + * payload panel). + * + * Phase 16 rewrite: the full uplink shift from scenario 3 with COMSEC on top, + * then a staged traffic-key mismatch after the pass (hardwareFaultEvents, + * `crypto-key-mismatch`, at 14:11): Rotterdam rotated the traffic key on + * schedule, the far end changed, decrypt and auth-tag verification fail and the + * key reads Mismatch. The operator re-keys and proves the traffic key on the + * optional SAR-2 pass. + * + * Clock: sim starts 2027-03-15 13:45:00 UTC. MERIDIAN-SAR-1: AOS 14:03:10, + * max el 28.0 at 14:06:45, LOS 14:10:18; command window 14:03:40 .. 14:10:00 = + * windowStartS 1120 .. windowEndS 1500. MERIDIAN-SAR-2: AOS 14:18:42, max el + * 25.0 at 14:22:10, LOS 14:25:40 (C/N >= 8 dB 14:20:45 .. 14:23:45). + * + * The console's zeroize control is deliberately arm-then-fire and is NOT part of + * any objective here: emergency key destruction is scenario 21's lesson, and an + * accidental zeroize during a routine rotation should feel like the serious + * mistake it is. + * + * Staged state (scenario-local clone of GW-01): BUC muted, as scenario 3 left it. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0874: Knowledge of cryptographic key management concepts + * - K0875: Knowledge of cryptology + * - K0728: Knowledge of cryptographic key storage and handling + * + * Supporting Codes: + * - S0077: Skill in securing network communications + * - T1567: Conduct satellite command and control operations + * - K0645: Knowledge of standard operating procedures + * - S0421: Skill in operating network equipment + * - T0431: Check system hardware availability, functionality, integrity + * - T1580: Report service status to stakeholders + */ + +/** GW-01 as scenario 3 left it: chain cold, BUC muted. Deep clone, never spread. */ +const galwayComsec: GroundStationConfig = structuredClone(galwayGroundStation); +galwayComsec.rfFrontEnds[0].buc = { ...galwayComsec.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario4Data: ScenarioData = { + id: 'nats-eu-scenario4', + url: 'nats-eu/scenarios/nats-eu-scenario4', + imageUrl: 'nats/4/card.png', + number: 4, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario3'], + title: 'Keys to the Bird', + subtitle: 'Command-Link COMSEC', + duration: '35-40 min', + missionType: 'Commanding', + description: `A command link that anyone can use is not a command link, it is a liability. MERIDIAN commands carry an authentication tag; the spacecraft rejects anything it cannot verify.

Today's rotation was scheduled weeks ago and it falls, as these things do, in the middle of your only pass. The old command key is already marked for retirement and the new material is loaded but not yet active. Until you complete the rotation, the bird will not authenticate a thing you send. Anneke needs a payload safe-mode command before the bird goes into its next imaging block. You have one window.

And there is a second key on this station, the traffic key that encrypts what you send and decrypts what comes down. Rotterdam rotates that one too. Keep an eye on it after the pass.`, + equipment: ['4m Ku-Band LEO Tracking Antenna', 'Ku-Band BUC + HPA', 'TT&C Commanding Console (COMSEC)', 'Payload Crypto (AES-256-GCM)', 'QPSK 3/4 Transmit Modem'], + settings: { + isSync: true, + groundStations: [galwayComsec], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:45:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-4?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'GW-01 COMSEC Log', + description: 'Key events on the station: rotations, re-keys, mismatches, and the command traffic authenticated under each key.', + }, + + // M5 - COMSEC required. A command sent while the key is Pending Rotation is + // rejected, so the rotation must complete inside the pass window. + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61701, + windowStartS: 1120, + windowEndS: 1500, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'OBC-WDT-RESET', label: 'Reset OBC watchdog' }, + ], + }, + + // Phase 16 E3: Rotterdam rotates the TRAFFIC key on schedule at 14:11, just + // after LOS. The far end changes; the station's payload crypto reads + // Mismatch (decrypt fails, auth tag fails) until the operator re-keys. + hardwareFaultEvents: [{ id: 'traffic-key-rotation', groundStationId: 'GW-01', target: 'crypto-key-mismatch', startTime: 1560 }], + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the COMSEC Brief', + description: 'Open the shift brief and confirm the rotation schedule and the command to be sent.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'COMSEC Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Rotation Risk Understood', + params: { + character: Character.SYSTEM, + question: 'The command key is marked Pending Rotation when the pass starts. What happens if you send PLD-SAFE before completing the rotation?', + options: [ + 'The spacecraft cannot authenticate it and rejects it - no second window before the imaging block.', + 'It is accepted - the rotation only affects future commands, and the imaging block goes ahead as tasked.', + 'It is queued on the spacecraft until the rotation completes - then it executes before the imaging block.', + ], + correctIndex: 0, + explanation: 'Correct. A key mid-rotation is not a key. Complete the rotation first, then send. Eighteen minutes to the window.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: PRE-PASS - KEYS, REFERENCE, WINDOW, CHAIN UP + // ============================================================ + { + id: 'crypto-status-check', + nice: ['K0728', 'K0874'], + title: 'Read Both Keys', + description: + 'Two keys on this station. On the TX chain and RX analysis payload panels confirm the traffic crypto is ACTIVE both ways with the traffic key Valid; then read the command key on the TT&C console.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-crypto-status', + description: 'TX Encryption ACTIVE', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'tx-key-status', + description: 'TX Traffic Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-crypto-status', + description: 'RX Decryption ACTIVE', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Traffic Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Which Key Rotates', + params: { + character: Character.SYSTEM, + question: "Traffic key Valid with 62 days left; command key Pending Rotation. Which one is today's rotation, and what does it gate?", + options: [ + 'The command key on the TT&C console - it gates whether the spacecraft accepts PLD-SAFE', + 'The traffic key on the payload panels - it gates whether the modem can transmit at all', + 'Both, since they are the same key material - it gates transmit and command acceptance alike', + 'Neither, the rotation is on the spacecraft - it gates nothing until the far end confirms', + ], + correctIndex: 0, + explanation: + 'The command key signs each command so the bird can prove who sent it. The traffic key encrypts the payload data both ways. Different custody, different schedules, and today only one of them is in rotation.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'complete-key-rotation', + nice: ['K0874', 'K0728', 'S0077'], + title: 'Complete the Scheduled Key Rotation', + description: 'On the TT&C console: Initiate Key Rotation, then Confirm Key Rotation. Do it before the pass, not during it. Leave the zeroize control alone.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['crypto-status-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'TT&C Console Open', + params: { tab: 'commanding' }, + mustMaintain: false, + }, + { + type: 'key-rotation-completed', + description: 'Command Key Rotation Completed', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Two Steps', + params: { + character: Character.SYSTEM, + question: 'Initiate, then Confirm. Why is a rotation two steps and not one switch?', + options: [ + 'The far end must acknowledge the new key before the old one retires - one step could mismatch the two ends', + 'The console needs time to generate the key - one step would retire the old key before the new one exists', + 'Regulations require two button presses - one step would let a single operator rotate without a witness', + 'So the operator can zeroize between them - one step would carry the old key material into the new one', + ], + correctIndex: 0, + explanation: + 'Rotation is a handshake, not a swap. Both ends move together or one of them stops understanding the other: a one-step swap can leave the two ends on different keys, which is a mismatch. Keep that in mind after the pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference and BUC Lock', + description: 'GPSDO locked, BUC on the external reference. Same as last pass; it does not stop mattering.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['complete-key-rotation'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'review-pass-schedule', + nice: ['K1032', 'T1567'], + title: 'Confirm the Command Window', + description: 'Read the pass off the Pass Schedule tab and confirm when the window is open.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Window Read', + params: { + character: Character.SYSTEM, + question: 'When can PLD-SAFE be sent, and what must be true of the command key by then?', + options: [ + '14:03:40 to 14:10:00, with the command key reading Valid', + '14:03:10 to 14:10:18, with the command key in any state', + 'Any time before the imaging block, with the command key reading Valid', + '14:06:45 only, with the command key reading Pending Rotation', + ], + correctIndex: 0, + explanation: 'Same window as last pass. This time the console also checks the key: Pending Rotation is a NAK, key invalid.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'enable-doppler-comp', + nice: ['T1567', 'K0773'], + title: 'Enable Uplink Doppler Compensation', + description: 'Engage uplink Doppler compensation on the TT&C console, exactly as last pass. New lessons do not retire old ones.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-pass-schedule'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'carrier-into-muted-buc', + nice: ['S0421', 'K0645'], + title: 'Carrier Up, BUC Muted', + description: 'Chain up in order. Step one: transmit modem 1 on the air into the muted BUC.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['enable-doppler-comp'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Still Muted', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'unmute-buc', + nice: ['S0421', 'K0645'], + title: 'Unmute the BUC', + description: 'Step two: unmute the BUC with the carrier behind it. HPA still disabled.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['carrier-into-muted-buc'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'HPA Still Disabled', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'enable-hpa', + nice: ['S0421', 'K0740'], + title: 'Enable the HPA', + description: 'Step three: HPA output enabled, within limits.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['unmute-buc'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'hpa-not-overdriven', + description: 'HPA Within Operating Limits', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: THE PASS + // ============================================================ + { + id: 'track-and-acquire', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Program-track MERIDIAN-SAR-1 before 14:03 and confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['enable-hpa'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'send-authenticated-command', + nice: ['T1567', 'K0875', 'S0077'], + title: 'Send the Authenticated Command', + description: 'In the window, send PLD-SAFE and confirm the acknowledgement. The spacecraft verifies the authentication tag under the new key before it answers.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['track-and-acquire'], + conditions: [ + { + type: 'command-acknowledged', + description: 'PLD-SAFE Acknowledged', + params: { commandId: 'PLD-SAFE' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + + // ============================================================ + // BEAT 4: CHAIN DOWN, THEN THE TRAFFIC KEY + // ============================================================ + { + id: 'secure-the-chain', + nice: ['S0421', 'K0645'], + title: 'Secure the Chain', + description: 'Command acknowledged. Bring the chain down in the mirror order: HPA off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['send-authenticated-command'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Order Confirmed', + params: { + character: Character.SYSTEM, + question: 'Three switches, one order. Which goes first on the way down?', + options: [ + 'HPA output off, while the BUC is still driving it', + 'Transmit modem off, while the BUC is still unmuted', + 'BUC mute, while the HPA is still enabled behind it', + 'Any of them, because the chain is interlocked', + ], + correctIndex: 0, + explanation: 'Amplifier before drive on the way down. Same as last pass, and the pass after that.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'traffic-key-mismatch', + nice: ['K0728', 'K0875'], + title: 'Read the Traffic Key Alarm', + description: + 'Shortly after LOS the RX payload panel starts reporting decrypt failures and an unverified auth tag, and the traffic key reads Mismatch. Read it before you touch anything.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['secure-the-chain'], + conditions: [ + { + type: 'rx-key-status', + description: 'RX Traffic Key Reads Mismatch', + params: { keyStatus: 'Mismatch', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Mismatch Diagnosed', + params: { + character: Character.SYSTEM, + question: 'Decrypt failing, auth tag unverified, traffic key Mismatch, and the command key still reads Valid. What happened?', + options: [ + 'Rotterdam rotated the traffic key; the station is still on the old key - re-key the payload crypto', + 'Your command-key rotation corrupted the traffic key; both keys are now suspect - zeroize and reload both', + 'The spacecraft was zeroized; nothing on it can decrypt any more - wait for Rotterdam to reload it', + 'The LNB LO drifted; the demodulator is losing frames and the crypto sees garbage - retune the receiver', + ], + correctIndex: 0, + explanation: + 'Two keys, two schedules. The one you rotated is fine. The traffic key moved at the far end and the station did not follow: a mismatch is the two ends of a handshake on different keys.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 're-key-traffic', + nice: ['K0728', 'S0077', 'K0874'], + title: 'Re-key the Payload Crypto', + description: 'Load the new traffic key on the TX chain payload panel and confirm both directions read Valid again.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['traffic-key-mismatch'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-key-status', + description: 'TX Traffic Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Traffic Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Re-key Understood', + params: { + character: Character.SYSTEM, + question: 'One key load cleared the RX mismatch and the TX key together. Why?', + options: [ + 'The traffic key is symmetric - one key material serves both encrypt and decrypt', + 'The RX side copies whatever the TX side does - the load propagates from the transmit panel', + 'Re-keying resets the spacecraft - both directions come back up on the default key', + 'It did not - the RX key needs a separate load, and the panel is only echoing the TX state', + ], + correctIndex: 0, + explanation: + 'AES-256-GCM is symmetric. One key, both directions, so the station is either on the current key or it is not, and the auth tag verifies again as soon as both ends share it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'prove-the-traffic-key', + nice: ['K0875', 'S0421'], + title: 'Prove the Traffic Key on SAR-2', + description: + 'MERIDIAN-SAR-2 rises at 14:18:42 from azimuth 130. Retune modem 1 to 1370 MHz, track it, and show the downlink decrypts under the new key. Optional: the COMSEC log is complete without it.', + groundStation: 'GW-01', + isOptional: true, + prerequisiteObjectiveIds: ['re-key-traffic'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'rx-crypto-status', + description: 'Decrypting', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'log-the-rotation', + nice: ['K0874', 'T1580'], + title: 'Log the Rotation', + description: 'Close out the COMSEC record for the shift: both keys, both events.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['re-key-traffic'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'COMSEC Record Closed', + params: { + character: Character.SYSTEM, + question: 'Rotation complete, command authenticated, traffic key re-keyed. Why is a key rotation scheduled at all, rather than left alone while it is working?', + options: [ + 'A key in use accumulates exposure - rotating on a schedule limits what any one compromise can reach.', + 'Keys wear out cryptographically - after a fixed number of uses the cipher can be broken by inspection.', + 'The spacecraft requires a new key every pass - it discards the old one at LOS and will not reuse it.', + ], + correctIndex: 0, + explanation: + 'Exactly. Exposure is traffic, time, and the people who have seen the key. Scheduled rotation is hygiene, not a response to a known problem - and today showed the cost of one end rotating without the other.', + pointPenalty: 5, + documentSection: 'Keys', + documentLine: + '14:03Z: command-link key rotation completed on the TT&C console before the window; PLD-SAFE authenticated under the new key, ACK received. 14:11Z: traffic key Mismatch after far-end rotation; payload crypto re-keyed, TX and RX Valid, auth tag verified. Chain cold.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:44] +

+

+ "Clean uplink yesterday, so Rotterdam has given us a real one today: safe-mode the payload before the next imaging block. There is a complication, which is that the scheduled key rotation was booked weeks ago and has, naturally, arrived in the middle of your only pass. The bird authenticates every command. A key that is mid-rotation is not a key. Finish the rotation before the pass, then send. And leave the zeroize switch exactly where it is." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'crypto-status-check': { + text: ` +

+ Two keys on this rack and people confuse them constantly. Command key on the TT&C console signs what you send. Traffic key on the payload panels encrypts what goes up and decrypts what comes down. Read both before you touch either. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'complete-key-rotation': { + text: ` +

+ [Text message from Anneke Visser, MERIDIAN Ops, at 13:50] +

+

+ "Galway, our records show your command key in its rotation window today. Please confirm the rotation is complete and the key reads Valid before you send - an unauthenticated PLD-SAFE will be rejected and we will not get a second window before the imaging block." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'carrier-into-muted-buc': { + text: ` +

+ Same three steps as yesterday. I am not going to say them again; you know them. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'send-authenticated-command': { + text: ` +

+ "Key reads Valid on our side too. Window open - PLD-SAFE when ready." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'secure-the-chain': { + text: ` +

+ Authenticated and acknowledged. Bring it down. Amplifier first. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'traffic-key-mismatch': { + text: ` +

+ [Text message from Anneke Visser at 14:12] +

+

+ "Galway - heads up, the fleet traffic key rolled at 14:11 on our schedule. If your payload crypto is complaining, that is why. New material should already be on your console." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 're-key-traffic': { + text: ` +

+ There it is: one end of the handshake moved and the other did not. Load the new key on the payload panel. Watch both directions clear at once - it is one key. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'prove-the-traffic-key': { + text: ` +

+ "SAR-2 is up for you at 14:18 if you want to prove the new traffic key on live frames. Not required. Appreciated." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'log-the-rotation': { + text: ` +

+ Log both. Rotation and re-key, with times. Whoever audits this station's COMSEC in six months will read that line before they read anything else. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario5.ts b/src/campaigns/nats-eu/scenario5.ts new file mode 100644 index 00000000..311c2f17 --- /dev/null +++ b/src/campaigns/nats-eu/scenario5.ts @@ -0,0 +1,927 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 5 - "Shetland Comes Online" / Two-Station Pass Network + * + * New mechanic: M3 multi-station contact scheduling (Contact Plan tab), and the + * debut of SH-02 Shetland with Fiona MacLeod. Phase 16 rewrite: the operator + * does not just allocate the plan, they work the network. Sweep Galway, build + * and validate the plan, then take the Shetland console remotely (phase 16 E1 + * per-station propagation: SH-02 tracks and hears the bird on Shetland's own + * horizon), sweep it, tune it, park it and fly MERIDIAN-SAR-1 from Shetland + * while Charlie flies the same bird from Galway. Hand back, retune Galway for + * SAR-2 and work that one from home while Fiona takes the 86 degree overhead. + * + * The scheduling puzzle is built from two conflicting pairs. Both sites can see + * each MERIDIAN pass, but a single antenna cannot work two overlapping windows, + * so the plan is only valid when each pair is split across the two stations. + * Every contact is priority <= 2 and `requiredPriorityAtOrAbove: 2`, so all four + * must be allocated before the plan validates. + * + * Clock: sim starts 2027-03-15 13:40:00 UTC (the brief freezes it until read). + * Passes at the 5 deg mask, as the Pass Schedule tab shows them: + * - SH-02 MERIDIAN-SAR-1: AOS 14:01:14 az 003, max el 23.8 at 14:04:42 (860 km), + * LOS 14:08:09 az 235. Measured C/N >= 8 dB 14:03:30 .. 14:06:05, peak 9.8 dB + * at culmination (test/campaigns/nats-eu-phase-b-validation.test.ts). + * - GW-01 MERIDIAN-SAR-1: AOS 14:03:10 az 000, max el 28.0 at 14:06:45 (761 km), + * LOS 14:10:18 az 224. Charlie's. + * - GW-01 MERIDIAN-SAR-2: AOS 14:18:42 az 130, max el 25.0 at 14:22:10 (828 km), + * LOS 14:25:40 az 000. C/N >= 8 dB roughly 14:20:30 .. 14:24:00. + * - SH-02 MERIDIAN-SAR-2: AOS 14:19:32 az 162, max el 86.1 at 14:23:23 (393 km), + * LOS 14:27:15 az 342. Fiona's; the keyhole at the top is a later lesson. + * + * Contact windows are the 0 deg horizon crossings rounded to 10 s, propagated + * per station (test/campaigns/nats-eu-contact-windows checks each against its + * stationId pass). + * + * Staged state (scenario-local clones): SH-02 as Fiona left it after her last + * acceptance run - tracker stowed south (az 180) against the wind, RX modem 1 + * on the 1370 MHz SAR-2 carrier, BUC muted. GW-01 is the shared default. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0689: Knowledge of network systems management principles and tools + * - T0129: Coordinate and manage system operations schedules + * - K0737: Knowledge of network operations planning + * + * Supporting Codes: + * - S0421: Skill in operating network equipment + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor network capacity and performance + * - T0431: Check system hardware availability, functionality, integrity + * - K0740: Knowledge of system performance indicators + * - K0741: Knowledge of system availability measures + * - K0773: Knowledge of telecommunications principles and practices + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + */ + +/** SH-02 as Fiona left it: stowed south, modem on SAR-2, BUC muted. Deep clone, never spread. */ +const shetlandDebut: GroundStationConfig = structuredClone(shetlandGroundStation); +shetlandDebut.antennasState![0] = { + ...shetlandDebut.antennasState![0], + azimuth: 180 as Degrees, + elevation: 3 as Degrees, + targetAzimuth: 180 as Degrees, + targetElevation: 3 as Degrees, +}; +shetlandDebut.receivers![0].modems![0] = { + ...shetlandDebut.receivers![0].modems![0], + frequency: 1370 as MHz, +}; +shetlandDebut.rfFrontEnds[0].buc = { ...shetlandDebut.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario5Data: ScenarioData = { + id: 'nats-eu-scenario5', + url: 'nats-eu/scenarios/nats-eu-scenario5', + imageUrl: 'nats/5/card.png', + number: 5, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario4'], + title: 'Shetland Comes Online', + subtitle: 'Two-Station Pass Network', + duration: '35-40 min', + missionType: 'Mission Planning', + description: `SH-02 Shetland went operational overnight. Fiona MacLeod has been running acceptance up there for three weeks in weather you would not believe, and as of this morning NATS Europe is a network rather than a station.

That changes your job. Galway is no longer the only place a MERIDIAN pass can be worked, which means somebody has to decide which site takes which contact - and that somebody is the operator holding the plan. Four contacts today across two birds. Both sites can see all four. One antenna cannot be in two places at once.

And then somebody has to fly what the plan says. Fiona is out at the pedestal for the first window, so the Shetland console is yours, from Galway, for the network's first remote-operated pass.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'Contact Plan Console', + 'Pass Schedule Planner', + 'RX Modems with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayGroundStation, shetlandDebut], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:40:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-5?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // A fast operator can skip the dead air between "ready" and AOS. The skip + // is blocked while a timed objective runs and stops 2 min before the pass. + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'NATS Europe Network Log', + description: 'First day as a network, 2027-03-15. One line per contact: time, bird, site, who flew it, result.', + }, + + // M3 - two conflicting pairs; the plan only validates when each pair is + // split across the two sites. Windows: 0 deg horizon crossings from the + // 13:40 epoch, per station. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 2, + contacts: [ + { id: 'SAR1-GW', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Galway, 28 deg pass)', priority: 1, windowStartS: 1320, windowEndS: 1890, stationId: 'GW-01' }, + { id: 'SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 24 deg pass)', priority: 1, windowStartS: 1200, windowEndS: 1760, stationId: 'SH-02' }, + { id: 'SAR2-GW', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Galway, 25 deg pass)', priority: 1, windowStartS: 2250, windowEndS: 2810, stationId: 'GW-01' }, + { id: 'SAR2-SH', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Shetland, 86 deg overhead pass)', priority: 2, windowStartS: 2310, windowEndS: 2900, stationId: 'SH-02' }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0737'], + title: 'Review the Network Brief', + description: "Open the shift brief and read the day's tasking. Four contacts, two sites, and the first window is Shetland's at 14:01.", + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Network Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Conflict Rule Understood', + params: { + character: Character.SYSTEM, + question: 'Two contacts have overlapping windows and both are visible from Galway. What makes that a conflict?', + options: [ + 'One antenna points at one satellite at a time, so overlapping windows on one site cannot both be worked.', + 'The two satellites would interfere on the downlink, so overlapping windows on one site cannot both decode.', + 'It is not a conflict, since the scheduler time-shares the antenna, so both windows on one site get worked.', + 'The two sites share one receiver, so only one contact can decode at a time whichever site is pointing.', + ], + correctIndex: 0, + explanation: 'The constraint is the pedestal, not the spectrum. Overlapping windows have to go to different sites. Twenty minutes to the first AOS.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: GALWAY SWEEP + // ============================================================ + { + id: 'galway-dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Clear the home board first: confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at turnover?', + options: [ + 'RX AGC at max gain (weak signal) - empty sky, not a fault; no hardware alarms', + 'No active alarms (all nominal) - board clear, sweep complete; nothing to carry over', + 'GPSDO in holdover (reference alarm) - timing at risk, not a fault; one hardware alarm', + 'Antenna drive fault (pedestal stowed) - tracker cannot slew; one hardware alarm', + ], + correctIndex: 0, + explanation: + 'The AGC rail is the board telling you the antenna is looking at empty sky, not that something broke; it clears on acquisition. Shetland is the one nobody has swept from this console yet.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'galway-reference-check', + nice: ['T0431', 'K0740'], + title: 'GW-01 Reference Check', + description: 'GPSDO locked and out of holdover. Charlie flies the Galway pass on this reference; confirm it before you hand him the station.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 5: THE CONTACT PLAN + // ============================================================ + { + id: 'allocate-galway', + nice: ['T0129', 'K0689', 'S0421'], + title: 'Allocate the Galway Contacts', + description: 'On the Contact Plan console, assign the two Galway-horizon contacts to GW-01. These are the passes this site has worked all week, and Galway keeps them.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-1 Galway Contact on GW-01', + params: { contactId: 'SAR1-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-2 Galway Contact on GW-01', + params: { contactId: 'SAR2-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'hand-shetland-the-overlaps', + nice: ['T0129', 'K0737'], + title: 'Hand Shetland the Overlaps', + description: 'The two remaining contacts overlap the ones Galway is already working. Give them to SH-02 - that is what a second site is for.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['allocate-galway'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'contact-assigned', + description: 'SAR-1 Shetland Contact on SH-02', + params: { contactId: 'SAR1-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-2 Shetland Contact on SH-02', + params: { contactId: 'SAR2-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Priority Rule Read', + params: { + character: Character.SYSTEM, + question: 'Three contacts are priority 1 and the Shetland SAR-2 overhead is priority 2. The plan requires priority 2 and above. What does that mean for the overhead?', + options: [ + 'It must be allocated - priority 2 is inside the required set, so the plan stays UNALLOCATED without it', + 'It can be left unassigned - only priority 1 is mandatory, so the plan validates with three contacts', + 'It goes to Galway automatically - the lower priority falls to the home site, so the plan fills itself', + 'It only affects list order - priority sets where the console lists a contact, not whether it is required', + ], + correctIndex: 0, + explanation: 'Required means required: every contact at or above the threshold has to have a site. A priority 3 contact could be dropped. There are none today.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'validate-the-plan', + nice: ['T0129', 'K0689'], + title: 'Validate the Contact Plan', + description: 'With every contact allocated and no site double-booked, the plan status should read DECONFLICTED. Confirm it before you publish it to Fiona.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['hand-shetland-the-overlaps'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'contact-plan-valid', + description: 'Plan Valid - No Conflicts, All Contacts Allocated', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Plan Published', + params: { + character: Character.SYSTEM, + question: 'The plan validates. What has a second site actually bought the network here?', + options: [ + 'Every contact gets worked instead of one of each overlapping pair being dropped.', + 'A stronger signal, because two antennas receive the same pass and the receivers combine.', + 'Nothing operationally - it is redundancy for the day one site is down for maintenance.', + 'Longer passes, because the two horizons are added together into one contact window.', + ], + correctIndex: 0, + explanation: 'Capacity, not just redundancy. Plan published to Shetland. Fiona is at the pedestal, so the first Shetland window is yours to fly from here.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 5 -> BEAT 1: THE SHETLAND CONSOLE + // ============================================================ + { + id: 'shetland-sweep', + nice: ['T0431', 'S0421', 'K0741'], + title: 'Shetland Health Check', + description: + 'Select SH-02 in the asset tree and run the sweep from here: GPSDO locked and out of holdover, BUC temperature normal, LNB thermally stable. Same rack, seven degrees further north.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['validate-the-plan'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'gpsdo-locked', + description: 'SH-02 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'SH-02 GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'buc-temperature-normal', + description: 'SH-02 BUC Temperature Normal', + params: { maxTemperature: 70, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'lnb-thermally-stable', + description: 'SH-02 LNB Thermally Stable', + params: { requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'shetland-geometry', + nice: ['K1032', 'K0773'], + title: "Read Shetland's Sky", + description: 'With SH-02 selected the Pass Schedule tab predicts from Shetland. Read the SAR-1 pass as Shetland sees it and compare it with the Galway numbers you know.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Geometry Read', + params: { + character: Character.SYSTEM, + question: + 'SAR-1 rises over Shetland at 14:01 and peaks at 24 degrees; over Galway it rises at 14:03 and peaks at 28. Same bird, same orbit. Why does Shetland see it first and lower?', + options: [ + 'Southbound track west of Ireland: Shetland is further north, so sees it first, and further off-track, so lower', + 'Elevation mask: Shetland runs a lower mask than Galway, so it sees the bird earlier, and its peak reads lower too', + 'Element sets: the two sites are propagating different TLEs, so the times differ, and the peaks differ with them', + 'Site altitude: Shetland sits higher, so its horizon is extended, and the bird rises earlier and appears lower', + ], + correctIndex: 0, + explanation: + 'Where you stand decides the pass. The ground track runs south, west of Ireland: Shetland is further north, so the bird crosses its horizon first, and further east of the track, so it never climbs as high. A sun-synchronous bird at 97 degrees inclination runs nearly pole to pole, so a site at 60 north sees more of its orbits than one at 53: more passes per day, and different ones. The SAR-2 pass at 14:19 goes 86 degrees overhead here and 25 over Galway.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'shetland-rx-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Shetland Receiver', + description: + 'Fiona left modem 1 on the 1370 MHz SAR-2 carrier from her last acceptance run. Retune it to 1414 MHz for SAR-1 and put the analyzer on the 1389 MHz beacon with a 2 MHz span.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-geometry'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'SH-02 Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'SH-02 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'shetland-preposition', + nice: ['S0421', 'K1032'], + title: 'Pre-position the Shetland Tracker', + description: + 'The SH-02 tracker is stowed south against the wind. Slew it to the rise azimuth, 003 at 5 degrees, so the pedestal is waiting when SAR-1 clears the mask at 14:01.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-rx-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'SH-02 Parked on Az 003 / El 5', + params: { azimuth: 3, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: MERIDIAN-SAR-1 FROM SHETLAND + // ============================================================ + { + id: 'acquire-from-shetland', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1 from Shetland', + description: 'Program-track SAR-1 on the SH-02 pedestal and confirm the beacon on Shetland’s RX analysis. The network’s first remote-operated pass.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-preposition'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'SAR-1 Beacon on the Shetland Analyzer', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-from-shetland', + nice: ['T0153', 'K0740'], + title: 'Decode from Shetland', + description: 'Lock the 1414 MHz imagery carrier on the SH-02 modem and hold C/N above 8 dB through the high-elevation segment, roughly 14:03:30 to 14:06.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['acquire-from-shetland'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'read-shetland-peak', + nice: ['K0740', 'K0773'], + title: 'Read the Shetland Peak', + description: + 'Around 14:04:42 the bird is at 24 degrees and 860 km from Shetland. Confirm C/N of 9 dB or better at the top of the pass and compare it with what Galway gets from the same bird.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['decode-from-shetland'], + conditions: [ + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N >= 9 dB at Culmination', + params: { modemNumber: 1, minCNRatio: 9, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Peak Compared', + params: { + character: Character.SYSTEM, + question: 'Shetland peaks at about 9.8 dB at 24 degrees; Galway peaks at about 11 dB at 28 degrees on the same pass. Where does the difference come from?', + options: [ + 'Slant range: 860 km against 761 km is about 1 dB more path loss, and lower elevation adds a little atmosphere', + 'LNB temperature: the Shetland LNB runs warmer, so its noise figure is worse, and Tsys rises with it', + 'Latitude: Shetland is further north, so further from a satellite over Ireland, and that costs about 1 dB', + 'Remote link: the console link from Galway adds about a decibel of loss, and the display rounds the rest away', + ], + correctIndex: 0, + explanation: + 'Same hardware, same bird, different geometry. 20 log(860 / 761) is 1.1 dB. Every dB in the difference between two sites should be explained by range and elevation; one that is not is a fault.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 4 -> BEAT 5: HAND BACK, RETUNE GALWAY + // ============================================================ + { + id: 'hand-back-to-galway', + nice: ['S0421', 'K0773', 'T0129'], + title: 'Hand Back and Retune Galway', + description: + 'Shetland sets at 14:08 and Fiona is back on her console. Select GW-01, retune modem 1 to the 1370 MHz SAR-2 carrier, put the analyzer on its 1397 MHz beacon and park the tracker on the rise azimuth, 130 at 5 degrees. SAR-2 clears the mask at 14:18.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-shetland-peak'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1397 MHz', + params: { centerFrequency: 1397e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-position', + description: 'GW-01 Parked on Az 130 / El 5', + params: { azimuth: 130, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: MERIDIAN-SAR-2 FROM GALWAY + // ============================================================ + { + id: 'acquire-sar2-galway', + nice: ['S0421', 'K1032', 'T0153'], + title: 'Acquire MERIDIAN-SAR-2', + description: + 'Program-track SAR-2 on the GW-01 pedestal and confirm its imagery carrier on RX analysis once it clears the mask. Fiona is taking the same bird overhead at Shetland.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['hand-back-to-galway'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink Detected', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar2-galway', + nice: ['T0153', 'K0740', 'K1032'], + title: 'Decode the Galway Contact', + description: 'Lock the 1370 MHz carrier and hold C/N above 8 dB through the high-elevation segment. While it holds, think about what Fiona is seeing at 86 degrees.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2-galway'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Overhead Compared', + params: { + character: Character.SYSTEM, + question: + 'SAR-2 culminates at 25 degrees and 828 km over Galway, and at 86 degrees and 393 km over Shetland. Ignoring the pedestal, whose C/N should be higher at the top, and by how much?', + options: [ + "Shetland's, by about 6.5 dB - 20 log(828 / 393) of path loss, and almost no atmosphere straight up", + "Galway's, by about 1 dB - a lower pass (25 degrees) keeps the bird in the beam longer, so the AGC settles", + "The same (within 1 dB) - C/N depends on the bird's EIRP, not the site, and both run the same LNB", + "Shetland's, by about 1 dB - 20 log(860 / 761) again, the same SAR-1 difference and the same atmosphere", + ], + correctIndex: 0, + explanation: + 'Range wins: an overhead pass is the strongest signal a site ever gets. "Ignoring the pedestal" is doing a lot of work in that question, and Fiona will tell you why when she rings.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4: NETWORK LOG + // ============================================================ + { + id: 'log-the-network', + nice: ['T1580', 'K0645', 'K0689'], + title: 'Log the First Network Day', + description: 'Four contacts on the plan, four worked: two by you, one by Charlie, one by Fiona. Write the day into the network log the way the next planner needs it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2-galway'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Network Log Entry', + params: { + character: Character.SYSTEM, + question: 'What does the log need to say about the SAR-1 pair, beyond "worked"?', + options: [ + 'Site and peak per contact: SH-02 remote, 9.8 dB at 24 degrees; GW-01, 11 dB at 28 degrees - both decoded', + 'That the plan validated: SAR1-SH on SH-02, SAR1-GW on GW-01; plan DECONFLICTED - both allocated', + 'Which pass was better: GW-01, 11 dB at 28 degrees; SH-02, 9.8 dB at 24 degrees - Shetland not needed', + 'The NORAD ids only: 61701 on SH-02, 61701 on GW-01; measurements go to Rotterdam - both worked', + ], + correctIndex: 0, + explanation: + 'Which site flew each contact and what it measured: site, operator, number. The first remote pass is a baseline for every remote pass after it. Day one as a network: nothing dropped.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '14:01Z MERIDIAN-SAR-1 SH-02 (remote from GW-01): decoded, peak C/N 9.8 dB at 24 deg. 14:03Z MERIDIAN-SAR-1 GW-01 (C. Brooks): decoded, peak 11 dB at 28 deg. 14:18Z MERIDIAN-SAR-2 GW-01: decoded, C/N >= 8 dB. 14:19Z MERIDIAN-SAR-2 SH-02 (F. MacLeod): 86 deg overhead, worked. Plan DECONFLICTED, nothing dropped.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:39] +

+

+ "Shetland signed its acceptance card overnight, which makes us a network as of this morning and makes somebody responsible for deciding which site takes which pass. That is the console you are sitting at. Four contacts, two birds, both sites can see all four. Build the plan, and then - Fiona is out at the pedestal for the first window - fly Shetland's from here. I will take the Galway one myself; it is my station too." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'allocate-galway': { + text: ` +

+ Galway keeps the passes it has been working all week. The question the plan exists to answer is what you do with the other two. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'hand-shetland-the-overlaps': { + text: ` +

+ [Text message from Fiona MacLeod at 13:46] +

+

+ "Morning Galway. Wind's forty knots gusting fifty-five and the dish is rated for worse, so I'm not worried and neither should you be. Send me the plan when it validates. If you give me a pass that overlaps one of yours I'll assume it's deliberate and then I'll ring you." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'validate-the-plan': { + text: ` +

+ "Plan received, and it's the one I'd have built. I'm going out to the pedestal for the first one - the feed heater wiring wants finishing before the weather gets its opinion in. The console's yours until I'm back. Don't be gentle with it, it's a Galway rack with a Shetland view." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'shetland-sweep': { + text: ` +

+ "Reference's locked, BUC's cold, LNB's been stable since Tuesday. You'll see the same numbers I see. The only thing you can't check from there is whether the dish is still attached, and I'm standing next to it, so." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'shetland-geometry': { + text: ` +

+ Look at the schedule with Shetland selected. Same bird, and it is a different pass: earlier, lower, and it will not be the last time the two sites disagree about the sky. That is the whole reason there are two of them. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'shetland-preposition': { + text: ` +

+ "I left the dish stowed south, pointing at the hill, because that's where the wind isn't. Bring it round to the north before the bird's up. Nine seconds, but you don't want to be doing it at AOS." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'acquire-from-shetland': { + text: ` +

+ "There it is on my analyzer, so it's on yours. First pass this site has ever flown and the operator's three hundred miles away. Rotterdam will want that in a press release." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'decode-from-shetland': { + text: ` +

+ I have it on Galway's dish at the same time, two minutes behind you. Two sites, one bird, both decoding. Hold yours above eight through the top; mine will do the same, a little later and a little higher. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'read-shetland-peak': { + text: ` +

+ "Nine point eight. Charlie'll get eleven and he'll mention it. Same bird, hundred kilometres further from us. Write both down; one day one of them will be wrong and you'll want to know which." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'hand-back-to-galway': { + text: ` +

+ Fiona is back on her console. Give her the station and take Galway for SAR-2. Different carrier, same arithmetic, ten minutes. She has the overhead; you have the sensible one. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'acquire-sar2-galway': { + text: ` +

+ "Mine's going straight over the top. Eighty-six degrees. I'll lose it for a minute at the top - the pedestal can't turn round fast enough - so tell your customer the middle third is what he gets. Yours is the one that'll decode clean, twenty-five degrees is a proper pass." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'log-the-network': { + text: ` +

+ Four for four on the network's first day, one of them from a console the operator was not sitting at. Log it properly: site, who flew it, the number. Then ring Fiona and tell her eleven point two. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario6.ts b/src/campaigns/nats-eu/scenario6.ts new file mode 100644 index 00000000..a319099d --- /dev/null +++ b/src/campaigns/nats-eu/scenario6.ts @@ -0,0 +1,885 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 6 - "Watch the Watchers" / Station Security Baseline + * + * New mechanic: M6 SOC-lite security console. Deliberately NOT a SOC: the + * console does log review, event disposition, and account state. No IDS, no + * packet capture, no firewall - a ground-station operator's security hygiene, + * which is what the NICE work roles this scenario claims actually describe. + * + * Phase 16 rewrite: the baseline is done around a routine shift, not instead + * of one. The operator sweeps the station, sets up the receiver, reads the + * audit trail end to end and dispositions two warning-severity entries that + * turn out to be benign, works the SAR-1 downlink, then after LOS flags the + * one entry that is a finding, closes the contractor account, expires a + * lapsed relief account, retunes for SAR-2, decodes it, and files the return. + * + * The audit log mixes routine traffic with three warning-severity entries: + * Fiona's first remote login from the new SH-02 address (benign), a scheduled + * poll-interval change by the monitoring service (benign, change ticket), and + * off-hours authentication failures against a maintenance contractor account + * left active after the install window closed (the finding). Nothing was + * breached. The lesson is that "nothing happened" is a conclusion you reach by + * looking, that severity is a hint and not a verdict, and that a dormant + * over-privileged account is a finding whether or not it was used. + * + * The 'evt-replay' critical event is intentionally NOT in this scenario - the + * replayed-command thread belongs to the Phase 3 adversary arc (S17, S21), and + * pulling it forward would spend it. + * + * Clock: sim starts 2027-03-15 13:45:00 UTC. MERIDIAN-SAR-1: AOS 14:03:10 az + * 000, max el 28.0 at 14:06:45 (761 km), LOS 14:10:18; C/N >= 8 dB roughly + * 14:05:00 .. 14:08:30. MERIDIAN-SAR-2: AOS 14:18:42 az 130, max el 25.0 at + * 14:22:10, LOS 14:25:40; C/N >= 8 dB roughly 14:20:30 .. 14:24:00. + * + * Staged state (scenario-local clone of GW-01): RX modem 1 left on the + * 1370 MHz SAR-2 carrier from the morning's test, as the audit log records. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0685: Knowledge of system administration concepts + * - K0686: Knowledge of account management + * - T1569: Perform account management + * + * Supporting Codes: + * - S0844: Skill in auditing system activity + * - K0645: Knowledge of standard operating procedures + * - S0421: Skill in operating network equipment + * - K1032: Knowledge of satellite-based communication systems + * - T0153: Monitor network capacity and performance + * - T0431: Check system hardware availability, functionality, integrity + * - K0740: Knowledge of system performance indicators + * - K0741: Knowledge of system availability measures + * - K0773: Knowledge of telecommunications principles and practices + * - T1580: Report service status to stakeholders + */ + +/** GW-01 as the morning left it: modem 1 on the SAR-2 test carrier. Deep clone, never spread. */ +const galwayBaseline: GroundStationConfig = structuredClone(galwayGroundStation); +galwayBaseline.receivers![0].modems![0] = { + ...galwayBaseline.receivers![0].modems![0], + frequency: 1370 as MHz, +}; + +export const natsEuScenario6Data: ScenarioData = { + id: 'nats-eu-scenario6', + url: 'nats-eu/scenarios/nats-eu-scenario6', + imageUrl: 'nats/6/card.png', + number: 6, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario5'], + title: 'Watch the Watchers', + subtitle: 'Station Security Baseline', + duration: '35-40 min', + missionType: 'Security Operations', + description: `Quiet shift. Two routine downlinks on the plan and a monthly item nobody enjoys: the station security baseline.

Every console action at GW-01 is logged - logins, configuration changes, command traffic. Once a month somebody actually reads it. Group Security in London reads it too, eventually, but they read it a fortnight later and from four hundred miles away.

Charlie's guidance is characteristically brief: "You'll find nothing. Find it properly. And do not miss the pass while you are finding it."`, + equipment: ['GW-01 Galway: 4m Ku-Band LEO Tracker', 'Security Console (audit log + access control)', 'Spectrum Analyzer', 'RX Modem with Video Decoder'], + settings: { + isSync: true, + groundStations: [galwayBaseline, shetlandGroundStation], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:45:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-6?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + timeSkip: { + leadTimeS: 120, + minSkipS: 120, + horizonHours: 1, + }, + + workingDocument: { + title: 'GW-01 March Baseline Return', + description: + 'Monthly security baseline for Group Security (ref GS-BASELINE-GW01-03). Every warning-severity entry dispositioned; every account reconciled against current authorisation.', + }, + + // M6 - routine traffic plus three warning-severity entries to disposition: + // two benign (Fiona's first login from the new SH-02 address, the + // monitoring service's scheduled poll-interval change) and one finding + // (off-hours auth failures against the contractor account, still active + // three weeks after the install window). One live entry arrives during the + // shift so the log is seen to be live. + security: { + accounts: [ + { id: 'op-charlie', name: 'C. Brooks', role: 'Site Lead', status: 'active' }, + { id: 'op-duty', name: 'Operator on duty (you)', role: 'Operator', status: 'active' }, + { id: 'op-fiona', name: 'F. MacLeod', role: 'Operator (SH-02)', status: 'active' }, + { id: 'svc-monitor', name: 'Monitoring Service', role: 'Service Account', status: 'active' }, + { id: 'op-guest', name: 'Kilbride Antenna Services (contractor)', role: 'Maintenance', status: 'active' }, + { id: 'op-relief', name: 'T. Nakamura (Vermont secondment, ended 28 Feb)', role: 'Relief Operator', status: 'active' }, + ], + events: [ + { + id: 'evt-relief-logout', + timeS: 0, + timestampLabel: '27 Feb 16:40 UTC', + actor: 'op-relief', + action: 'Console logout (end of secondment)', + category: 'auth', + severity: 'info', + }, + { + id: 'evt-authfail', + timeS: 0, + timestampLabel: '02:47 UTC', + actor: 'op-guest', + action: 'Repeated failed logins (off-hours, 6 attempts)', + category: 'auth', + severity: 'warning', + isAnomaly: true, + }, + { + id: 'evt-svc-interval', + timeS: 0, + timestampLabel: '03:00 UTC', + actor: 'svc-monitor', + action: 'Configuration change: telemetry poll interval 30 -> 15 min (change ticket CHG-0412)', + category: 'config', + severity: 'warning', + }, + { id: 'evt-login-charlie', timeS: 0, timestampLabel: '06:02 UTC', actor: 'op-charlie', action: 'Console login', category: 'auth', severity: 'info' }, + { + id: 'evt-cfg-rx', + timeS: 0, + timestampLabel: '06:14 UTC', + actor: 'op-charlie', + action: 'Set receiver 1 frequency 1370 MHz (SAR-2 loopback test)', + category: 'config', + severity: 'info', + }, + { + id: 'evt-login-fiona', + timeS: 0, + timestampLabel: '06:30 UTC', + actor: 'op-fiona', + action: 'Console login (SH-02 remote) - first login from a new source address', + category: 'auth', + severity: 'warning', + }, + { + id: 'evt-cmd-playback', + timeS: 0, + timestampLabel: '07:11 UTC', + actor: 'op-charlie', + action: 'TT&C command REC-PLAYBACK acknowledged', + category: 'command', + severity: 'info', + }, + { id: 'evt-svc-poll', timeS: 0, timestampLabel: '07:30 UTC', actor: 'svc-monitor', action: 'Telemetry poll', category: 'config', severity: 'info' }, + { id: 'evt-keyrotate', timeS: 0, timestampLabel: '08:05 UTC', actor: 'op-charlie', action: 'COMSEC key rotation completed', category: 'command', severity: 'info' }, + { id: 'evt-login-duty', timeS: 0, timestampLabel: '13:44 UTC', actor: 'op-duty', action: 'Console login', category: 'auth', severity: 'info' }, + { + id: 'evt-fiona-tune', + timeS: 900, + timestampLabel: '14:00 UTC', + actor: 'op-fiona', + action: 'Set receiver 1 frequency 1414 MHz (SH-02)', + category: 'config', + severity: 'info', + }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'S0844'], + title: 'Review the Baseline Task', + description: 'Open the shift brief and confirm what the monthly baseline actually requires, and when the passes are.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Baseline Task Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Scope Understood', + params: { + character: Character.SYSTEM, + question: 'What does the March return require from GW-01?', + options: [ + 'The whole trail read, every warning-severity entry dispositioned, and every account reconciled', + 'A search of the log for failed logins, every hit dispositioned, and the affected accounts locked', + 'A list of active accounts, every one confirmed by its owner, and the log kept for London to read', + 'Nothing unless something happened, every incident reported at the time, and the return left blank', + ], + correctIndex: 0, + explanation: + 'Read everything, disposition every warning, reconcile every account. "Nothing happened" is a conclusion, not an assumption. First pass at 14:03; the log will keep, the pass will not.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: STATION SWEEP AND RECEIVER SET-UP + // ============================================================ + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Clear the board before the baseline: confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at turnover?', + options: [ + 'RX AGC at max gain (weak signal) - empty sky, not a fault; no hardware alarms', + 'No active alarms (all nominal) - board clear, sweep complete; nothing to carry over', + 'Security console (unacknowledged anomaly) - flagged to the board, not yet read; one alarm', + 'GPSDO in holdover (reference alarm) - timing at risk, not a fault; one hardware alarm', + ], + correctIndex: 0, + explanation: + 'The only entry is the receive AGC at its rail: empty sky, not a fault. The security console does not raise dashboard alarms; that is exactly why somebody has to read it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover before the pass.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-1', + description: + 'Charlie left modem 1 on the 1370 MHz SAR-2 carrier this morning - the log says so. Retune it to 1414 MHz and put the analyzer on the 1389 MHz beacon with a 2 MHz span.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // THE BASELINE, PART 1: READ THE TRAIL, DISPOSITION THE BENIGN + // ============================================================ + { + id: 'read-the-audit-log', + nice: ['S0844', 'K0685'], + title: 'Read the Station Audit Log', + description: + 'Open the Security console and read the audit log end to end, then sign off the review. Routine traffic is most of it; that is what makes the exceptions findable.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Security Console Open', + params: { tab: 'security-console' }, + mustMaintain: false, + }, + { + type: 'audit-log-reviewed', + description: 'Audit Log Reviewed', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Routine Recognised', + params: { + character: Character.SYSTEM, + question: 'Three entries carry warning severity. How many of them are findings?', + options: [ + 'You do not know yet - severity is a hint from the logger, and each entry still has to be dispositioned', + 'All three - warning is the logger flagging a finding, and each one goes straight into the return', + 'None - nothing was breached, and a finding needs a loss or an intrusion, not just an odd entry', + 'One - only the failed authentications count, and the two successful entries are routine by definition', + ], + correctIndex: 0, + explanation: + 'The logger scores rarity; you score meaning, on who, what and when. Fiona from a new address, a service account changing its own poll interval, and six failed logins at 02:47 are three different things.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'disposition-new-address', + nice: ['S0844', 'K0686'], + title: 'Disposition the New-Address Login', + description: "Fiona's 06:30 login from SH-02 tripped the new-source-address rule. Flag it for the return with its disposition, then decide what it is.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['read-the-audit-log'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'New-Address Login Dispositioned', + params: { eventId: 'evt-login-fiona' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Benign or Finding', + params: { + character: Character.SYSTEM, + question: 'op-fiona, 06:30, console login from a source address the station has never seen. Finding?', + options: [ + 'Benign: SH-02 is new to the rule and the account, hour and site register agree - note it, no action', + 'Finding: any unknown source address is an intrusion attempt until proven otherwise - lock op-fiona, escalate', + 'Finding: operators must not log in remotely, whatever the site register says - report it, revoke remote access', + 'Ignore it: successful logins are never findings, whatever the address - skip it, nothing in the return', + ], + correctIndex: 0, + explanation: + 'SH-02 went operational this week and its address is new to the rule; the account, the hour and the site register all agree. Expected, explained, and checkable against the site register. A rule that fires once for a new site is doing its job; the disposition says so and the rule will not fire for Shetland again.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'disposition-poll-change', + nice: ['S0844', 'K0685'], + title: 'Disposition the Poll-Interval Change', + description: 'The monitoring service changed its own telemetry poll interval at 03:00. Flag it for the return with its disposition.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['disposition-new-address'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Poll-Interval Change Dispositioned', + params: { eventId: 'evt-svc-interval' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Benign or Finding', + params: { + character: Character.SYSTEM, + question: 'svc-monitor, 03:00, configuration change to its own poll interval, referencing change ticket CHG-0412. Finding?', + options: [ + 'Benign: a service account changing its own config on schedule, with a ticket - verify the ticket, note it', + 'Finding: service accounts must never change configuration, ticket or not - revoke its config rights, report', + 'Finding: 03:00 is off-hours for any actor, scheduled or not - report it, hold the change until reviewed', + 'Benign: config entries are never findings, whatever the actor - skip it, no need to check the ticket', + ], + correctIndex: 0, + explanation: + 'Who, what, when: the right actor doing the thing it exists to do, when the ticket said. Off-hours is a property of people, not of a scheduler. The ticket is the evidence; the return cites it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: THE ROUTINE PASS + // ============================================================ + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'The baseline pauses for the pass. Program-track SAR-1 before 14:03 and confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['disposition-poll-change'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar1', + nice: ['T0153', 'K0740'], + title: 'Decode the Routine Downlink', + description: 'Lock the 1414 MHz imagery carrier and hold C/N above 8 dB through the high-elevation segment, roughly 14:05 to 14:08.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4 / THE BASELINE, PART 2: THE FINDING AND THE ROSTER + // ============================================================ + { + id: 'flag-the-off-hours-failures', + nice: ['S0844', 'K0686'], + title: 'Flag the Off-Hours Failures', + description: + "LOS. Back to the trail: six failed logins at 02:47 against the antenna contractor's account, hours after any maintenance window and three weeks after the install closed. Flag it so Group Security sees it in this month's return.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar1'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'security-event-acknowledged', + description: 'Off-Hours Auth Failures Flagged', + params: { eventId: 'evt-authfail' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Finding Confirmed', + params: { + character: Character.SYSTEM, + question: 'op-guest, 02:47, six failed logins, none succeeded. Finding?', + options: [ + 'Finding: an account that should not exist, tried at an hour nobody authorised - report exposure and attempt', + 'Benign: unsuccessful attempts are noise, and the lockout did its job - note the count, nothing to report', + 'Benign: the contractor probably forgot their password on a late call-out - ring Kilbride, nothing to report', + 'Finding only if it repeats: one burst of failures is not a pattern - watch the account, report next month', + ], + correctIndex: 0, + explanation: + 'Who should not have an account, what looks like a guess sequence, when nobody is on site. You report the exposure and the attempt, not just the damage, whether or not anything got in. Flag first, note the time you saw it, then act on the account.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'close-the-contractor-account', + nice: ['K0686', 'T1569'], + title: 'Close the Contractor Account', + description: + 'Kilbride Antenna Services finished the feed work three weeks ago and their account is still active. Set it to disabled in the access-control panel. Nothing was breached - and that is not a reason to leave it open.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['flag-the-off-hours-failures'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'access-control-set', + description: 'Contractor Account Disabled', + params: { accountId: 'op-guest', accountStatus: 'disabled' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Least Privilege', + params: { + character: Character.SYSTEM, + question: 'The account was never used after the install. Why disable it rather than leave it for the next feed job?', + options: [ + 'An account that exists can be attacked; one needed again is re-enabled in a minute - privilege follows current need', + 'Because the contractor was caught attempting access; a live attempt is grounds - the account is now evidence', + 'Because maintenance accounts expire automatically after 30 days; this one is overdue - the panel is just catching up', + 'It should not be disabled; the return only requires it be noted - Kilbride will need it for the next feed job', + ], + correctIndex: 0, + explanation: + 'Privilege should match current need, not future convenience. Least privilege is a habit, not a response. The install phase leaves things behind; the baseline is where they get picked up.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'expire-the-relief-account', + nice: ['K0686', 'T1569', 'K0685'], + title: 'Expire the Relief Account', + description: + "T. Nakamura's Vermont secondment ended on 28 February and the last entry under op-relief is a logout the day before. The account is still active. Set it to expired, so a return visit has to be re-authorised through London rather than walking back in.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['close-the-contractor-account'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'access-control-set', + description: 'Relief Account Expired', + params: { accountId: 'op-relief', accountStatus: 'expired' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Expired vs Disabled', + params: { + character: Character.SYSTEM, + question: 'Why expire the relief account rather than disable it like the contractor?', + options: [ + 'Expired records that authorisation lapsed and forces re-authorisation; disabled is a deliberate shut-off', + 'Expired keeps the login working in case they come back; disabled would lock T. Nakamura out for good', + 'Disabled is only for contractors and outside firms; expired is the status for NATS staff who move on', + 'There is no difference in what the panel enforces; either is fine, and expired just reads better', + ], + correctIndex: 0, + explanation: + 'Same effect today, different story in the return. The status is part of the record. Somebody reading the roster in six months should be able to tell a lapsed secondment from a closed contractor without asking you.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 5 -> BEATS 2-3: THE SECOND DOWNLINK + // ============================================================ + { + id: 'retune-for-sar2', + nice: ['S0421', 'K0773'], + title: 'Retune for SAR-2', + description: 'MERIDIAN-SAR-2 rises at 14:18 from azimuth 130. Retune modem 1 to its 1370 MHz carrier and the analyzer to its 1397 MHz beacon.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['expire-the-relief-account'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1397 MHz', + params: { centerFrequency: 1397e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'decode-sar2', + nice: ['T0153', 'K0740', 'S0421'], + title: 'Decode the Second Downlink', + description: 'Retarget program-track to SAR-2, lock the 1370 MHz carrier and hold C/N above 8 dB through the high-elevation segment, roughly 14:20:30 to 14:24.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retune-for-sar2'], + conditions: [ + { + type: 'antenna-locked', + description: 'Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4: FILE THE RETURN + // ============================================================ + { + id: 'close-the-baseline', + nice: ['S0844', 'T1580', 'K0685'], + title: 'Close the Baseline', + description: "Record the month's findings and their dispositions for the return. Two passes decoded while you did it.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Baseline Recorded', + params: { + character: Character.SYSTEM, + question: 'The failed logins never succeeded and nothing was taken. Why is this still a finding worth reporting?', + options: [ + 'A dormant account with maintenance privileges is exposure whatever the outcome - and somebody was trying it.', + 'It is not - unsuccessful attempts are noise and should be filtered out before the return goes to London.', + 'Because the contractor breached their contract by attempting access - the report is a contractual step.', + 'Only because Group Security requires at least one finding per return - a blank return gets sent back.', + ], + correctIndex: 0, + explanation: + 'Somebody was trying that account at 02:47. You report the exposure and the attempt, not just the damage. Baseline closed: trail read, three warnings dispositioned, two accounts reconciled, two passes decoded.', + pointPenalty: 5, + documentSection: 'Findings', + documentLine: + 'Audit trail read end to end and signed off. evt-login-fiona 06:30: benign, first SH-02 remote login. evt-svc-interval 03:00: benign, CHG-0412. evt-authfail 02:47: FINDING, six failed logins against op-guest (Kilbride, install closed 3 weeks); account disabled. op-relief: authorisation lapsed 28 Feb; account expired. Passes 14:03 SAR-1 and 14:18 SAR-2 decoded, no impact.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:44] +

+

+ "Quiet one. SAR-1 at 14:03 and SAR-2 at 14:18, both routine downlinks that need nothing from you beyond doing them properly. The item that actually needs you is the monthly security baseline, which nobody enjoys and which I want done properly too. Every console action at this site is logged. Once a month somebody reads all of it. This month that is you. You'll find nothing. Find it properly. And do not miss the pass while you are finding it." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'read-the-audit-log': { + text: ` +

+ [Memo from Priya Sharma, NATS Group Security, ref GS-BASELINE-GW01-03] +

+

+ "Monthly return for GW-01 is due by close of business Friday. Standard scope: full audit-trail review since the last return, every warning-severity event dispositioned, and an account roster reconciled against current authorisation. Anything flagged, tell us what it was and what you did about it. Anything not flagged, we assume you looked. A note for new sites: the install phase leaves things behind." +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'disposition-new-address': { + text: ` +

+ That one is Fiona. The rule has never seen Shetland's address because Shetland did not exist last month. Write down why it is fine; "it is fine" on its own is what people write when they did not look. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'disposition-poll-change': { + text: ` +

+ Service account changing its own poll interval at three in the morning with a change ticket on it. That is a scheduler doing what schedulers do. Cite the ticket and move on. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'acquire-sar1': { + text: ` +

+ Pass first. The log has been sitting there since six this morning; it will keep for ten minutes. The bird will not. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'decode-sar1': { + text: ` +

+ Routine is the point. Every action you just took went into the trail you are auditing - program-track, modem lock, the lot. Next month somebody reads those and decides whether they were you. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'flag-the-off-hours-failures': { + text: ` +

+ [Text message from Priya Sharma at 14:11] +

+

+ "Charlie says you have something on the contractor account. Do not clean it up yet. Flag the entry, note the time you saw it, then disable the account. The order matters to me later." +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'close-the-contractor-account': { + text: ` +

+ Kilbride did a good job on the feed and I would have them back tomorrow. That is a reason to re-enable the account tomorrow, not a reason to leave it open tonight. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'expire-the-relief-account': { + text: ` +

+ Nakamura covered for me in February and went home on the twenty-eighth. Nobody closed the account because nobody's job was to close the account. That is what the roster reconciliation is for. Expired, not disabled: the story is different and the return should say so. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'decode-sar2': { + text: ` +

+ Second one decoding. A baseline that cost the station a pass would not be a baseline, it would be an outage with paperwork. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'close-the-baseline': { + text: ` +

+ "Thank you. That is an incident record, not a story. Send it as it is." +

+ `, + character: Character.PRIYA_SHARMA, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario7.ts b/src/campaigns/nats-eu/scenario7.ts new file mode 100644 index 00000000..074b0dd6 --- /dev/null +++ b/src/campaigns/nats-eu/scenario7.ts @@ -0,0 +1,821 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 7 - "Moving Target" / Ephemeris Management + * + * New mechanic: M4 space-domain events. A conjunction-avoidance burn on + * MERIDIAN-SAR-2 invalidates the element set the station is tracking on. The + * operator has a baseline to compare against - the SAR-1 pass worked on + * current elements an hour earlier - then attempts SAR-2 on the stale set, + * watches the pedestal read LOCKED while the carrier sinks as the bird rises, + * loads the post-burn elements, reacquires, and reports to Rotterdam. + * + * Phase 16: the miss is experienced, not asserted. At `maneuverAtS` the + * spacecraft moves onto `newTle` while the station's element set stays where + * it was (OrbitalSatellite.maneuverTo); program-track follows the old set until + * the operator loads the update (reloadTle). The burn is 0.03 deg of mean + * anomaly, about 3.5 km along track - a real avoidance burn, not a new orbit. + * At 828 km that is a third of a degree, which at a 0.45 deg Ku beamwidth is + * the difference between 10 dB and 4 dB. Measured on the stale set: C/N peaks + * 4.2 dB at 12 deg elevation and FALLS to -2.6 dB at culmination as the range + * shortens and the angular error grows; on the loaded set 10.2 dB at 25 deg + * with 174 s above 8 dB (test/campaigns/nats-eu-phase-b-validation.test.ts). + * + * Clock: sim starts 2027-03-15 13:45:00 UTC. `maneuverAtS` runs on the mission + * clock: the burn executes at 14:12:00 (T+1620), between the two passes. + * - MERIDIAN-SAR-1 (baseline, current elements): AOS 14:03:10 az 000, max el + * 28.0 at 14:06:45 (761 km), LOS 14:10:18 az 224. C/N >= 8 dB ~14:05 .. 14:08:30. + * - MERIDIAN-SAR-2 (burned): AOS 14:18:42 az 130, max el 25.0 at 14:22:10 + * (828 km), LOS 14:25:40 az 000. Loaded set: C/N >= 8 dB 14:20:45 .. 14:23:40. + * + * Staged state (scenario-local clone of GW-01): RX modem 1 on the 1370 MHz + * SAR-2 carrier and the tracker stowed south (az 180) after yesterday's shift. + * + * NICE Framework Alignment: + * Primary Codes: + * - K1032: Knowledge of satellite-based communication systems + * - T0431: Monitor and maintain system operations + * - T1138: Perform system reconfiguration + * + * Supporting Codes: + * - S0421: Skill in operating network equipment + * - K0740: Knowledge of system performance indicators + * - K0741: Knowledge of system availability measures + * - K0773: Knowledge of telecommunications principles and practices + * - T0153: Monitor network capacity and performance + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + */ + +/** GW-01 as yesterday's shift left it: modem on SAR-2, tracker stowed south. Deep clone, never spread. */ +const galwayMovingTarget: GroundStationConfig = structuredClone(galwayGroundStation); +galwayMovingTarget.antennasState![0] = { + ...galwayMovingTarget.antennasState![0], + azimuth: 180 as Degrees, + elevation: 3 as Degrees, + targetAzimuth: 180 as Degrees, + targetElevation: 3 as Degrees, +}; +galwayMovingTarget.receivers![0].modems![0] = { + ...galwayMovingTarget.receivers![0].modems![0], + frequency: 1370 as MHz, +}; + +export const natsEuScenario7Data: ScenarioData = { + id: 'nats-eu-scenario7', + url: 'nats-eu/scenarios/nats-eu-scenario7', + imageUrl: 'nats/7/card.png', + number: 7, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario6'], + title: 'Moving Target', + subtitle: 'Ephemeris Management', + duration: '35-40 min', + missionType: 'Anomaly Response', + description: `Anneke called at 05:40. Space Surveillance flagged a conjunction between MERIDIAN-SAR-2 and a spent upper stage, close enough that the constellation would rather burn than argue about probabilities. Go / no-go at 14:00; if it is go, the burn executes at 14:11, between your two passes.

The burn will be small. The problem is that everything at GW-01 - your pass predictions, your program-track pointing, the antenna's idea of where to look at AOS - is computed from an element set that will describe an orbit the spacecraft is no longer in.

Work SAR-1 first on current elements so you know what right looks like. Then find out what wrong looks like.`, + equipment: ['GW-01 Galway: 4m Ku-Band LEO Tracker', 'Pass Schedule Console (ephemeris status)', 'Spectrum Analyzer', 'QPSK 3/4 Receiver'], + settings: { + isSync: true, + groundStations: [galwayMovingTarget, shetlandGroundStation], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-15', + scenarioStartWallTime: '13:45:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-7?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 2, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // Two-hour search horizon: after the 14:18 pass the next contact at the + // mask is SAR-2 over Galway at 15:50, which is the dead hour the skip is + // for (and what e2e/specs/nats-eu-time-skip.spec.ts exercises here). + timeSkip: { + leadTimeS: 120, + minSkipS: 300, + horizonHours: 2, + }, + + workingDocument: { + title: 'GW-01 Ephemeris Log', + description: 'Element-set events on the station: which set, which epoch, what it did to the pass.', + }, + + // M4 - the avoidance burn on SAR-2, on the mission clock at 14:12:00. The + // spacecraft moves onto the new set; the station keeps predicting from the + // old one until the operator loads the update. Mean anomaly +0.03 deg. + spaceEvents: [ + { + id: 'SAR2-CAM', + satelliteNoradId: 61702, + maneuverAtS: 1620, + label: 'MERIDIAN-SAR-2 conjunction-avoidance manoeuvre executed 14:11Z', + newTle: { + tle1: '1 61702U 27015A 27074.58333333 .00001000 00000-0 10000-3 0 9997', + tle2: '2 61702 98.4000 42.0000 0010000 90.0000 240.0300 15.60000000123458', + }, + }, + ], + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K1032'], + title: 'Review the Conjunction Notice', + description: 'Open the shift brief and read the conjunction report and the decision timeline.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Conjunction Notice Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Impact Understood', + params: { + character: Character.SYSTEM, + question: 'If the burn goes ahead and you keep the old SAR-2 element set, what breaks at the ground station?', + options: [ + 'Pass predictions and program-track pointing - the antenna aims where the elements say, so it looks where the bird used to be', + 'Nothing at the station - a small burn is a problem for the spacecraft operator, and the Ku beam is wide enough to cover it', + 'The receiver frequency - the new orbit changes the Doppler profile, so the modem has to be retuned before the bird rises', + 'The link budget - the burn changes the transmit power and the range at culmination, so the predicted C/N no longer holds', + ], + correctIndex: 0, + explanation: "The element set is the station's entire model of where the satellite is. A stale set is a confidently wrong antenna. SAR-1 did not burn; work it first.", + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: PRE-PASS FOR THE BASELINE + // ============================================================ + { + id: 'dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Clear the board: confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at turnover?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear', + 'Ephemeris stale on MERIDIAN-SAR-2 - pass schedule alarm, pointing at risk', + 'GPSDO in holdover - reference alarm, timing at risk', + ], + correctIndex: 0, + explanation: + 'The AGC rail is empty sky, not a fault, and there is no stale flag yet: the burn has not happened. When it does, the ephemeris panel on the Pass Schedule tab is where it shows, not the dashboard.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'reference-check', + nice: ['T0431', 'K0740'], + title: 'Reference Check', + description: 'GPSDO locked and out of holdover. Timing is half of an element set: the epoch means nothing against a wrong clock.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'ephemeris-epoch-check', + nice: ['K1032', 'T0431'], + title: 'Check the Element Sets', + description: 'Open the Pass Schedule tab. Both birds show current elements with the 14:00 epoch. Know what would make either of them stale.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reference-check'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'Pass Schedule Tab Open', + params: { tab: 'pass-schedule' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Staleness Understood', + params: { + character: Character.SYSTEM, + question: 'Both sets are hours old and the panel reads current. What makes an element set stale?', + options: [ + 'A manoeuvre at once, or age - drag and model error grow the along-track error each day until the beam misses', + 'Only a manoeuvre - without one an element set stays good indefinitely, because the orbit does not change on its own', + 'Age alone - any set older than one orbit is stale, because the propagator cannot carry it past a single revolution', + 'A change of ground station - elements are fitted per site, so a set computed for Galway is stale at Shetland', + ], + correctIndex: 0, + explanation: + 'Two clocks run on a TLE: the calendar, which erodes it slowly (at 360 km, drag and model error add hundreds of metres of along-track error a day), and the thruster, which breaks it at once. Rotterdam distributes a fresh set every few days for the first reason and within minutes for the second.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'rx-chain-ready', + nice: ['S0421', 'K0773'], + title: 'Set Up the Receiver for SAR-1', + description: "Modem 1 is on yesterday's 1370 MHz SAR-2 carrier. Retune it to 1414 MHz for SAR-1 and put the analyzer on the 1389 MHz beacon with a 2 MHz span.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['ephemeris-epoch-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'preposition-for-aos', + nice: ['S0421', 'K1032'], + title: 'Pre-position for AOS', + description: 'The tracker is stowed south. Slew it to the SAR-1 rise azimuth, 000 at 5 degrees, before 14:03.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['rx-chain-ready'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 000 / El 5', + params: { azimuth: 0, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: THE BASELINE PASS + // ============================================================ + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Program-track SAR-1 on current elements and confirm the beacon on RX analysis. This is what a good element set looks like.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-aos'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'baseline-decode', + nice: ['T0153', 'K0740'], + title: 'Decode the Baseline Pass', + description: + 'Lock the 1414 MHz carrier, hold C/N above 8 dB through the high-elevation segment and read the peak at culmination (14:06:45, 28 degrees). Remember what the curve looks like.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N >= 9 dB at Culmination', + params: { modemNumber: 1, minCNRatio: 9, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Baseline Read', + params: { + character: Character.SYSTEM, + question: 'On current elements, how did C/N behave from AOS to culmination?', + options: [ + 'Climbed steadily with elevation to a peak of about 11 dB at 28 degrees - shorter range, nothing else changed', + 'Peaked at AOS and fell through the pass - the beam is widest at the horizon and narrows as the bird climbs', + 'Flat at about 8 dB throughout - the AGC holds the carrier level constant once the modem has locked', + 'Climbed, then dipped at the top of the pass - the azimuth rate at culmination pulls the pedestal off the bird', + ], + correctIndex: 0, + explanation: + 'Shorter range, less path loss, more signal: C/N rises with elevation and peaks where the bird is closest. Hold that picture; the next pass will not draw it.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4 -> BEAT 5: LOG, RETUNE, THE BURN + // ============================================================ + { + id: 'log-the-baseline', + nice: ['T1580', 'K0645'], + title: 'Log the Baseline', + description: 'Write the SAR-1 result into the ephemeris log: which set, which epoch, what it delivered.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['baseline-decode'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Baseline Logged', + params: { + character: Character.SYSTEM, + question: 'What goes in the ephemeris log for the SAR-1 contact?', + options: [ + 'Elements current (epoch 14:00), program-track locked, decoded, peak C/N about 11 dB at 28 degrees', + 'Elements current (epoch 14:00), program-track locked, decoded, peak C/N not recorded for a baseline', + 'Elements stale (epoch 14:00), program-track locked, decoded anyway, peak C/N about 11 dB at 28 degrees', + 'Elements current (epoch 14:03), step-track locked, decoded, peak C/N about 8 dB at 28 degrees', + ], + correctIndex: 0, + explanation: 'The set and its epoch belong next to the result. When a pass goes wrong, the first question is which elements it was flown on.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: '14:03Z MERIDIAN-SAR-1 GW-01: elements current (epoch 2027-03-15 14:00Z), program-track locked, decoded, peak C/N ~11 dB at 28 deg. Baseline.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'retune-for-sar2', + nice: ['S0421', 'K0773'], + title: 'Retune for SAR-2', + description: + 'The burn executed at 14:11. SAR-2 rises at 14:18 from azimuth 130: retune modem 1 to its 1370 MHz carrier and the analyzer to its 1397 MHz beacon. Leave the elements alone for now.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['log-the-baseline'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'Analyzer Centre 1397 MHz', + params: { centerFrequency: 1397e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'preposition-for-sar2', + nice: ['S0421', 'K1032'], + title: 'Pre-position for SAR-2', + description: 'Manual mode, and park the tracker on the SAR-2 rise azimuth, 130 at 5 degrees, as the old elements predict it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['retune-for-sar2'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'antenna-position', + description: 'Parked on Az 130 / El 5', + params: { azimuth: 130, elevation: 5, tolerance: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 2 ON THE STALE SET: THE MISS + // ============================================================ + { + id: 'stale-acquisition-attempt', + nice: ['K1032', 'T0431', 'K0740'], + title: 'Attempt SAR-2 on the Old Elements', + description: + 'Program-track SAR-2 on the set you have. The pedestal will report LOCKED. Watch RX analysis as the bird rises: the carrier is there, weak, and it gets weaker as the elevation climbs. Read what that is telling you before you fix it.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['preposition-for-sar2'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Pedestal Reports LOCKED on SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Stuck Below 5 dB', + params: { modemNumber: 1, maxCNRatio: 5, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Pointing Error Diagnosed', + params: { + character: Character.SYSTEM, + question: 'Pedestal LOCKED, every drive indicator green, and C/N is falling as the bird climbs. What is happening?', + options: [ + 'The pedestal is locked to a prediction, not the bird - the same along-track error is a bigger angle at shorter range', + 'The receiver has lost lock and needs retuning - the Doppler on the new orbit walked the carrier out of the capture range', + 'Rain fade on the rising leg - a wet path at low elevation costs several dB at Ku and the carrier is barely above the floor', + 'The pedestal is slewing too slowly and lagging the pass - the azimuth rate grows toward culmination and the drive cannot keep up', + ], + correctIndex: 0, + explanation: + 'Program-track lock means the pedestal is where it was told to go. It was told wrong. Three and a half kilometres along track is 0.1 deg at 2000 km and 0.25 deg at 828 km: the closer the bird, the worse the miss.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'load-the-ephemeris', + nice: ['T1138', 'K1032', 'T0431'], + title: 'Load the Post-Burn Elements', + description: + 'On the Pass Schedule tab the SAR-2 entry reads EPHEMERIS STALE. Press Load Updated Ephemeris. The station recomputes the pass and program-track slews to where the bird actually is.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['stale-acquisition-attempt'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ephemeris-updated', + description: 'Post-Burn Ephemeris Loaded', + params: { eventId: 'SAR2-CAM' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Epoch Read', + params: { + character: Character.SYSTEM, + question: 'Two element sets for SAR-2, both dated today. What tells you which one describes the orbit the bird is in now?', + options: [ + 'The epoch: the post-burn set is timed after 14:11, and an older set cannot know about a burn made after it was fitted', + 'The NORAD number: the catalogue issues a new number for each orbit change, so the higher one is the current orbit', + 'The load order: the set the panel loaded most recently is always the current one, whatever its epoch says', + 'Neither: they describe the same orbit, and the burn only shifted the pass timing by under ten seconds', + ], + correctIndex: 0, + explanation: + 'An element set is a snapshot of the orbit at its epoch. Anything the spacecraft does after that instant is invisible to it. Newer is not always better; post-burn is.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEATS 2-3: REACQUIRE ON THE NEW SET + // ============================================================ + { + id: 'reacquire-sar2', + nice: ['S0421', 'K1032'], + title: 'Reacquire MERIDIAN-SAR-2', + description: 'Program-track slews the third of a degree to the real bird. Confirm the imagery carrier comes up on RX analysis where the new prediction says it should.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['load-the-ephemeris'], + conditions: [ + { + type: 'antenna-locked', + description: 'Tracking SAR-2 on Current Elements', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink Detected', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'verify-the-reacquisition', + nice: ['T0153', 'K0740'], + title: 'Verify the Reacquisition', + description: 'Lock the 1370 MHz carrier and hold C/N above 8 dB through what is left of the high-elevation segment. A clean reacquisition draws the same curve SAR-1 drew.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['reacquire-sar2'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 4: REPORT + // ============================================================ + { + id: 'report-to-rotterdam', + nice: ['T1580', 'K1032', 'K0645'], + title: 'Report to Rotterdam', + description: 'Close the ephemeris log and tell Anneke the bird came up where it should.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['verify-the-reacquisition'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Report Filed', + params: { + character: Character.SYSTEM, + question: 'What does Rotterdam need from this station about the burn?', + options: [ + 'That the post-burn set was loaded, its epoch, and that SAR-2 was reacquired and decoded on it', + 'That the old elements still worked, the C/N they gave, and that SAR-2 was decoded on them through culmination', + 'That the pass was attempted on the old set, the time it was missed, and that the pedestal reported LOCKED', + 'That the pre-burn set was flagged stale, the time the flag appeared, and that the station awaits a confirmed set', + ], + correctIndex: 0, + explanation: + 'A constellation operator distributes elements; the ground sites are how they learn the elements are right. Your reacquisition on the loaded set is their ground confirmation that the new elements are good.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '14:18Z MERIDIAN-SAR-2 GW-01: attempted on pre-burn elements, pedestal LOCKED, C/N below 5 dB and falling with elevation (pointing error). Post-burn set loaded (SAR2-CAM), reacquired, decoded, C/N >= 8 dB. Reported to MERIDIAN Ops.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 13:44] +

+

+ "Anneke rang at 05:40, which tells you how her night went. Space Surveillance has a conjunction on SAR-2 with a spent upper stage. Rotterdam decides at 14:00; if it is go, the burn is at 14:11, which is between your passes. Small burn. New orbit, slightly. Here is the problem with slightly: every prediction this station makes comes from an element set that will describe an orbit the bird is not in. Work SAR-1 first. It did not burn, and I want you to know what right looks like before you meet wrong." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'ephemeris-epoch-check': { + text: ` +

+ [Text message from Anneke Visser, MERIDIAN Ops, at 13:52] +

+

+ "Galway - conjunction MER-OPS-CAM-2027-014 on SAR-2 is go / no-go at 14:00, burn at 14:11 if go. It is a small along-track adjustment; the payload schedule is unaffected. If we burn, treat your SAR-2 set as stale from that moment and load the post-burn elements before your 14:18 window. The pass shift is under ten seconds. The pointing error at a 4 m Ku beamwidth is not." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'preposition-for-aos': { + text: ` +

+ Park it on the rise. Same as any other day - and today "any other day" is the measurement. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'acquire-sar1': { + text: ` +

+ Beacon where the elements said it would be. Nothing remarkable about it, which is the point. Remember how unremarkable this felt. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'baseline-decode': { + text: ` +

+ Watch the number climb with the elevation. Up, up, peak at the top, down the other side. That is the curve a good set draws. You are about to see a different one. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'retune-for-sar2': { + text: ` +

+ [Text message from Anneke Visser at 14:12] +

+

+ "Burn executed 14:11:00, nominal. Your SAR-2 set is stale as of now. Post-burn elements are on your ephemeris inbox within two minutes; the panel will flag them. Load before 14:18 if you can - and if you cannot, I would still like to hear what you saw." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.CONCERNED, + audioUrl: '', + }, + 'stale-acquisition-attempt': { + text: ` +

+ Locked, is it. Locked on what? Look at the carrier, not the pedestal. It is there, it is weak, and it is getting weaker as the bird gets closer, which is backwards. The dish is exactly where it was told to be. That is the problem. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.SKEPTICAL, + audioUrl: '', + }, + 'load-the-ephemeris': { + text: ` +

+ "Epoch on the new set is 14:11:30 - after the burn. The old one is 14:00 and knows nothing about it. Load it and the pedestal will move about a third of a degree, which at this beamwidth is the whole link." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'reacquire-sar2': { + text: ` +

+ And there it is. A third of a degree. The bird never moved more than a few kilometres and the station could not see it. Precise antenna, wrong place. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'verify-the-reacquisition': { + text: ` +

+ "Seeing your lock from here. That is the ground confirmation we needed on the new set - thank you. Sleep is now a possibility." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'report-to-rotterdam': { + text: ` +

+ Log both passes with the set they were flown on. Next time a bird comes up weak the first question is "which elements", and the log should answer it before anyone has to ask. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario8.ts b/src/campaigns/nats-eu/scenario8.ts new file mode 100644 index 00000000..e65b7174 --- /dev/null +++ b/src/campaigns/nats-eu/scenario8.ts @@ -0,0 +1,1069 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import type { Degrees } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { meridianSar1Satellite, meridianSar2Satellite } from './satellites'; + +/** + * nats-eu Scenario 8 - "Night Passes" / Solo Evaluation (Phase 1 graduation) + * + * No new mechanics. Everything from S1-S7 in one unassisted night: build a + * valid two-station contact plan, complete a COMSEC rotation, prove the link + * budget on a cold chain, chain up in order inside the window, command the + * bird, chain down, + * then run the rest of the night alone across both sites - a remote telemetry + * contact from Shetland on a reference in holdover, and a steep pass at Galway + * after a traffic-key roll - with no coaching clip telling the operator what to + * do next. Quizzes are the operator's own checklist. + * + * Phase 16 rewrite: four contacts on the board, three worked (beats 2-4 each), + * two faults staged on the mission clock (E3): a GNSS outage at SH-02 from + * 02:44 to 02:59 that puts the Shetland reference into holdover for the 02:54 + * contact, and the fleet traffic-key roll at 04:15 that the operator finds as + * a Mismatch during the pre-pass check for the 04:27 contact. + * + * Clock: sim starts 2027-03-16 00:15:00 UTC, sixteen minutes before the first + * AOS so the plan, rotation, budget and chain fit before it. Passes at the + * 5 deg mask (0 deg horizon in brackets): + * - C1 GW-01 MERIDIAN-SAR-1: AOS 00:31:19 az 184 (00:30:10), max el 40.4 at + * 00:35:03 (581 km), LOS 00:38:49 az 338 (00:39:58). Peak C/N 13.4 dB, >= 8 dB + * ~00:33:10 .. 00:37:00. Command window 00:31:40 .. 00:38:30. + * SH-02 sees the same pass at 17 deg (00:33:31 .. 00:39:56): Fiona's, overlaps. + * - C2 SH-02 MERIDIAN-SAR-2: AOS 02:54:39 az 042, max el 13.4 at 02:57:34 + * (1233 km), LOS 03:00:27 az 140. Peak C/N 6.6 dB, >= 5 dB for 228 s: a + * telemetry contact, not an imagery one. Galway sees a 5.6 deg graze. + * - C3 GW-01 MERIDIAN-SAR-2: AOS 04:27:31 az 018, max el 73.1 at 04:31:23 + * (408 km), LOS 04:35:12 az 192. Peak C/N 16.5 dB, >= 8 dB for 259 s; the + * 20 deg/s pedestal holds it through the top (no keyhole at 73 deg). + * SH-02 sees a 72.5 deg pass at the same time (04:25:31 .. 04:33:13): + * priority 3, one operator, dropped. + * All numbers measured through the real chain; see + * test/campaigns/nats-eu-phase-b-validation.test.ts. + * + * Staged state (scenario-local clone): SH-02 modem 1 on the 1414 MHz SAR-1 + * carrier from Fiona's evening, BUC muted. GW-01 is the shared default. + * + * NICE Framework Alignment: + * Primary Codes: + * - T0081: Perform operational testing and evaluation + * - S0421: Skill in operating network equipment + * - T0153: Monitor network capacity and performance + * + * Supporting Codes: + * - K0874: Knowledge of cryptographic key management + * - K0728: Knowledge of cryptographic key storage and handling + * - K0689: Knowledge of network systems management + * - K0740: Knowledge of system performance indicators + * - K0741: Knowledge of system availability measures + * - K1032: Knowledge of satellite-based communication systems + * - T0129: Coordinate and manage system operations schedules + * - T0431: Check system hardware availability, functionality, integrity + * - T1567: Conduct satellite command and control operations + * - K0645: Knowledge of standard operating procedures + * - T1580: Report service status to stakeholders + */ + +/** SH-02 as Fiona left it: modem on SAR-1, BUC muted. Deep clone, never spread. */ +const shetlandNight: GroundStationConfig = structuredClone(shetlandGroundStation); +shetlandNight.rfFrontEnds[0].buc = { ...shetlandNight.rfFrontEnds[0].buc, isMuted: true }; + +export const natsEuScenario8Data: ScenarioData = { + id: 'nats-eu-scenario8', + url: 'nats-eu/scenarios/nats-eu-scenario8', + imageUrl: 'nats/8/card.png', + number: 8, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario7'], + title: 'Night Passes', + subtitle: 'Solo Evaluation', + duration: '40 min', + missionType: 'Evaluation', + description: `00:15 local. Charlie is at home. Fiona takes the first Shetland pass and goes off shift at 01:00; after that SH-02 is yours, remote, along with everything else.

This is the qualification shift for NATS Europe LEO operations: a contact plan to build across two sites, a COMSEC rotation that fell on your watch, an acceptance budget to prove, a command that has to reach MERIDIAN-SAR-1 inside a seven-minute window, and then two more contacts before dawn that nobody has pre-checked for you.

Nobody is going to prompt you. Everything on this shift you have already done once.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'Contact Plan / Link Analysis / TT&C consoles', + 'Ku-Band BUC + HPA, QPSK 3/4 modems', + 'Payload Crypto (AES-256-GCM)', + ], + settings: { + isSync: true, + groundStations: [galwayGroundStation, shetlandNight], + satellites: [meridianSar1Satellite, meridianSar2Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-16', + scenarioStartWallTime: '00:15:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-8?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 5, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // Two and a half hours of empty sky between the first and second contact, + // and ninety minutes between the second and third. The skip stops 2 min + // before the next pass at either site and is blocked while a timed + // objective runs; mission-elapsed advances with it, so the plan, the faults + // and the command window stay aligned with the sky. + timeSkip: { + leadTimeS: 120, + minSkipS: 300, + horizonHours: 5, + }, + + workingDocument: { + title: 'GW-01 Night Shift Handover', + description: 'What the day shift inherits: every contact worked, every fault found and cleared, the state of both sites at handover.', + }, + + // M3 - the night plan. Two overlapping pairs have to be split across the + // sites; the 04:25 Shetland overhead is priority 3 and one operator cannot + // fly both 04:27 passes, so it may be dropped. Windows are the 5 deg mask + // crossings from the 00:15 epoch, per station. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 2, + contacts: [ + { id: 'N-SAR1-GW', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Galway, 40 deg pass)', priority: 1, windowStartS: 980, windowEndS: 1430, stationId: 'GW-01' }, + { id: 'N-SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 17 deg pass)', priority: 1, windowStartS: 1110, windowEndS: 1500, stationId: 'SH-02' }, + { id: 'N-SAR2-SH', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Shetland, 13 deg pass)', priority: 2, windowStartS: 9580, windowEndS: 9930, stationId: 'SH-02' }, + { id: 'N-SAR2-GW', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Galway, 73 deg pass)', priority: 1, windowStartS: 15150, windowEndS: 15610, stationId: 'GW-01' }, + { + id: 'N-SAR2-SH-2', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Shetland, 72 deg overhead, same orbit)', + priority: 3, + windowStartS: 15030, + windowEndS: 15490, + stationId: 'SH-02', + }, + ], + }, + + // M1 - acceptance budget for the night pass geometry (581 km at max el). + linkBudget: { + label: 'Night pass: MERIDIAN-SAR-1 downlink at max elevation', + expectedCNRDb: 13.3, + toleranceDb: 1.0, + thresholdCNRDb: 6, + requiredMarginDb: 3, + }, + + // M5 - the rotation fell on the night shift, as they always do. Window is + // the SAR-1 pass: AOS 00:31:19 (979 s) .. LOS 00:38:49 (1429 s). + commanding: { + groundStationId: 'GW-01', + targetNoradId: 61701, + windowStartS: 1000, + windowEndS: 1410, + requireDopplerComp: true, + requireValidKey: true, + commands: [ + { id: 'REC-PLAYBACK', label: 'Start recorder playback' }, + { id: 'PLD-SAFE', label: 'Payload to safe mode' }, + { id: 'OBC-WDT-RESET', label: 'Reset OBC watchdog' }, + ], + }, + + // E3 - two faults on the mission clock, unlabelled: noticing them is the + // evaluation. The Shetland GNSS antenna drops out at 02:44 and comes back at + // 02:59, so the 02:54 contact is flown on the oscillator; the fleet traffic + // key rolls at 04:15 and the station's payload crypto reads Mismatch until + // the operator re-keys before the 04:27 pass. + hardwareFaultEvents: [ + { id: 'sh-gnss-outage', groundStationId: 'SH-02', target: 'gpsdo-gnss-loss', startTime: 8940, duration: 900 }, + { id: 'traffic-key-roll', groundStationId: 'GW-01', target: 'crypto-key-mismatch', startTime: 14400 }, + ], + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'T0081'], + title: 'Take the Shift', + description: 'Open the shift brief and take the console. Everything you need is in it; nobody will walk you through the rest.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Order of Work', + params: { + character: Character.SYSTEM, + question: 'Plan, rotation, budget. AOS is at 00:31. Which of these depend on the spacecraft being in view?', + options: [ + 'None of them - the plan, the key and the worksheet are all ground-side, so all three can be finished before AOS', + 'The rotation - the far end must be in view to acknowledge the new key, so it waits for the 00:31 window', + 'The budget - it needs a live C/N from the modem to compute, so the worksheet cannot be signed until AOS', + 'The plan - a contact can only be allocated while its bird is above the mask, so it is built pass by pass', + ], + correctIndex: 0, + explanation: + 'The plan, the key and the worksheet are all ground-side. The pass is seven and a half minutes and none of it should be spent on preparation. The chain goes up inside the window, after the downlink is proven cold. Sixteen minutes to AOS. Shift clock started.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 5 / BEAT 1: PLAN, KEY, BUDGET, CHAIN + // ============================================================ + { + id: 'build-the-night-plan', + nice: ['K0689', 'T0129'], + title: 'Build the Night Contact Plan', + description: 'Allocate the required contacts across GW-01 and SH-02 with no site double-booked. The plan must read DECONFLICTED.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Dropped Contact Justified', + params: { + character: Character.SYSTEM, + question: 'The 04:25 Shetland overhead and the 04:27 Galway pass are the same orbit. One operator, two sites. What happens to the overhead?', + options: [ + 'It is priority 3 and may be dropped: the plan is valid without it, and one operator cannot fly both sites at once', + 'It must be allocated to SH-02: the plan is invalid without it, and a site with a pass in view cannot sit idle', + 'Both go to Galway: the higher pass wins, and one pedestal can take the two contacts back to back', + 'It replaces the Galway pass: 72 degrees at Shetland is the steeper geometry, so it is the one to fly alone', + ], + correctIndex: 0, + explanation: + 'The plan requires priority 2 and above. Dropping a P3 is a decision, and the plan records who made it and why: Fiona is off shift, and the 73 degree Galway pass is the stronger one to fly alone.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'night-key-rotation', + nice: ['K0874', 'K0728', 'T1567'], + title: 'Complete the COMSEC Rotation', + description: 'The scheduled rotation is pending on the command key. Initiate, then confirm, before the pass - or nothing you send will authenticate.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'key-rotation-completed', + description: 'Key Rotation Completed', + params: {}, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'night-budget', + nice: ['T0081', 'K0740'], + title: 'Predict the Night Link', + description: + 'Compute the expected C/N for this pass. The geometry is better than the afternoon passes: 581 km at maximum elevation, free-space path loss 169.1 dB at 11686 MHz. EIRP 28 dBm, receive gain 51.8 dBi, system noise temperature 88 K, bandwidth 36 MHz, miscellaneous losses 1 dB.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'link-budget-computed', + description: 'Predicted C/N Matches Truth', + params: {}, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Range Delta Read', + params: { + character: Character.SYSTEM, + question: 'The afternoon card used 171.4 dB of path loss at 761 km; tonight is 169.1 dB at 581 km. Where do the 2.3 dB come from?', + options: [ + '20 log(761 / 581): a 40 degree pass puts the bird a third closer, and path loss goes with the square of range', + 'A cooler night sky: the noise temperature drops after dark, and the receiver gains 2.3 dB of G/T from a colder LNB', + 'A hotter transmitter: the bird runs warmer on the night side, and the EIRP rises with the amplifier temperature', + 'Less atmosphere at night: gaseous absorption at Ku falls after dark, and 2.3 dB is what the daytime path costs', + ], + correctIndex: 0, + explanation: 'Every dB of the difference is geometry. Predicted 13.3 dB; the card is wrong if the measurement is more than a decibel away.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'gw-reference-and-chain', + nice: ['T0431', 'K0740'], + title: 'GW-01 Reference and BUC Lock', + description: 'GPSDO locked, BUC on the external reference, before the chain goes up.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['build-the-night-plan', 'night-key-rotation', 'night-budget'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GW-01 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'GW-01 BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'chain-up-for-commanding', + nice: ['S0421', 'T1567', 'K0645'], + title: 'Chain Up for Commanding', + description: + 'Downlink proven, window open until 00:38:30. Uplink Doppler compensation on the TT&C console, then the chain in order: carrier into the muted BUC, unmute, HPA. An uplink on this rack spoils the receive measurement, which is why the link was proven cold first. Nobody will check the order for you.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['prove-the-night-link'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'uplink-doppler-comp-enabled', + description: 'Uplink Doppler Compensation Engaged', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-transmitting', + description: 'Transmit Modem On Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + { + type: 'buc-unmuted', + description: 'BUC Unmuted', + params: {}, + mustMaintain: true, + }, + { + type: 'hpa-enabled', + description: 'HPA Output Enabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // CONTACT 1: MERIDIAN-SAR-1 FROM GALWAY (BEATS 2-4) + // ============================================================ + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'AOS 00:31. Program-track MERIDIAN-SAR-1 and confirm the beacon on RX analysis. Chain cold until the link is proven.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['gw-reference-and-chain'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'prove-the-night-link', + nice: ['T0153', 'T0081', 'K0740'], + title: 'Prove the Night Link', + description: + 'Lock the 1414 MHz imagery downlink, then commit the link in Link Analysis with at least 3 dB of margin over the 6 dB threshold. The strong part of this pass runs 00:33 to 00:37.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'RX Modem Locked on Downlink', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'link-margin-met', + description: 'Measured Margin >= 3 dB', + params: { minMarginDb: 3 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'command-the-bird', + nice: ['T1567', 'S0421', 'K0874'], + title: 'Command the Bird', + description: 'Send REC-PLAYBACK under the rotated key and confirm the acknowledgement before the window closes at 00:38:30.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['chain-up-for-commanding'], + conditions: [ + { + type: 'command-acknowledged', + description: 'REC-PLAYBACK Acknowledged', + params: { commandId: 'REC-PLAYBACK' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'secure-the-chain', + nice: ['S0421', 'K0645'], + title: 'Secure the Chain', + description: 'LOS 00:38:49. Chain down in the mirror order: HPA off, BUC muted, carrier off.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['command-the-bird'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'hpa-disabled', + description: 'HPA Output Disabled', + params: {}, + mustMaintain: true, + }, + { + type: 'buc-muted', + description: 'BUC Muted', + params: {}, + mustMaintain: true, + }, + { + type: 'tx-modem-not-transmitting', + description: 'Transmit Modem Off Air', + params: { modemNumber: 1 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'log-contact-1', + nice: ['T1580', 'K0645'], + title: 'Log the First Contact', + description: 'Write contact 1 into the handover before you skip ahead. The next window is two and a half hours away and at the other site.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['secure-the-chain'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Contact 1 Logged', + params: { + character: Character.SYSTEM, + question: 'What does the handover need for the 00:31 contact?', + options: [ + 'Rotation completed before the window, link committed with the margin measured, REC-PLAYBACK acknowledged, chain cold', + 'Pass worked as planned, link margin within a decibel of the card, command sent at the top, chain left up for the next contact', + 'Rotation left for the day shift, link committed on the afternoon card, REC-PLAYBACK acknowledged, chain cold', + 'Plan validated with the P3 dropped, link budget signed, command sent with the ACK time not recorded, HPA disabled', + ], + correctIndex: 0, + explanation: 'Key state, link number, command result, chain state. The day shift commands on the key you rotated and schedules against the card you signed.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '00:31Z MERIDIAN-SAR-1 GW-01: command key rotated before the window; link committed, predicted 13.3 dB, margin >= 3 dB over 6 dB threshold; REC-PLAYBACK ACK; chain secured HPA-BUC-modem.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // CONTACT 2: MERIDIAN-SAR-2 FROM SHETLAND, ON HOLDOVER (BEATS 5, 1-4) + // ============================================================ + { + id: 'shetland-remote-sweep', + nice: ['T0431', 'S0421', 'K0741'], + title: 'Shetland Remote Sweep', + description: + 'Skip ahead to the 02:54 Shetland window. Select SH-02: BUC temperature normal, LNB thermally stable, and retune modem 1 from the 1414 MHz SAR-1 carrier Fiona left it on to 1370 MHz for SAR-2.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['log-contact-1'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'buc-temperature-normal', + description: 'SH-02 BUC Temperature Normal', + params: { maxTemperature: 70, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'lnb-thermally-stable', + description: 'SH-02 LNB Thermally Stable', + params: { requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'holdover-diagnosis', + nice: ['T0431', 'K0740', 'K0741'], + title: 'Read the Shetland Reference', + description: + 'The SH-02 GPS Timing panel is not what you left it. Read it - lock, GNSS, satellite count, holdover - and decide what the reference is doing and whether the pass can be flown on it.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-remote-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + description: 'SH-02 GPS Timing Panel Open', + params: { tab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Holdover Diagnosed', + params: { + character: Character.SYSTEM, + question: 'SH-02 GPSDO: locked, HOLDOVER active, GNSS input switch up, zero satellites. What is happening, and what do you do about the 02:54 pass?', + options: [ + 'A GNSS outage - the switch is up and nothing is received; the oscillator holds for tens of minutes, so fly the pass on holdover', + 'A switch left down - somebody isolated the GNSS input; cycle the switch and wait for lock before AOS, then fly the pass', + 'A lost reference - the oscillator has nothing to discipline it; the 02:54 pass cannot be flown from Shetland tonight', + 'A failed oscillator - holdover with zero satellites means it has stopped; power-cycle the GPSDO and re-lock before AOS', + ], + correctIndex: 0, + explanation: + 'Holdover is what the disciplined oscillator is for: GNSS disciplines it, and when GNSS goes away it coasts on the last correction, within microseconds for tens of minutes. Switch up with zero satellites is an outage, not an operator error; cycling the switch cannot conjure a signal. Leave the switch alone and confirm re-lock when the signal returns. A few minutes of holdover is a few microseconds and a few hertz at Ku.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'acquire-sar2-from-shetland', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-2 from Shetland', + description: 'AOS 02:54:39 from azimuth 042. Program-track MERIDIAN-SAR-2 on the SH-02 pedestal, on holdover, and confirm the carrier on Shetland’s RX analysis.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['holdover-diagnosis'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink on the Shetland Analyzer', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'telemetry-contact', + nice: ['T0153', 'K0740', 'K1032'], + title: 'Work the Telemetry Contact', + description: + 'A 13 degree pass at 1233 km will not decode imagery. Hold lock and C/N above 5 dB through culmination (02:57:34) for the state-of-health frames, and read what the geometry did to the number.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['acquire-sar2-from-shetland'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 5 dB', + params: { modemNumber: 1, minCNRatio: 5, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Geometry Read', + params: { + character: Character.SYSTEM, + question: 'Contact 1 peaked at 13.4 dB at 40 degrees and 581 km. This one peaks near 6.6 dB at 13 degrees and 1233 km. Is the Shetland station underperforming?', + options: [ + 'No: 20 log(1233 / 581) is 6.5 dB of extra path loss, and 13 degrees of elevation adds atmosphere on top of that', + 'Yes: the same hardware at both sites should give the same C/N, and 6.6 dB against 13.4 dB is a receive chain fault', + 'Yes: holdover has degraded the reference, and a drifting LO costs the demodulator several dB of implementation loss', + 'Unknown: the outage has to clear and the reference re-lock before a C/N reading from Shetland can be trusted', + ], + correctIndex: 0, + explanation: + 'The number is what the geometry predicts. A telemetry contact on a low pass is worked for what it can give: lock and frames, not imagery. A site is underperforming only when its number is worse than its geometry.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'confirm-reference-recovered', + nice: ['T0431', 'K0740', 'T1580'], + title: 'Confirm the Shetland Reference Recovered', + description: 'The GNSS signal returns at 02:59. Confirm on the SH-02 GPS Timing panel that the receiver has re-locked and left holdover, then log contact 2.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['telemetry-contact'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-gnss-locked', + description: 'SH-02 GNSS Receiving Again', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'SH-02 Out of Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Contact 2 Logged', + params: { + character: Character.SYSTEM, + question: 'What does the handover need about the Shetland reference?', + options: [ + 'GNSS outage 02:44 to 02:59, reference held on the oscillator through the contact, re-locked on return, no action at the switch', + 'GPSDO fault 02:44 to 02:59, reference lost through the contact, replacement unit requested, switch cycled to recover', + 'Nothing beyond the pass result, reference recovered on its own, no fault to record, no action for the day shift', + 'GNSS outage 02:44 to 02:59, contact flown without a reference, frames unusable, switch cycled at 02:59 to force re-lock', + ], + correctIndex: 0, + explanation: + 'The day shift needs to know the outage was real and the site recovered itself. A fault that cleared is still a fault. The day shift decides whether a fifteen-minute GNSS outage in a Shetland gale is weather or a failing antenna; they cannot decide it without the record.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '02:54Z MERIDIAN-SAR-2 SH-02 (remote): 13 deg telemetry contact, locked, C/N >= 5 dB, peak ~6.6 dB. SH-02 GNSS outage 02:44-02:59Z, reference in holdover through the contact (switch up, zero satellites), re-locked on return. No action at the switch.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // CONTACT 3: MERIDIAN-SAR-2 OVER GALWAY, AFTER THE KEY ROLL (BEATS 5, 1-3) + // ============================================================ + { + id: 'traffic-key-alarm', + nice: ['K0728', 'K0874', 'T0431'], + title: 'Read the Traffic Key', + description: 'Skip ahead to the 04:27 Galway window and select GW-01. Before the pass, read the payload crypto on RX analysis. It is not what you left it either.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['confirm-reference-recovered'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Traffic Key Reads Mismatch', + params: { keyStatus: 'Mismatch', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Mismatch Diagnosed', + params: { + character: Character.SYSTEM, + question: + 'Traffic key Mismatch on the payload crypto, command key still Valid, nobody has touched the station since 00:40. What happened, and what does it do to the 04:27 pass?', + options: [ + 'Rotterdam rolled the fleet traffic key and the station did not follow; the imagery will not decrypt until the payload crypto is re-keyed', + 'Your rotation at 00:20 corrupted the traffic key; the imagery will not decrypt until the command key is rolled back and re-issued', + 'The spacecraft zeroized its traffic key on a tamper event; nothing decrypts until Rotterdam uploads a new key over the command link', + 'The holdover reference at Shetland desynchronised the crypto; the imagery will not decrypt until both sites are back on GNSS', + ], + correctIndex: 0, + explanation: + 'Two keys, two schedules, and the one you did not rotate is the one that moved on schedule. The command key is a different key and is fine. A mismatch is the two ends of a handshake on different keys; the fix is on the payload panel, not the TT&C console.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 're-key-before-aos', + nice: ['K0728', 'K0874', 'S0421'], + title: 'Re-key the Payload Crypto', + description: 'Load the new traffic key on the TX chain payload panel and confirm both directions read Valid before AOS.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['traffic-key-alarm'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tx-key-status', + description: 'TX Traffic Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'rx-key-status', + description: 'RX Traffic Key Valid', + params: { keyStatus: 'Valid', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'acquire-sar2-galway', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-2 over Galway', + description: + 'AOS 04:27:31 from azimuth 018, and this one goes to 73 degrees. Retune modem 1 to 1370 MHz if you have not, program-track MERIDIAN-SAR-2 and confirm the carrier on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['re-key-before-aos'], + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink Detected', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-the-steep-pass', + nice: ['T0153', 'K0740', 'K0728'], + title: 'Decode the Steep Pass', + description: 'Lock the 1370 MHz carrier, hold C/N above 8 dB through the top of the pass (04:31:23, 73 degrees, 408 km) and confirm the payload decrypts under the new key.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar2-galway'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'rx-crypto-status', + description: 'Payload Decrypting', + params: { cryptoMode: 'ACTIVE', requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Steep Pass Read', + params: { + character: Character.SYSTEM, + question: 'C/N peaks near 16.5 dB at 73 degrees, the best number of the night, and the pedestal held it through the top. Why did this one not keyhole?', + options: [ + 'The azimuth rate at the top of a 73 degree pass is a few degrees a second, inside the 20 deg/s the pedestal can do', + 'Program-track disables the azimuth axis above 60 degrees, so the pedestal rides through the top on elevation alone', + 'The bird slowed down at the top of the pass, so the azimuth rate at culmination stayed inside what the pedestal can do', + 'It did keyhole for a few seconds at the top, and the receiver held lock through it on frames stored in the modem buffer', + ], + correctIndex: 0, + explanation: + 'The keyhole is a near-zenith problem, where the azimuth rate goes to infinity. Steep is strong, and 73 degrees is still tracking. The last few degrees to the zenith are a different problem, and a different shift.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + + // ============================================================ + // BEAT 4: HANDOVER + // ============================================================ + { + id: 'hand-over-the-shift', + nice: ['T0081', 'T1580', 'K0645'], + title: 'Hand Over the Shift', + description: 'Close the handover for the qualification: three contacts, two sites, two faults found and cleared.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-the-steep-pass'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Shift Closed', + params: { + character: Character.SYSTEM, + question: 'Plan built, key rotated, link proven, command acknowledged, a holdover flown through and a traffic key re-keyed. What carries over to the day shift?', + options: [ + 'The contact plan, the COMSEC state of both keys, the Shetland outage record and the state of both sites', + 'Nothing beyond the pass results; each shift starts clean, and the day shift builds its own plan and checks its own keys', + 'Only the recorded telemetry and imagery, which go to the customer, and the card the link was committed on', + 'The dropped 04:25 Shetland contact and the reason it was dropped, so the day shift can reschedule it with Fiona', + ], + correctIndex: 0, + explanation: 'The next operator inherits all of it, not just the pass results. Qualified for NATS Europe LEO operations. The network is yours to run.', + pointPenalty: 5, + documentSection: 'Handover', + documentLine: + '04:27Z MERIDIAN-SAR-2 GW-01: fleet traffic key rolled 04:15Z, payload crypto re-keyed before AOS (TX/RX Valid), 73 deg pass decoded, peak C/N ~16.5 dB. Dropped: N-SAR2-SH-2 (P3, one operator). Both sites cold, references locked, plan DECONFLICTED. Shift handed over.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Charlie Brooks at 23:40] +

+

+ "Evening shift left it tidy. SAR-1 has a 40 degree pass at 00:31 and it is the best geometry this station has seen. Rotation is pending on the command key. Rotterdam wants REC-PLAYBACK on the pass. Contact plan for the night is not built, and after Fiona goes off at one the Shetland console is yours as well. This is the qualification shift. Everything on it you have done once. Nobody is going to prompt you to do it again." +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'build-the-night-plan': { + text: ` +

+ [Text message from Fiona MacLeod at 00:17] +

+

+ "On console till one. I'll take whatever the plan gives me before then and after that SH-02 is yours, remote. The GNSS antenna up here has been flaky in the wind all week; if it drops out you know what holdover looks like. Don't ring unless the building's on fire." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'command-the-bird': { + text: ` +

+ "ACK on REC-PLAYBACK, authenticated under the new key. Thank you, Galway. Nothing more from us tonight - the traffic key rolls on the fleet schedule at 04:15, as it does every Tuesday." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'log-contact-1': { + text: ` +

+ [Text message from Dana Torres at 00:41] +

+

+ "Charlie says tonight's the one. Same as Vermont: nobody's coming, and that's the point. Text me when it's done." +

+ `, + character: Character.DANA_TORRES, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'shetland-remote-sweep': { + text: ` +

+ [Text message from Fiona MacLeod at 00:58] +

+

+ "Off. SAR-1 was seventeen degrees and decoded, for the record. Modem's still on 1414. Station's yours. Night." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'traffic-key-alarm': { + text: ` +

+ [Automated notice from MERIDIAN Ops at 04:15] +

+

+ "Fleet traffic key rotation executed 04:15:00Z per schedule. Sites: load new material before your next payload contact." +

+ `, + character: Character.ANNEKE_VISSER, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'decode-the-steep-pass': { + text: ` +

+ [Text message from Fiona MacLeod at 04:33] +

+

+ "Couldn't sleep, watched yours from the flat. Seventy-three degrees and it never wobbled. Mine would've gone over the top and lost it. Right call dropping it." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'hand-over-the-shift': { + text: ` +

+ That was your night. Three contacts, two sites, a reference that went walkabout, a key that did not match and a plan that had to drop something. You did not call me. Good. +

+ `, + character: Character.CHARLIE_BROOKS, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats-eu/scenario9.ts b/src/campaigns/nats-eu/scenario9.ts new file mode 100644 index 00000000..832127b0 --- /dev/null +++ b/src/campaigns/nats-eu/scenario9.ts @@ -0,0 +1,1036 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { Character, Emotion } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, MHz } from '@app/types'; +import type { Degrees, TleLine1, TleLine2 } from 'ootk'; +import { galwayGroundStation, shetlandGroundStation } from './ground-stations'; +import { createMeridianSar1, createMeridianSar2, type MeridianTle } from './satellites'; + +/** + * nats-eu Scenario 9 - "Morning Constellation" / Network Health & Daily Pass Plan + * + * First qualified shift (Phase 2 opener). No new mechanics. Phase 16 rewrite: + * the whole morning, not just the two passes. Both sites are swept from the + * Galway console (dashboard, reference, receive chain, transmit chain: four + * objectives per site), the six-contact day plan is built, then the network is + * flown: MERIDIAN-SAR-1 from Galway for the customer's standing collect, + * Galway armed on program-track for its SAR-2 window and left to fly it + * unattended, the Shetland console taken remotely when Fiona goes out to the + * pedestal, MERIDIAN-SAR-2 flown FROM SH-02 (phase 16 E1: per-station + * propagation), and the unattended Galway pass checked on the way back. + * + * This is the first scenario with its own epoch and its own element sets. The + * clock starts 2027-03-17 06:00:00 UTC (moved 10 min earlier in phase 16 so + * the sweeps fit before the first window) and the birds are built here with + * the satellites.ts factories, so nothing this scenario does to a TLE can leak + * into another scenario's sky. + * + * Pass timeline (scripts/author-passes.mjs, verify-only, epoch 06:00:00Z): + * + * GW-01 MERIDIAN-SAR-1 #1: AOS T+18.0 (06:18:00Z, az 143), max el 30.5 deg at + * T+22.8 (06:22:47Z, 716 km), LOS T+27.6 (06:27:33Z, + * az 355), 9.5 min, northbound. Peak C/N ~11 dB. + * SH-02 MERIDIAN-SAR-1 #1: AOS T+19.1 (06:19:07Z, az 167), max el 72.8 deg at + * T+24.1 (409 km), LOS T+29.1 (06:29:04Z). Fiona's. + * SH-02 MERIDIAN-SAR-2 #1: AOS T+30.0 (06:30:00Z, az 011), max el 23.0 deg at + * T+34.6 (06:34:38Z, 879 km), LOS T+39.3 (06:39:17Z, + * az 233), 9.3 min, southbound. Flown from SH-02: + * peak C/N 9.7 dB, >= 7 dB for ~3.5 min (harness). + * GW-01 MERIDIAN-SAR-2 #1: AOS T+32.0 (06:32:00Z, az 006), max el 25.2 deg at + * T+36.7 (06:36:43Z, 823 km), LOS T+41.4 (06:41:22Z, + * az 224), 9.4 min, southbound. Unattended. + * GW-01 MERIDIAN-SAR-1 #2: AOS T+109.9 (07:49:51Z), max el 18.3 deg, LOS T+119.0 + * SH-02 MERIDIAN-SAR-2 #2: AOS T+122.0 (08:01:59Z), max el 4.8 deg (graze) + * + * Contact windows: 0 deg AOS/LOS rounded to 10 s for BOTH sites, propagated per + * station (test/campaigns/nats-eu-contact-windows checks each window against its + * `stationId` pass). C/N thresholds are flown in + * test/campaigns/nats-eu-phase-c-validation.test.ts, the Shetland one from + * Shetland. + * + * Staged state (scenario-local clone): GW-01 RX modem 1 on the 1370 MHz SAR-2 + * carrier from the evening shift's last contact, analyzer at its 1414 / 60 MHz + * default. SH-02 is the shared default (modem on 1414, ready for Fiona's SAR-1). + * + * RF is the S1 envelope, unchanged: video 28 dBm EIRP, IF 1414 / 1370 MHz, + * C/N peaks ~11 dB at 25-30 deg max elevation under real program-track. No faults. + * + * NICE Framework Alignment: + * Primary Codes: + * - T0431: Check system hardware availability, functionality, integrity, and efficiency + * - K0689: Knowledge of network systems management principles and tools + * - T0153: Monitor network capacity and performance + * + * Supporting Codes: + * - K0741: Knowledge of system administration concepts + * - T0129: Coordinate and manage system operations schedules + * - S0421: Skill in operating network equipment + * - K0740: Knowledge of system performance indicators + * - K0773: Knowledge of telecommunications principles and practices + * - K1032: Knowledge of satellite-based communication systems + * - K0645: Knowledge of standard operating procedures + * - S0478: Skill in communicating with customers + * - T1580: Report service status to stakeholders + */ + +/** MERIDIAN-SAR-1 (61701) element set at the S9 epoch. */ +const SAR1_S9_TLE: MeridianTle = { + tle1: '1 61701U 27015A 27076.25694444 .00001000 00000-0 10000-3 0 9995' as TleLine1, + tle2: '2 61701 97.2000 280.0000 0010000 90.0000 275.7500 15.60000000123458' as TleLine2, +}; + +/** MERIDIAN-SAR-2 (61702) element set at the S9 epoch. */ +const SAR2_S9_TLE: MeridianTle = { + tle1: '1 61702U 27015A 27076.25694444 .00001000 00000-0 10000-3 0 9996' as TleLine1, + tle2: '2 61702 98.4000 62.2500 0010000 90.0000 289.5000 15.60000000123455' as TleLine2, +}; + +const meridianSar1S9 = createMeridianSar1(SAR1_S9_TLE); +const meridianSar2S9 = createMeridianSar2(SAR2_S9_TLE); + +/** GW-01 as the evening shift left it: modem on SAR-2. Deep clone, never spread. */ +const galwayMorning: GroundStationConfig = structuredClone(galwayGroundStation); +galwayMorning.receivers![0].modems![0] = { + ...galwayMorning.receivers![0].modems![0], + frequency: 1370 as MHz, +}; + +export const natsEuScenario9Data: ScenarioData = { + id: 'nats-eu-scenario9', + url: 'nats-eu/scenarios/nats-eu-scenario9', + imageUrl: 'nats/9/card.png', + number: 9, + isDisabled: false, + difficulty: 'intermediate', + prerequisiteScenarioIds: ['nats-eu-scenario8'], + title: 'Morning Constellation', + subtitle: 'Network Health & Daily Pass Plan', + duration: '35 min', + missionType: 'Network Operations', + description: `06:00 local. First qualified shift. Two sites, two birds, six contacts on the board and nobody to hand you the plan.

Erik Halvorsen at Nordic Maritime Watch has a standing collect on the 06:18 MERIDIAN-SAR-1 window and wants to know it is covered. Fiona is on the SH-02 console for her 73 degree pass and then she is outside on the feed heater, which makes the Shetland SAR-2 window at 06:30 yours, remotely, at the same time as Galway's own.

Sweep both sites before the first window, build the day plan, fly the collect, arm Galway to fly its second window on its own, take Shetland's from here, and tell Erik what he can count on.`, + equipment: [ + 'GW-01 Galway: 4m Ku-Band LEO Tracker', + 'SH-02 Shetland: 4m Ku-Band LEO Tracker (remote console)', + 'Contact Plan Console', + 'Ku-Band RF Front End (GPSDO / LNB / BUC / HPA)', + 'RX Modem with Video Decoder', + ], + settings: { + isSync: true, + groundStations: [galwayMorning, shetlandGroundStation], + satellites: [meridianSar1S9, meridianSar2S9], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-03-17', + scenarioStartWallTime: '06:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-2/scenario-9?content-only=true&dark=true', + + contactTimeline: { + horizonHours: 3, + minElevation: 5 as Degrees, + showLighting: true, + }, + + // The two P3 second-orbit contacts sit at T+110 and T+122 min. If the + // operator keeps them in the plan, the skip gets there without sitting + // through an hour of empty sky. The skip stops 2 min before the NEXT pass + // at either site and is blocked while a timed objective runs, so skipping + // before the morning windows are worked forfeits them. + timeSkip: { + leadTimeS: 120, + minSkipS: 300, + horizonHours: 4, + }, + + workingDocument: { + title: 'NATS Europe Morning Report', + description: 'Qualified shift, 2027-03-17. One line per contact worked, and the customer status as sent.', + }, + + // M3 - the day plan. Two conflict pairs (SAR-1 at T+18, SAR-2 at T+30), + // each of which has to be split across the sites, plus two P3 second-orbit + // contacts that may be left unassigned: requiredPriorityAtOrAbove 2 means + // only the four P1/P2 contacts have to be covered for the plan to validate. + // Windows are 0 deg horizon crossings from the 06:00 epoch, per station. + contactSchedule: { + stationIds: ['GW-01', 'SH-02'], + requiredPriorityAtOrAbove: 2, + contacts: [ + { id: 'M-SAR1-GW', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Galway, 30 deg pass)', priority: 1, windowStartS: 1080, windowEndS: 1650, stationId: 'GW-01' }, + { id: 'M-SAR1-SH', satelliteNoradId: 61701, label: 'MERIDIAN-SAR-1 (Shetland, 73 deg pass)', priority: 2, windowStartS: 1150, windowEndS: 1750, stationId: 'SH-02' }, + { id: 'M-SAR2-GW', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Galway, 25 deg pass)', priority: 1, windowStartS: 1920, windowEndS: 2480, stationId: 'GW-01' }, + { id: 'M-SAR2-SH', satelliteNoradId: 61702, label: 'MERIDIAN-SAR-2 (Shetland, 23 deg pass)', priority: 1, windowStartS: 1800, windowEndS: 2360, stationId: 'SH-02' }, + { + id: 'M-SAR1-GW-2', + satelliteNoradId: 61701, + label: 'MERIDIAN-SAR-1 (Galway, second orbit, 18 deg)', + priority: 3, + windowStartS: 6590, + windowEndS: 7140, + stationId: 'GW-01', + }, + { + id: 'M-SAR2-SH-2', + satelliteNoradId: 61702, + label: 'MERIDIAN-SAR-2 (Shetland, second orbit, 5 deg graze)', + priority: 3, + windowStartS: 7320, + windowEndS: 7690, + stationId: 'SH-02', + }, + ], + }, + }, + objectives: [ + // ============================================================ + // MISSION PREPARATION + // ============================================================ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0737'], + title: 'Take the Shift', + description: 'Open the brief; confirm the six contacts, who flies what, and the health-check list for both sites.', + groundStation: 'GW-01', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Shift Brief Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Plan Rule Confirmed', + params: { + character: Character.SYSTEM, + question: 'Six contacts, two sites. What must be true before you publish a plan to Fiona?', + options: [ + 'No site is double-booked and every P1 and P2 contact is assigned to a station.', + 'Every contact on the board, including the P3 second-orbit passes, is assigned to Galway.', + 'Both sites are assigned to the same contact so there is a backup.', + 'Every contact is assigned to the site with the higher maximum elevation.', + ], + correctIndex: 0, + explanation: + 'Overlaps go to different sites, the P1/P2 contacts are all covered, and the P3 passes are yours to keep or drop. Eighteen minutes to the first window. Shift clock started.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEAT 1: GALWAY SWEEP (FOUR CHECKS) + // ============================================================ + { + id: 'galway-dashboard-sweep', + nice: ['T0153', 'K0741'], + title: 'GW-01 Dashboard Sweep', + description: 'Clear the home board first: confirm the active alarm state on GW-01.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['review-mission-brief'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Alarm State Confirmed', + params: { + character: Character.SYSTEM, + question: 'What is the active alarm state on GW-01 at turnover?', + options: [ + 'RX AGC at max gain - weak signal on an empty sky, no hardware alarm', + 'No active alarms - all systems nominal, board clear at turnover', + 'GPSDO in holdover - reference alarm, timing at risk for the pass', + 'Antenna drive fault - hardware alarm, pedestal down for the pass', + ], + correctIndex: 0, + explanation: + 'The AGC rail is empty sky, not a fault. The evening shift left the hardware healthy and the receiver on the wrong bird; the next three checks find which is which.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'galway-reference-check', + nice: ['T0431', 'K0740'], + title: 'GW-01 Reference Check', + description: 'GPSDO locked and out of holdover. Every LO in the rack keys off it; confirm it before the plan commits the site to anything.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'gpsdo-locked', + description: 'GW-01 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'GW-01 GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'galway-receive-chain', + nice: ['T0431', 'S0421', 'K0773'], + title: 'GW-01 Receive Chain', + description: + 'LNB on the 13100 MHz LO and thermally stable. The evening shift left modem 1 on the 1370 MHz SAR-2 carrier: retune it to 1414 MHz for the 06:18 SAR-1 collect and put the analyzer on the SAR-1 beacon, 1389 MHz, with a 2 MHz span.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'lnb-lo-set', + description: 'GW-01 LNB LO 13100 MHz', + params: { loFrequency: 13100 as MHz, loFrequencyTolerance: 0, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'lnb-thermally-stable', + description: 'GW-01 LNB Thermally Stable', + params: { requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1414 MHz', + params: { modemNumber: 1, frequency: 1414e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'GW-01 Analyzer Centre 1389 MHz', + params: { centerFrequency: 1389e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-span-set', + description: 'GW-01 Analyzer Span 2 MHz', + params: { span: 2e6, frequencyTolerance: 1e6 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'galway-transmit-chain', + nice: ['T0431', 'K0740', 'K0645'], + title: 'GW-01 Transmit Chain', + description: 'No uplink is tasked today. Confirm the chain is cold the right way: BUC on the external reference with normal current, HPA output disabled.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-receive-chain'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'buc-reference-locked', + description: 'GW-01 BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'buc-current-normal', + description: 'GW-01 BUC Current Normal', + params: { maxCurrentDraw: 4.5, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'hpa-disabled', + description: 'GW-01 HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Cold Chain Read', + params: { + character: Character.SYSTEM, + question: 'Receive-only shift. Which transmit-chain state is the correct "cold" for a site that might be tasked later today?', + options: [ + 'Modem off air, HPA output disabled, BUC muted - everything still powered and on the reference so it can come up in order', + 'HPA output enabled with the modem off, BUC unmuted - the chain is ready the moment a command arrives, with no carrier up', + 'BUC and HPA powered off entirely, modem off air - nothing draws power or warms the feed until tasking comes in', + 'Modem off air, HPA output enabled, BUC muted - the mute is the only barrier needed, and the HPA stays warm for tasking', + ], + correctIndex: 0, + explanation: + 'Cold is not off. An HPA enabled with nothing driving it amplifies the noise floor into the feed, and a chain that was powered down needs a reference lock and a warm-up before it can be trusted. Powered, locked, disabled.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEAT 5: THE DAY PLAN + // ============================================================ + { + id: 'build-the-day-plan', + nice: ['K0689', 'T0129'], + title: 'Build the Day Plan', + description: + "Allocate the morning's contacts across GW-01 and SH-02 with no site double-booked; leave the P3 second-orbit contacts to your judgment. The plan must read DECONFLICTED.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['galway-transmit-chain'], + timeLimitSeconds: 4 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'tab-active', + hidden: true, + description: 'Contact Plan Open', + params: { tab: 'contact-schedule' }, + mustMaintain: false, + }, + { + type: 'contact-assigned', + description: 'SAR-1 06:18 Window on GW-01', + params: { contactId: 'M-SAR1-GW', groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'contact-assigned', + description: 'SAR-2 Shetland Window on SH-02', + params: { contactId: 'M-SAR2-SH', groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'contact-plan-valid', + description: 'Contact Plan Valid', + params: {}, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'SAR-2 Pair Resolved', + params: { + character: Character.SYSTEM, + question: 'M-SAR2-SH opens at 06:30 and M-SAR2-GW at 06:32, both priority 1, and Fiona is outside from 06:29. One operator, two sites. How does the plan get flown?', + options: [ + 'Galway is armed on program-track and flies its window unattended; Shetland has no unattended-pass sign-off yet, so it is flown from here', + 'Drop the Galway SAR-2 contact from the plan; one operator cannot fly both sites, and Shetland is the site Fiona cannot cover', + 'Fly Galway from here and let Shetland miss its window; the customer has one collect on the board, and one site covers it', + 'Assign both contacts to SH-02 in the plan; one pedestal takes them back to back, and its console is the one in front of you', + ], + correctIndex: 0, + explanation: + 'A site does not need an operator to track a pass it has been armed for; it needs one to notice when it does not. GW-01 has been signed off for unattended passes since acceptance. SH-02 went live four days ago and has not. That is the difference the plan has to respect.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 5 -> BEAT 1: SHETLAND SWEEP (FOUR CHECKS, REMOTE) + // ============================================================ + { + id: 'shetland-dashboard-sweep', + nice: ['T0153', 'K0741', 'S0421'], + title: 'SH-02 Dashboard Sweep', + description: 'Select SH-02 in the asset tree and clear its board from here: confirm the active alarm state on Shetland.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['build-the-day-plan'], + timeLimitSeconds: 90, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'tab-active', + hidden: true, + description: 'Dashboard Open', + params: { tab: 'dashboard' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Remote Board Read', + params: { + character: Character.SYSTEM, + question: 'SH-02 shows one warning, the receive AGC at its rail, and nothing else. What does that board on a remote site tell you, and what does it not?', + options: [ + 'Nothing in the alarm set is tripped and the AGC rail is empty sky; it says nothing about configuration', + 'The site is ready to fly and the receiver is on the carrier; it says nothing about whether the pedestal is armed', + 'Fiona has already checked it and left it configured; it says nothing that the rest of the sweep would add', + 'The equipment is powered and the reference is locked; it says nothing about whether the receive chain has a fault', + ], + correctIndex: 0, + explanation: + 'Configuration is what the next three checks are for. An alarm is a threshold crossed, and an AGC at its rail with nothing in the beam is the threshold behaving. A modem on the wrong carrier, an analyzer on the wrong span, an antenna stowed the wrong way - none of those is an alarm, and any of them loses a pass.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'shetland-reference-check', + nice: ['T0431', 'K0740'], + title: 'SH-02 Reference Check', + description: 'Shetland GPSDO locked and out of holdover. The GNSS antenna up there has been flaky in the wind all week.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-dashboard-sweep'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'gpsdo-locked', + description: 'SH-02 GPSDO Locked', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + { + type: 'gpsdo-not-in-holdover', + description: 'SH-02 GPSDO Not in Holdover', + params: { requiresObservation: true, observationTab: 'gps-timing' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'shetland-receive-chain', + nice: ['T0431', 'K0773', 'K1032'], + title: 'SH-02 Receive Chain', + description: + "Shetland's LNB on the 13100 MHz LO and thermally stable. Leave modem 1 on 1414 MHz - Fiona flies SAR-1 on it at 06:19 - and read her pass on the schedule while you are there.", + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-reference-check'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'lnb-lo-set', + description: 'SH-02 LNB LO 13100 MHz', + params: { loFrequency: 13100 as MHz, loFrequencyTolerance: 0, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'lnb-thermally-stable', + description: 'SH-02 LNB Thermally Stable', + params: { requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Shetland Geometry Read', + params: { + character: Character.SYSTEM, + question: + "Same LNB, same 60 K. Fiona's SAR-1 pass peaks at 73 degrees and 409 km; Galway's peaks at 30 degrees and 716 km with about 11 dB of C/N. What should Shetland see at the top, before the pedestal has its say?", + options: [ + 'About 16 dB: 20 log(716 / 409) is 4.9 dB less path loss, and almost no atmosphere that steep', + 'About 11 dB: same bird, same LNB and same 60 K, so the same number at whatever elevation', + 'About 8 dB: a steep pass is shorter, so the receiver has less time to integrate the carrier', + 'Nothing usable: 73 degrees is inside the keyhole, so the pedestal loses the bird at the top of the pass', + ], + correctIndex: 0, + explanation: + 'Range wins, and a site at 60 north gets more of these on a sun-synchronous bird than a site at 53. 73 degrees is still tracking on a 20 deg/s pedestal; the keyhole is the last few degrees to the zenith, and that is a later shift.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'shetland-transmit-chain', + nice: ['T0431', 'K0740', 'S0421'], + title: 'SH-02 Transmit Chain', + description: 'Shetland BUC on the external reference and under 70 degrees, HPA output disabled. Cold, the right way, three hundred miles from the rack.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['shetland-receive-chain'], + timeLimitSeconds: 2 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'buc-reference-locked', + description: 'SH-02 BUC on External Reference', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + { + type: 'buc-temperature-normal', + description: 'SH-02 BUC Temperature Normal', + params: { maxTemperature: 70, requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: false, + }, + { + type: 'hpa-disabled', + description: 'SH-02 HPA Output Disabled', + params: { requiresObservation: true, observationTab: 'tx-chain' }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + + // ============================================================ + // BEATS 2-3: THE STANDING COLLECT FROM GALWAY + // ============================================================ + { + id: 'acquire-sar1', + nice: ['S0421', 'K1032'], + title: 'Acquire MERIDIAN-SAR-1', + description: 'Back to GW-01. AOS 06:18 from azimuth 143. Program-track MERIDIAN-SAR-1 and confirm the beacon on RX analysis.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['shetland-transmit-chain'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Tracking SAR-1', + params: { noradId: 61701 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-1 Beacon Detected', + params: { + signalId: 'MERIDIAN-SAR-1-Beacon', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'decode-the-standing-collect', + nice: ['T0153', 'S0421', 'K0740'], + title: 'Decode the Standing Collect', + description: "Lock the 1414 MHz imagery downlink and hold C/N above 8 dB through the high segment, roughly 06:20:30 to 06:25. This is Erik's contact.", + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['acquire-sar1'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'GW-01 RX Modem Locked on SAR-1', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 8 dB', + params: { modemNumber: 1, minCNRatio: 8, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Delivery Standard Read', + params: { + character: Character.SYSTEM, + question: 'C/N peaks near 11 dB at 06:22:47, 30 degrees, 716 km. Erik will ask whether the frames are "good". What is the technically honest answer?', + options: [ + 'Decoded with about 5 dB over the QPSK 3/4 threshold: at this margin the frames are clean, with no partly decoded ones', + 'Good but degraded: anything under 12 dB loses detail, so at 11 dB the frames are usable but not at full quality', + 'Unknown until Rotterdam processes them: a C/N figure says the link held, not that the payload data inside is intact', + 'Marginal: 11 dB is close to the 8 dB delivery standard, so the frames are in but a fade would have cost them', + ], + correctIndex: 0, + explanation: + 'Digital links are a cliff, not a slope. Above threshold the frames are the frames; below it there are none. 8 dB is the delivery standard because it leaves 2 dB for weather and pointing, not because 8 dB frames look different from 11 dB ones.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 4/5: ARM GALWAY, TAKE SHETLAND + // ============================================================ + { + id: 'arm-galway-for-sar2', + nice: ['S0421', 'K0645', 'T0129'], + title: 'Arm Galway for SAR-2', + description: + 'Galway flies its 06:32 SAR-2 window without you. Before you leave the console: modem 1 to the 1370 MHz SAR-2 carrier, program-track with MERIDIAN-SAR-2 as the target. The pedestal acquires on its own at AOS.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-the-standing-collect'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'rx-modem-frequency-set', + description: 'GW-01 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'antenna-tracking-mode-set', + description: 'GW-01 Program-Track Armed', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Unattended Pass Understood', + params: { + character: Character.SYSTEM, + question: 'What does an unattended pass need from you before you leave the console, and what can it not do for itself?', + options: [ + 'Receiver on the right carrier and program-track on the right target; it cannot notice a bad number, so you check on return', + 'Nothing before you leave; the scheduler configures the station from the contact plan, but it cannot start the modem recording', + 'A recording started on the modem so the frames are kept; it cannot lock the pedestal, so program-track is left to the scheduler', + 'Step-track enabled so the pedestal finds the bird without elements; it cannot judge C/N, so the number is read on return', + ], + correctIndex: 0, + explanation: + 'The contact plan allocates; it does not configure. Program-track on current elements does not need an operator to acquire. What it needs is one to look at the C/N afterwards and decide whether the pass counts.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'take-the-shetland-console', + nice: ['S0421', 'K0773', 'T0129'], + title: 'Take the Shetland Console', + description: + 'Fiona sets at 06:29 and goes outside. Select SH-02: retune modem 1 from her 1414 MHz SAR-1 carrier to 1370 MHz for SAR-2 and put the analyzer on the SAR-2 beacon, 1397 MHz. AOS 06:30 from azimuth 011.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['arm-galway-for-sar2'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'SH-02 Selected', + params: { groundStationId: 'SH-02' }, + mustMaintain: true, + }, + { + type: 'rx-modem-frequency-set', + description: 'SH-02 RX 1370 MHz', + params: { modemNumber: 1, frequency: 1370e6, frequencyTolerance: 100e3 }, + mustMaintain: true, + }, + { + type: 'speca-center-frequency', + description: 'SH-02 Analyzer Centre 1397 MHz', + params: { centerFrequency: 1397e6, centerFrequencyTolerance: 100e3 }, + mustMaintain: true, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + + // ============================================================ + // BEATS 2-3: MERIDIAN-SAR-2 FROM SHETLAND + // ============================================================ + { + id: 'acquire-sar2-from-shetland', + nice: ['S0421', 'K1032'], + title: 'Acquire SAR-2 from Shetland', + description: 'Program-track MERIDIAN-SAR-2 on the SH-02 pedestal and confirm its imagery carrier on Shetland’s RX analysis once it clears the mask.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['take-the-shetland-console'], + conditions: [ + { + type: 'antenna-tracking-mode-set', + description: 'SH-02 Program-Track Enabled', + params: { trackingMode: 'program-track' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'SH-02 Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'MERIDIAN-SAR-2 Downlink on the Shetland Analyzer', + params: { + signalId: 'MERIDIAN-SAR-2-VIDEO', + minPower: -130 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'decode-sar2-from-shetland', + nice: ['T0153', 'K0740', 'K1032'], + title: 'Decode SAR-2 from Shetland', + description: + 'Lock the 1370 MHz carrier on the SH-02 modem and hold C/N above 7 dB through culmination, 23 degrees at 06:34:38 and 879 km; the peak is about 9.7 dB, a decibel under what Galway gets from the same bird. Galway is flying it two minutes behind you.', + groundStation: 'SH-02', + prerequisiteObjectiveIds: ['acquire-sar2-from-shetland'], + conditions: [ + { + type: 'receiver-signal-locked', + description: 'SH-02 RX Modem Locked on SAR-2', + params: { modemNumber: 1, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'receiver-snr-threshold', + description: 'SH-02 C/N Above 7 dB', + params: { modemNumber: 1, minCNRatio: 7, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: true, + }, + { + type: 'status-check', + description: 'Unattended Check Planned', + params: { + character: Character.SYSTEM, + question: 'Galway has been tracking SAR-2 on its own since 06:32. When you get back to its console, what tells you whether its pass counted?', + options: [ + 'The pedestal still program-track locked on SAR-2 and the modem locked with C/N over threshold, read while the bird is still up', + 'The contact plan showing M-SAR2-GW as assigned to Galway and the window closed, read after the bird has set', + 'The absence of an alarm on the Galway dashboard and the AGC off its rail, read while the bird is still up', + 'The processing confirmation from Rotterdam and the frame count it reports, read after the bird has set', + ], + correctIndex: 0, + explanation: 'Assigned is a plan. Locked with a number is a pass. Galway sets at 06:41; the check has to happen before that or it is a guess.', + pointPenalty: 5, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + + // ============================================================ + // BEAT 4: BACK TO GALWAY, REPORT + // ============================================================ + { + id: 'check-the-unattended-pass', + nice: ['T0153', 'T0431', 'K0740'], + title: 'Check the Unattended Pass', + description: + 'Shetland sets at 06:39. Select GW-01 while SAR-2 is still up (until 06:41): the pedestal should be program-track locked on it and the modem locked with C/N above 6 dB on the way down.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['decode-sar2-from-shetland'], + conditions: [ + { + type: 'ground-station-selected', + hidden: true, + description: 'GW-01 Selected', + params: { groundStationId: 'GW-01' }, + mustMaintain: true, + }, + { + type: 'antenna-locked', + description: 'GW-01 Still Tracking SAR-2', + params: { noradId: 61702 }, + mustMaintain: false, + }, + { + type: 'receiver-snr-threshold', + description: 'GW-01 C/N Above 6 dB', + params: { modemNumber: 1, minCNRatio: 6, requiresObservation: true, observationTab: 'rx-analysis' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'customer-status', + nice: ['S0478', 'T1580', 'K0645'], + title: 'Customer Status', + description: 'Close the morning with Erik and write it into the report: what was captured, what is committed, what is best-effort.', + groundStation: 'GW-01', + prerequisiteObjectiveIds: ['check-the-unattended-pass'], + timeLimitSeconds: 3 * 60, + timerStartTrigger: 'on-activate', + conditions: [ + { + type: 'status-check', + description: 'Status Reported to Erik', + params: { + character: Character.SYSTEM, + question: 'Erik asks whether his 06:18 collect was captured and what the day looks like. What do you report?', + options: [ + 'The collect decoded with margin; both SAR-2 windows were worked, one from each site; the second-orbit contacts are best-effort.', + 'The collect decoded; every contact on the board is committed from Galway alone; the second-orbit contacts are guaranteed.', + 'The collect was captured; the SAR-2 windows were dropped, both sites being busy; the second orbit will cover them.', + 'The collect decoded at 11 dB C/N; the margin over the QPSK 3/4 threshold was 5 dB; the SAR-2 windows decoded as well.', + ], + correctIndex: 0, + explanation: + 'Report what was captured, what is committed, and what is best-effort. Erik does not need the C/N numbers; he needs to know what he can count on. The numbers go in the report, for the people who do.', + pointPenalty: 5, + documentSection: 'Contacts', + documentLine: + '06:18Z MERIDIAN-SAR-1 GW-01: decoded for NMW, peak C/N ~11 dB at 30 deg. 06:19Z MERIDIAN-SAR-1 SH-02 (F. MacLeod): 73 deg, worked. 06:30Z MERIDIAN-SAR-2 SH-02 (remote from GW-01): decoded, peak C/N ~9.7 dB at 23 deg. 06:32Z MERIDIAN-SAR-2 GW-01 (unattended, checked 06:38): tracking, locked. Second-orbit contacts best-effort. Customer status sent 06:42.', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], + dialogClips: { + intro: { + text: ` +

+ [Text message from Erik Halvorsen at 05:52] +

+

+ "Morning. Standing collect on the 06:18 SAR-1 window, same as every day this week. I have a vessel track that depends on it. I do not need the details - just tell me it's covered." +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + objectives: { + 'shetland-dashboard-sweep': { + text: ` +

+ [Text message from Fiona MacLeod at 06:07] +

+

+ "On console since half five and I've not touched the equipment, so what your console shows for my site is what I've got. I'll take the seventy-three at 06:19 myself - I want eyes on the pedestal through the top - and then I'm outside on the heater cable till seven. The half-past SAR-2 is yours from there. Modem's on 1414 for mine; don't move it till I'm off." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.NEUTRAL, + audioUrl: '', + }, + 'decode-the-standing-collect': { + text: ` +

+ Got the frames. Vessel is where I thought it was. Same time tomorrow? +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'take-the-shetland-console': { + text: ` +

+ [Text message from Fiona MacLeod at 06:29] +

+

+ "Off. Seventy-three degrees and it never wobbled - sixteen and a bit at the top, for the record. Modem's still on 1414. Console's yours; SAR-2 is up in a minute from the north. Don't be gentle with it." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + 'check-the-unattended-pass': { + text: ` +

+ [Text message from Fiona MacLeod at 06:38] +

+

+ "Mine decoded from three hundred miles away and yours flew itself. Watched Galway's dish move on the site camera from the car park. Two sites, one of you. That's the network." +

+ `, + character: Character.FIONA_MACLEOD, + emotion: Emotion.HAPPY, + audioUrl: '', + }, + 'customer-status': { + text: ` +

+ That's what I needed. Two sites covering the morning and a straight answer on the rest. I'll take best-effort on the second orbit as long as nobody calls it guaranteed. Thanks - talk tomorrow. +

+ `, + character: Character.ERIK_HALVORSEN, + emotion: Emotion.CONFIDENT, + audioUrl: '', + }, + }, + }, +}; diff --git a/src/campaigns/nats/campaign-data.ts b/src/campaigns/nats/campaign-data.ts index db7927e4..02454eed 100644 --- a/src/campaigns/nats/campaign-data.ts +++ b/src/campaigns/nats/campaign-data.ts @@ -1,4 +1,23 @@ import type { CampaignData } from '@app/campaigns/campaign-types'; +import { ccsScenario1Data } from '@app/campaigns/ccs/scenario1'; +import { ccsScenario2Data } from '@app/campaigns/ccs/scenario2'; +import { ccsScenario3Data } from '@app/campaigns/ccs/scenario3'; +import { ccsScenario4Data } from '@app/campaigns/ccs/scenario4'; +import { ccsScenario5Data } from '@app/campaigns/ccs/scenario5'; +import { hamSdrSandboxData } from '@app/campaigns/ham-sdr/sandbox'; +import { hamSdrScenario1Data } from '@app/campaigns/ham-sdr/scenario1'; +import { hamSdrScenario2Data } from '@app/campaigns/ham-sdr/scenario2'; +import { hamSdrScenario3Data } from '@app/campaigns/ham-sdr/scenario3'; +import { hamSdrScenario4Data } from '@app/campaigns/ham-sdr/scenario4'; +import { hamSdrScenario5Data } from '@app/campaigns/ham-sdr/scenario5'; +import { hamSdrScenario6Data } from '@app/campaigns/ham-sdr/scenario6'; +import { hamSdrScenario7Data } from '@app/campaigns/ham-sdr/scenario7'; +import { hamSdrScenario8Data } from '@app/campaigns/ham-sdr/scenario8'; +import { signalHunterSandboxData } from '@app/campaigns/signal-hunter/sandbox'; +import { signalHunterScenario1Data } from '@app/campaigns/signal-hunter/scenario1'; +import { signalHunterScenario2Data } from '@app/campaigns/signal-hunter/scenario2'; +import { signalHunterScenario3Data } from '@app/campaigns/signal-hunter/scenario3'; +import { signalHunterScenario4Data } from '@app/campaigns/signal-hunter/scenario4'; import { sandboxData } from './sandbox'; import { scenario1Data } from './scenario1'; import { scenario2Data } from './scenario2'; @@ -40,8 +59,15 @@ export const natsCampaignData: CampaignData = { description: `Welcome to North Atlantic Teleport Services, a commercial satellite ground station facility in rural Vermont. In this campaign, you'll learn the fundamentals of satellite communications by conducting first light tests, tracking satellites, and establishing reliable RF links for GEO communication satellites serving the North Atlantic region.

Through a series of progressively challenging scenarios, you'll master ground station equipment operation, signal acquisition techniques, and RF link analysis while following the story of bringing multiple communication satellites into operational service.`, imageUrl: 'nats/north-atlantic-teleport-services.png', difficulty: 'beginner', - totalDuration: '175-240 min', + totalDuration: '650-820 min', campaignType: 'GEO Commercial Communications', + headerIdentity: { + name: 'ORBITAL', + nameAccent: 'OPS', + icon: 'fa-solid fa-earth-americas', + }, + chromeVariant: 'standard', + releaseStage: 'stable', scenarios: [ sandboxData, scenario1Data, @@ -73,38 +99,39 @@ export const natsCampaignData: CampaignData = { isDisabled: false, }; -export const natsEuCampaignData: CampaignData = { - id: 'nats-eu', - title: 'North Atlantic Teleport Services EU', - subtitle: 'Commercial Ground Station Operations', - description: `This campaign follows the North Atlantic Teleport Services EU branch, focusing on Low Earth Orbit (LEO) satellite communications. As a ground station operator, you'll work through a series of scenarios to establish and maintain RF links with various LEO satellites, gaining hands-on experience with tracking fast-moving targets and optimizing communication parameters for reliable data transmission.

Through these scenarios, you'll develop essential skills in antenna tracking, Doppler shift compensation, and link budget analysis, all while supporting the operational needs of cutting-edge LEO satellite constellations.`, - imageUrl: 'nats/north-atlantic-teleport-services.png', - difficulty: 'intermediate', - totalDuration: '160-220 min', - campaignType: 'LEO Commercial Communications', - scenarios: [ - - ], - isLocked: true, - lockedText: 'Under Development', - isDisabled: false, -}; - export const hamSdrCampaignData: CampaignData = { id: 'ham-sdr', - title: 'Ham Radio SDR Station', - subtitle: 'Amateur Radio Operations with Software-Defined Radio', - description: `This campaign focuses on amateur radio operations using software-defined radio (SDR) technology. As an amateur radio operator, you'll engage in a series of scenarios that involve setting up and configuring SDR equipment, establishing communication links with other ham radio operators, and exploring various modes of transmission.

Through these scenarios, you'll gain practical experience in antenna tuning, signal propagation analysis, and digital mode operation, all while adhering to amateur radio regulations and best practices.`, - imageUrl: 'nats/north-atlantic-teleport-services.png', - difficulty: 'intermediate', - totalDuration: '160-220 min', + title: 'Backyard Operator', + subtitle: 'DIY Satellite Tracking with Software-Defined Radio', + description: `Charlie's niece Riley teaches you how to track satellites from your backyard. No mission control, no nine-meter dish - just software-defined radio, DIY antennas, and physics.

Start by catching weather images on a hand-wound quadrifilar helix. Add a crossed yagi on a TV rotator and chase cubesat Doppler by hand. Learn why handedness costs you eighteen decibels, find the GPS constellation hiding under the noise floor, and audit a link budget until a hopeless low pass decodes anyway.

Then the band turns hostile. Someone spoofs your clock. Someone hands you poisoned orbital elements. A neighbor's failing gear buries the birds in hash. And when you finally key a transmitter of your own, you meet an unlicensed operator the satellite relays as faithfully as it relays you - and a beacon claiming to be a satellite that never left the ground.

Eight scenarios, one lesson: RF is unauthenticated, and physics is your only authentication.`, + // Art lives under assets/campaigns/home-sdr/, which does not match the + // 'ham-sdr' campaign id. + imageUrl: 'home-sdr/home-sdr.png', + difficulty: 'beginner', + totalDuration: '170-210 min', campaignType: 'Amateur Radio Operations', + headerIdentity: { + name: 'BACKYARD', + nameAccent: 'SDR', + icon: 'fa-solid fa-satellite-dish', + }, + chromeVariant: 'sdr', + // S1-S8 are shipped but the engine seams behind them (terrestrial emitters, + // backyard TX path, REF/holdover) are still moving, so saves can break. + releaseStage: 'alpha', scenarios: [ - + hamSdrSandboxData, + hamSdrScenario1Data, + hamSdrScenario2Data, + hamSdrScenario3Data, + hamSdrScenario4Data, + hamSdrScenario5Data, + hamSdrScenario6Data, + hamSdrScenario7Data, + hamSdrScenario8Data, ], isLocked: false, - isDisabled: true, - disabledText: 'Access Denied', + isDisabled: false, }; export const ccsCampaignData: CampaignData = { @@ -112,33 +139,44 @@ export const ccsCampaignData: CampaignData = { title: '9th Electronic Warfare Squadron', subtitle: 'Counter Communications Systems', description: `This campaign delves into the realm of electronic warfare and counter communications systems. As a specialist in this field, you'll navigate through a series of scenarios that challenge you to identify, analyze, and disrupt hostile communication signals while ensuring the integrity of friendly communications.

Through these scenarios, you'll develop expertise in signal intelligence, jamming techniques, and electronic countermeasures, all while operating within the constraints of modern electronic warfare environments.`, - imageUrl: 'nats/north-atlantic-teleport-services.png', + imageUrl: 'ccs/ccs.png', difficulty: 'advanced', - totalDuration: '200-260 min', + totalDuration: '100-140 min', campaignType: 'Electronic Warfare', - scenarios: [ - - ], + headerIdentity: { + name: 'COUNTER', + nameAccent: 'COMMS', + icon: 'fa-solid fa-tower-broadcast', + }, + chromeVariant: 'astro', + // Sandbox, Failover, and the spacecraft half (First Shift, State of Health, Ranging Pass). + releaseStage: 'alpha', + scenarios: [ccsScenario1Data, ccsScenario2Data, ccsScenario3Data, ccsScenario4Data, ccsScenario5Data], isLocked: false, - isDisabled: true, - disabledText: 'Access Denied', + isDisabled: false, }; export const geolocationCampaignData: CampaignData = { - id: 'ccs', + // Campaign 5: "Signal Hunter" (README Q4). Was id 'ccs', a duplicate of + // ccsCampaignData; renamed to a unique id matching its /campaigns/:id route + // and body theme class (.campaign-signal-hunter). + id: 'signal-hunter', title: '22nd Electronic Warfare Squadron', - subtitle: 'Geolocation of Interference Sources', - description: `This campaign focuses on the geolocation of interference sources in electronic warfare scenarios. As a member of the 22 Electronic Warfare Squadron, you'll engage in a series of scenarios that require you to accurately locate and identify sources of signal interference while maintaining the integrity of friendly communications.

Through these scenarios, you'll hone your skills in direction finding, triangulation techniques, and signal analysis, all while operating within the dynamic landscape of electronic warfare.`, + subtitle: 'Signal Hunter — Geolocation of Interference Sources', + description: `Someone is jamming allied satellites. As a member of the 22nd Electronic Warfare Squadron, you'll locate the sources of hostile interference using advanced RF geolocation.

Learn the two-satellite TDOA/FDOA cross-fix technique: an uplink jammer leaks into a neighboring satellite's sidelobes, and correlating the two downlinks lets you draw crossing lines of position over the emitter. Detect, characterize, and geolocate intermittent interference, then hand a fix and error ellipse to the incident response cell.`, imageUrl: 'nats/north-atlantic-teleport-services.png', difficulty: 'advanced', - totalDuration: '200-260 min', + totalDuration: '130-170 min', campaignType: 'Electronic Warfare', - scenarios: [ - - ], + headerIdentity: { + name: 'SIGNAL', + nameAccent: 'HUNTER', + icon: 'fa-solid fa-crosshairs', + }, + chromeVariant: 'astro', + // Sandbox plus the four-scenario arc: First Fix, Two Carriers, Cold Trail, Prove It. + releaseStage: 'alpha', + scenarios: [signalHunterSandboxData, signalHunterScenario1Data, signalHunterScenario2Data, signalHunterScenario3Data, signalHunterScenario4Data], isLocked: false, - isDisabled: true, - disabledText: 'Access Denied', + isDisabled: false, }; - - diff --git a/src/campaigns/nats/ground-stations.ts b/src/campaigns/nats/ground-stations.ts index f8ddcde6..cc34265d 100644 --- a/src/campaigns/nats/ground-stations.ts +++ b/src/campaigns/nats/ground-stations.ts @@ -3,7 +3,7 @@ import { FrequencyBand } from '@app/constants'; import { type AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { type CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; -import { TapPoint } from "@app/equipment/rf-front-end/coupler-module/tap-points"; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; import type { Degrees } from 'ootk'; @@ -36,124 +36,126 @@ export const vermontGroundStation = { isLocked: true, } as Partial, ], - rfFrontEnds: [{ - omt: { - isPowered: true, - txPolarization: 'H', - rxPolarization: 'V', - effectiveTxPol: 'H', - effectiveRxPol: 'V', - crossPolIsolation: 28.5 as dB, - isFaulted: false, - insertionLoss: 0.5 as dB, - }, - buc: { - isPowered: true, - isMuted: false, - isLoopback: false, - temperature: 25, - currentDraw: 0, - loFrequency: 7000 as MHz, - filterHighHz: FrequencyBand.c.upHigh, - filterLowHz: FrequencyBand.c.upLow, - filterRejectionDb: 40 as dB, - isExtRefLocked: true, - frequencyError: 0, - phaseLockRange: 10000, - gain: 23 as dB, - outputPower: -10 as dBm, - saturationPower: 15 as dBm, - gainFlatness: 0.5 as dB, - groupDelay: 3, - phaseNoise: -100, - spuriousOutputs: [], - noiseFloor: -140, - }, - hpa: { - isPowered: true, - backOff: 10, - outputPower: 50 as dBm, - isOverdriven: false, - imdLevel: -30, - temperature: 45, - isHpaEnabled: true, - isHpaSwitchEnabled: true, - noiseFloor: -140, - gain: 44 as dB, - }, - filter: { - isPowered: true, - bandwidthIndex: 13, - bandwidth: 40 as MHz, // Only the index matters here - insertionLoss: 2.0, // Only the index matters here - noiseFloor: -101, // Only the index matters here - }, - lnb: { - isPowered: true, - loFrequency: 5250 as MHz, - gain: 65 as dB, - lnaNoiseFigure: 0.6, // dB - mixerNoiseFigure: 16.0, // dB - noiseTemperature: 43, // K - stable - noiseTemperatureStabilizationTime: 0, // Already stabilized - isExtRefLocked: true, // Locked to GPSDO 10 MHz - noiseFloor: -140, // dBm/Hz - frequencyError: 0, // Hz - temperature: 28, // °C - stable - thermalStabilizationTime: 0, // Already stabilized - }, - agc: { - isPowered: true, - isBypassed: false, - targetLevel: -30 as dBm, - currentGain: 10 as dB, - inputPower: -80 as dBm, - outputPower: -30 as dBm, - attackTime: 10, - releaseTime: 100, - maxGain: 10 as dB, - minGain: -100 as dB, - }, - coupler: { - isEngineeringMode: false, - tapPointA: TapPoint.TX_IF, - tapPointB: TapPoint.RX_IF, - availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], - availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], - couplingFactorA: -40, // dB - couplingFactorB: -39, // dB - isEnabledA: false, - isEnabledB: true, - isActiveA: false, - isActiveB: true, - } as CouplerState, - gpsdo: { - isPowered: true, - isLocked: true, - warmupTimeRemaining: 0, - temperature: 70, // °C - stable operating temp - gnssSignalPresent: true, - isGnssSwitchUp: true, - isGnssAcquiringLock: false, - satelliteCount: 8, - utcAccuracy: 50, // ns - constellation: 'GPS', - lockDuration: 7200, // 2 hours locked - frequencyAccuracy: 2e-11, // Excellent stability - allanDeviation: 1e-11, - phaseNoise: -110, // dBc/Hz - isInHoldover: false, - holdoverDuration: 0, - holdoverError: 0, - active10MHzOutputs: 3, - max10MHzOutputs: 5, - output10MHzLevel: 7, // dBm - ppsOutputsEnabled: true, - operatingHours: 8760, // 1 year of operation - selfTestPassed: true, - agingRate: 1e-10, + rfFrontEnds: [ + { + omt: { + isPowered: true, + txPolarization: 'H', + rxPolarization: 'V', + effectiveTxPol: 'H', + effectiveRxPol: 'V', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: true, + isMuted: false, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 7000 as MHz, + filterHighHz: FrequencyBand.c.upHigh, + filterLowHz: FrequencyBand.c.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 23 as dB, + outputPower: -10 as dBm, + saturationPower: 15 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: true, + backOff: 10, + outputPower: 50 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 45, + isHpaEnabled: true, + isHpaSwitchEnabled: true, + noiseFloor: -140, + gain: 44 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 13, + bandwidth: 40 as MHz, // Only the index matters here + insertionLoss: 2.0, // Only the index matters here + noiseFloor: -101, // Only the index matters here + }, + lnb: { + isPowered: true, + loFrequency: 5250 as MHz, + gain: 65 as dB, + lnaNoiseFigure: 0.6, // dB + mixerNoiseFigure: 16.0, // dB + noiseTemperature: 43, // K - stable + noiseTemperatureStabilizationTime: 0, // Already stabilized + isExtRefLocked: true, // Locked to GPSDO 10 MHz + noiseFloor: -140, // dBm/Hz + frequencyError: 0, // Hz + temperature: 28, // °C - stable + thermalStabilizationTime: 0, // Already stabilized + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 10 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, // dB + couplingFactorB: -39, // dB + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, // °C - stable operating temp + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 8, + utcAccuracy: 50, // ns + constellation: 'GPS', + lockDuration: 7200, // 2 hours locked + frequencyAccuracy: 2e-11, // Excellent stability + allanDeviation: 1e-11, + phaseNoise: -110, // dBc/Hz + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 3, + max10MHzOutputs: 5, + output10MHzLevel: 7, // dBm + ppsOutputsEnabled: true, + operatingHours: 8760, // 1 year of operation + selfTestPassed: true, + agingRate: 1e-10, + }, }, - }], + ], spectrumAnalyzers: [ { referenceLevel: -91 as dBm, // Set for beacon observation @@ -174,49 +176,58 @@ export const vermontGroundStation = { { isVisible: false, isUpdating: false, mode: 'clearwrite' }, // Trace 3 ], selectedTrace: 1, - } + }, + ], + transmitters: [ + { + activeModem: 1, + modems: [ + { + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: true, + isTransmittingSwitchUp: true, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: 'TIDEMARK-1-Teleport', + serverId: 1, + noradId: 61525, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1094e6 as IfFrequency, + power: -7 as dBm, + bandwidth: 36e6 as Hertz, // Match payload bandwidth + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + }, + }, + ], + }, + ], + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 1532 as MHz, // IF frequency for 3718 MHz RF with 5150 MHz LO + bandwidth: 36 as MHz, // Match payload bandwidth + modulation: 'QPSK', + fec: '3/4', + antenna_id: 1, + }, + ], + }, ], - transmitters: [{ - activeModem: 1, - modems: [{ - isPowered: true, - antenna_id: 1, - modem_number: 1, - isFaulted: false, - isTransmitting: true, - isTransmittingSwitchUp: true, - isFaultSwitchUp: false, - id: 1, - isLoopback: false, - ifSignal: { - signalId: 'TIDEMARK-1-Teleport', - serverId: 1, - noradId: 61525, polarization: 'V', - feed: '', - isDegraded: false, - origin: SignalOrigin.TRANSMITTER, - noiseFloor: null, - gainInPath: 0 as dBi, - frequency: 1094e6 as IfFrequency, - power: -7 as dBm, - bandwidth: 36e6 as Hertz, // Match payload bandwidth - modulation: 'QPSK' as ModulationType, - fec: '3/4' as FECType, - }, - }], - }], - receivers: [{ - activeModem: 1, - modems: [{ - modemNumber: 1, - isPowered: true, - frequency: 1532 as MHz, // IF frequency for 3718 MHz RF with 5150 MHz LO - bandwidth: 36 as MHz, // Match payload bandwidth - modulation: 'QPSK', - fec: '3/4', - antenna_id: 1, - }], - }], } as GroundStationConfig; export const maineGroundStation = { @@ -229,124 +240,126 @@ export const maineGroundStation = { elevation: 48, }, antennas: [ANTENNA_CONFIG_KEYS.C_BAND_9M_VORTEK], - rfFrontEnds: [{ - omt: { - isPowered: true, - txPolarization: 'H', - rxPolarization: 'V', - effectiveTxPol: 'H', - effectiveRxPol: 'V', - crossPolIsolation: 28.5 as dB, - isFaulted: false, - insertionLoss: 0.5 as dB, - }, - buc: { - isPowered: true, - isMuted: false, - isLoopback: false, - temperature: 25, - currentDraw: 0, - loFrequency: 6425 as MHz, - filterHighHz: FrequencyBand.c.upHigh, - filterLowHz: FrequencyBand.c.upLow, - filterRejectionDb: 40 as dB, - isExtRefLocked: true, - frequencyError: 0, - phaseLockRange: 10000, - gain: 0 as dB, - outputPower: -10 as dBm, - saturationPower: 15 as dBm, - gainFlatness: 0.5 as dB, - groupDelay: 3, - phaseNoise: -100, - spuriousOutputs: [], - noiseFloor: -140, - }, - hpa: { - isPowered: true, - backOff: 6, - outputPower: 50 as dBm, - isOverdriven: false, - imdLevel: -30, - temperature: 45, - isHpaEnabled: false, - isHpaSwitchEnabled: false, - noiseFloor: -140, - gain: 44 as dB, - }, - filter: { - isPowered: true, - bandwidthIndex: 12, - bandwidth: 20 as MHz, - insertionLoss: 2.0, - noiseFloor: -101, - }, - lnb: { - isPowered: false, - loFrequency: 6080 as MHz, // MHz - gain: 0 as dB, - lnaNoiseFigure: 0.6, // dB - mixerNoiseFigure: 16.0, // dB - noiseTemperature: 290, // K - noiseTemperatureStabilizationTime: 180, // seconds - isExtRefLocked: false, - noiseFloor: -140, // dBm/Hz - frequencyError: 0, // Hz - temperature: 25, // °C - thermalStabilizationTime: 180, // seconds - }, - agc: { - isPowered: true, - isBypassed: false, - targetLevel: -30 as dBm, - currentGain: 0 as dB, - inputPower: -100 as dBm, - outputPower: -100 as dBm, - attackTime: 10, - releaseTime: 100, - maxGain: 0 as dB, - minGain: -60 as dB, - }, - coupler: { - isEngineeringMode: false, - tapPointA: TapPoint.TX_IF, - tapPointB: TapPoint.RX_IF, - availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], - availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], - couplingFactorA: -40, // dB - couplingFactorB: -39, // dB - isEnabledA: false, - isEnabledB: true, - isActiveA: false, - isActiveB: true, - } as CouplerState, - gpsdo: { - isPowered: true, // CHANGE - isLocked: false, - warmupTimeRemaining: 0, // seconds - temperature: 70, // °C - gnssSignalPresent: false, - isGnssSwitchUp: false, - isGnssAcquiringLock: false, - satelliteCount: 0, - utcAccuracy: 0, - constellation: 'GPS', - lockDuration: 0, - frequencyAccuracy: 0, - allanDeviation: 0, - phaseNoise: 0, - isInHoldover: true, - holdoverDuration: 600, - holdoverError: 0, - active10MHzOutputs: 2, - max10MHzOutputs: 5, - output10MHzLevel: 0, - ppsOutputsEnabled: false, - operatingHours: 6, - selfTestPassed: true, - agingRate: 0, + rfFrontEnds: [ + { + omt: { + isPowered: true, + txPolarization: 'H', + rxPolarization: 'V', + effectiveTxPol: 'H', + effectiveRxPol: 'V', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: true, + isMuted: false, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 6425 as MHz, + filterHighHz: FrequencyBand.c.upHigh, + filterLowHz: FrequencyBand.c.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 0 as dB, + outputPower: -10 as dBm, + saturationPower: 15 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: true, + backOff: 6, + outputPower: 50 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 45, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 44 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 12, + bandwidth: 20 as MHz, + insertionLoss: 2.0, + noiseFloor: -101, + }, + lnb: { + isPowered: false, + loFrequency: 6080 as MHz, // MHz + gain: 0 as dB, + lnaNoiseFigure: 0.6, // dB + mixerNoiseFigure: 16.0, // dB + noiseTemperature: 290, // K + noiseTemperatureStabilizationTime: 180, // seconds + isExtRefLocked: false, + noiseFloor: -140, // dBm/Hz + frequencyError: 0, // Hz + temperature: 25, // °C + thermalStabilizationTime: 180, // seconds + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 0 as dB, + inputPower: -100 as dBm, + outputPower: -100 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 0 as dB, + minGain: -60 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, // dB + couplingFactorB: -39, // dB + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, // CHANGE + isLocked: false, + warmupTimeRemaining: 0, // seconds + temperature: 70, // °C + gnssSignalPresent: false, + isGnssSwitchUp: false, + isGnssAcquiringLock: false, + satelliteCount: 0, + utcAccuracy: 0, + constellation: 'GPS', + lockDuration: 0, + frequencyAccuracy: 0, + allanDeviation: 0, + phaseNoise: 0, + isInHoldover: true, + holdoverDuration: 600, + holdoverError: 0, + active10MHzOutputs: 2, + max10MHzOutputs: 5, + output10MHzLevel: 0, + ppsOutputsEnabled: false, + operatingHours: 6, + selfTestPassed: true, + agingRate: 0, + }, }, - }], + ], spectrumAnalyzers: [ { referenceLevel: 0, // dBm @@ -355,7 +368,7 @@ export const maineGroundStation = { rbw: 50e6 as Hertz, minAmplitude: -170, maxAmplitude: 0, - scaleDbPerDiv: (-0 + 170) / 10 as dB, // 6 dB/div + scaleDbPerDiv: ((-0 + 170) / 10) as dB, // 6 dB/div screenMode: 'both', inputUnit: 'MHz', inputValue: '', @@ -367,7 +380,7 @@ export const maineGroundStation = { { isVisible: false, isUpdating: false, mode: 'clearwrite' }, // Trace 3 ], selectedTrace: 1, - } + }, ], transmitters: [], receivers: [], diff --git a/src/campaigns/nats/sandbox.ts b/src/campaigns/nats/sandbox.ts index 93ff4d75..212ce693 100644 --- a/src/campaigns/nats/sandbox.ts +++ b/src/campaigns/nats/sandbox.ts @@ -24,19 +24,10 @@ export const sandboxData: ScenarioData = { missionType: 'Sandbox', description: `Explore the Vermont and Maine ground stations freely without objectives or time limits. All equipment is available and operational. Practice configuring the antenna, RF chain, spectrum analyzer, and modems at your own pace.

Use the sandbox to familiarize yourself with the Signal Range interface, test different setups, and hone your satellite communication skills without the pressure of a mission scenario.`, - equipment: [ - '9-meter C-band Antenna', - 'Complete RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'All Control Systems', - ], + equipment: ['9-meter C-band Antenna', 'Complete RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'All Control Systems'], settings: { isSync: true, - groundStations: [ - vermontGroundStation, - { ...maineGroundStation, isOperational: true }, - ], + groundStations: [vermontGroundStation, { ...maineGroundStation, isOperational: true }], satellites: [tidemark1Satellite, tidemark2Satellite, aurora7Satellite], isExtraSatellitesVisible: true, }, diff --git a/src/campaigns/nats/satellites.ts b/src/campaigns/nats/satellites.ts index 100fd9f6..9d64fbee 100644 --- a/src/campaigns/nats/satellites.ts +++ b/src/campaigns/nats/satellites.ts @@ -1,6 +1,6 @@ import { Satellite, TransponderConfig } from '@app/equipment/satellite/satellite'; import { SignalOrigin } from '@app/signal-origin'; -import type { FECType, Hertz, ModulationType, RfFrequency, dBi, dBm } from '@app/types'; +import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; import type { Degrees } from 'ootk'; export const tidemark1Satellite = new Satellite( @@ -23,7 +23,7 @@ export const tidemark1Satellite = new Satellite( origin: SignalOrigin.SATELLITE_RX, noiseFloor: null, gainInPath: 0 as dBi, - } + }, ], [], // Beacons now defined in transponderConfigs { @@ -90,7 +90,7 @@ export const tidemark2Satellite = new Satellite( origin: SignalOrigin.SATELLITE_RX, noiseFloor: null, gainInPath: 0 as dBi, - } + }, ], [], // Beacons now defined in transponderConfigs { @@ -99,7 +99,7 @@ export const tidemark2Satellite = new Satellite( rotation: -25 as Degrees, frequencyOffset: 2.225e9 as Hertz, // Legacy fallback // Ephemeris error: simulates TLE inaccuracy - ephemerisErrorAz: 0.10 as Degrees, + ephemerisErrorAz: 0.1 as Degrees, ephemerisErrorEl: 0.06 as Degrees, transponderConfigs: [ { @@ -175,7 +175,7 @@ export const tidemark3Satellite = new Satellite( origin: SignalOrigin.SATELLITE_RX, noiseFloor: null, gainInPath: 0 as dBi, - } + }, ], [], // Beacons defined in transponderConfigs { @@ -184,7 +184,7 @@ export const tidemark3Satellite = new Satellite( rotation: 8 as Degrees, frequencyOffset: 2.225e9 as Hertz, // Ephemeris error: smallest of the constellation (newest bird, freshest TLE) - ephemerisErrorAz: 0.10 as Degrees, + ephemerisErrorAz: 0.1 as Degrees, ephemerisErrorEl: 0.07 as Degrees, transponderConfigs: [ { @@ -234,7 +234,7 @@ export const ses10Satellite = new Satellite( origin: SignalOrigin.SATELLITE_RX, noiseFloor: null, gainInPath: 0 as dBi, - } + }, ], [], // Beacons now defined in transponderConfigs { @@ -244,7 +244,7 @@ export const ses10Satellite = new Satellite( frequencyOffset: 2.225e9 as Hertz, // Legacy fallback // Ephemeris error: simulates TLE inaccuracy ephemerisErrorAz: 0.15 as Degrees, - ephemerisErrorEl: 0.10 as Degrees, + ephemerisErrorEl: 0.1 as Degrees, transponderConfigs: [ { id: 'TP-1', @@ -322,7 +322,7 @@ export const aurora7Satellite = new Satellite( origin: SignalOrigin.SATELLITE_RX, noiseFloor: null, gainInPath: 0 as dBi, - } + }, ], [], // Beacons defined in transponderConfigs { @@ -331,7 +331,7 @@ export const aurora7Satellite = new Satellite( rotation: 0 as Degrees, frequencyOffset: 2.225e9 as Hertz, // Ephemeris error: larger for inclined orbit (TLE is harder to predict) - ephemerisErrorAz: 0.20 as Degrees, + ephemerisErrorAz: 0.2 as Degrees, ephemerisErrorEl: 0.15 as Degrees, orbitType: 'geosynchronous', geosyncConfig: { diff --git a/src/campaigns/nats/scenario1.ts b/src/campaigns/nats/scenario1.ts index 886b7ca8..fa009703 100644 --- a/src/campaigns/nats/scenario1.ts +++ b/src/campaigns/nats/scenario1.ts @@ -56,22 +56,13 @@ export const scenario1Data: ScenarioData = { difficulty: 'beginner', missionType: 'Routine Operations', description: `Welcome to your first day at North Atlantic Teleport Services, a commercial satellite ground station facility in rural Vermont. Your company provides ground segment services for the TIDEMARK constellation - SeaLink Global Communications' fleet of GEO satellites providing maritime broadband across the Atlantic.

TIDEMARK-1 is already online at 53°W, serving customer traffic. Today, Charlie Brooks will walk you through a routine health check. You'll learn what each equipment panel shows, what the indicators mean, and what "normal" looks like.

No pressure today - just observation and familiarization. Click through each panel and verify the status indicators as Charlie explains them.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems'], settings: { isSync: true, - groundStations: [ - vermontGroundStation, - ], + groundStations: [vermontGroundStation], missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-1?content-only=true&dark=true', isExtraSatellitesVisible: true, - satellites: [ - tidemark1Satellite, - ] + satellites: [tidemark1Satellite], }, timeLimitSeconds: 35 * 60, // 35 minutes objectives: [ @@ -100,9 +91,7 @@ export const scenario1Data: ScenarioData = { description: 'Ready to Proceed', params: { question: 'Have you reviewed the mission brief and are you ready to begin?', - options: [ - 'Yes, I have read the mission brief and I am ready to proceed.', - ], + options: ['Yes, I have read the mission brief and I am ready to proceed.'], correctIndex: 0, explanation: 'The mission timer has started. Good luck!', pointPenalty: 0, @@ -290,7 +279,8 @@ export const scenario1Data: ScenarioData = { 'No reading - LNB is offline', ], correctIndex: 0, - explanation: 'The LNB noise temperature of 43K is excellent. Lower noise temperature means better receive sensitivity. Anything under 100K is considered good for C-band.', + explanation: + 'The LNB noise temperature of 43K is excellent. Lower noise temperature means better receive sensitivity. Anything under 100K is considered good for C-band.', pointPenalty: 10, }, mustMaintain: false, @@ -326,12 +316,13 @@ export const scenario1Data: ScenarioData = { question: 'What does the Tap Points card show, and what is its purpose?', options: [ 'RX IF selected - monitoring the receive chain after downconversion', - 'TX IF selected - monitoring the transmit chain', - 'Both TX and RX IF active - dual monitoring mode', - 'No tap point selected - spectrum analyzer disabled', + 'TX IF selected - monitoring the transmit chain before upconversion', + 'Both TX and RX IF active - monitoring both chains in dual mode', + 'No tap point selected - spectrum analyzer input is disconnected', ], correctIndex: 0, - explanation: 'The Tap Points card selects where in the signal chain the spectrum analyzer takes its input. RX IF monitors the receive path after the LNB downconverts from RF to IF - this is the standard monitoring point for receive operations.', + explanation: + 'The Tap Points card selects where in the signal chain the spectrum analyzer takes its input. RX IF monitors the receive path after the LNB downconverts from RF to IF - this is the standard monitoring point for receive operations.', pointPenalty: 10, }, mustMaintain: false, @@ -385,7 +376,8 @@ export const scenario1Data: ScenarioData = { 'Flat line at 0 dBm - equipment malfunction', ], correctIndex: 0, - explanation: 'The beacon signal appears as a narrow spike rising above the noise floor. This CW (continuous wave) intermediate frequency signal confirms the satellite is in view and the receive chain is working.', + explanation: + 'The beacon signal appears as a narrow spike rising above the noise floor. This CW (continuous wave) intermediate frequency signal confirms the satellite is in view and the receive chain is working.', pointPenalty: 10, }, mustMaintain: false, @@ -419,14 +411,10 @@ export const scenario1Data: ScenarioData = { description: 'Verify Spectrum Analyzer Configuration', params: { question: 'What center frequency and span are set on the spectrum analyzer?', - options: [ - '1074.5 MHz center, 0.002 MHz span', - '1532 MHz center, 2 kHz span', - '0.002 MHz center, 1074.5 MHz span', - '1074.5 MHz center, 2 MHz span', - ], + options: ['1074.5 MHz center, 0.002 MHz span', '1532 MHz center, 2 kHz span', '0.002 MHz center, 1074.5 MHz span', '1074.5 MHz center, 2 MHz span'], correctIndex: 0, - explanation: 'The spectrum analyzer is set to 1074.5 MHz (beacon IF frequency for TIDEMARK-1 after LNB downconversion) with a 2 kHz (0.002 MHz) span. This narrow span allows you to clearly see the beacon signal above the noise floor.', + explanation: + 'The spectrum analyzer is set to 1074.5 MHz (beacon IF frequency for TIDEMARK-1 after LNB downconversion) with a 2 kHz (0.002 MHz) span. This narrow span allows you to clearly see the beacon signal above the noise floor.', pointPenalty: 10, }, mustMaintain: false, @@ -467,7 +455,8 @@ export const scenario1Data: ScenarioData = { '< 3 dB - Below demodulation threshold; no reliable lock', ], correctIndex: 0, - explanation: 'A C/N ratio above 10 dB indicates a healthy link with adequate margin for reliable data reception. This confirms the entire receive chain from antenna to modem is functioning properly.', + explanation: + 'A C/N ratio above 10 dB indicates a healthy link with adequate margin for reliable data reception. This confirms the entire receive chain from antenna to modem is functioning properly.', pointPenalty: 10, }, mustMaintain: false, @@ -508,7 +497,8 @@ export const scenario1Data: ScenarioData = { 'Empty display - no signal lock', ], correctIndex: 0, - explanation: 'The tight clusters at the four QPSK symbol points indicate clean demodulation with good signal-to-noise ratio. Spread or scattered points would indicate noise, interference, or phase problems.', + explanation: + 'The tight clusters at the four QPSK symbol points indicate clean demodulation with good signal-to-noise ratio. Spread or scattered points would indicate noise, interference, or phase problems.', pointPenalty: 10, }, mustMaintain: false, @@ -548,12 +538,13 @@ export const scenario1Data: ScenarioData = { question: 'What does the Payload Data Integrity card show about the received data?', options: [ 'Frame sync locked, CRC valid, Reed-Solomon active - data path healthy', - 'Frame sync unlocked - no data being received', - 'CRC errors detected - data corruption', - 'Viterbi decoder disabled - no error correction', + 'Frame sync unlocked, CRC failing, Reed-Solomon idle - no data received', + 'Frame sync locked, CRC errors present, Reed-Solomon active - data corrupted', + 'Frame sync locked, CRC valid, Viterbi decoder disabled - no error correction', ], correctIndex: 0, - explanation: 'The Payload Data Integrity card confirms the data path is healthy: frame sync is locked (receiving valid frames), CRC checks pass (no corruption), and the Reed-Solomon decoder is actively correcting any bit errors.', + explanation: + 'The Payload Data Integrity card confirms the data path is healthy: frame sync is locked (receiving valid frames), CRC checks pass (no corruption), and the Reed-Solomon decoder is actively correcting any bit errors.', pointPenalty: 10, }, mustMaintain: false, @@ -619,14 +610,10 @@ export const scenario1Data: ScenarioData = { description: 'Verify HPA Status', params: { question: 'What is the current state of the HPA (High Power Amplifier)?', - options: [ - 'Transmitting with 10 dB backoff', - 'Powered on but not enabled (safe standby)', - 'Transmitting at full power', - 'Powered off completely', - ], + options: ['Transmitting with 10 dB backoff', 'Powered on but not enabled (safe standby)', 'Transmitting at full power', 'Powered off completely'], correctIndex: 0, - explanation: 'The HPA is powered on and transmitting with 10 dB backoff, which is a safe condition for routine operations. This reduces stress on the amplifier while still allowing signal transmission.', + explanation: + 'The HPA is powered on and transmitting with 10 dB backoff, which is a safe condition for routine operations. This reduces stress on the amplifier while still allowing signal transmission.', pointPenalty: 10, }, mustMaintain: false, @@ -666,12 +653,13 @@ export const scenario1Data: ScenarioData = { question: 'What does the TX Payload Data card show about the transmit data path?', options: [ 'Source feed active, encryption enabled, buffer healthy - ready to transmit', - 'Source feed inactive - no data available', - 'Encryption disabled - transmitting in clear', - 'Buffer overflow - data loss occurring', + 'Source feed inactive, encryption enabled, buffer empty - no data to transmit', + 'Source feed active, encryption disabled, buffer healthy - transmitting in clear', + 'Source feed active, encryption enabled, buffer overflowing - data loss occurring', ], correctIndex: 0, - explanation: 'The TX Payload Data card shows the transmit path is healthy: source feed is active, encryption is enabled with a valid key, and the buffer utilization is within normal range with no overflows or underruns.', + explanation: + 'The TX Payload Data card shows the transmit path is healthy: source feed is active, encryption is enabled with a valid key, and the buffer utilization is within normal range with no overflows or underruns.', pointPenalty: 10, }, mustMaintain: false, @@ -738,13 +726,14 @@ export const scenario1Data: ScenarioData = { params: { question: 'What tracking mode is the antenna currently using?', options: [ - 'Step-track - actively tracking beacon signal', + 'Step-track - actively tracking the received beacon signal', 'Program-track - following predicted orbital position', - 'Manual - operator-controlled pointing', - 'Stow - antenna in safe position', + 'Manual - holding operator-commanded azimuth and elevation', + 'Stow - antenna parked in its protected survival position', ], correctIndex: 1, - explanation: 'Program-track mode follows the predicted orbital position of the satellite based on ephemeris data. This mode is used when the beacon signal is not available, during initial acquisition, or when the satellite is GEO stationary and we don\'t want the ACU to make constant adjustments.', + explanation: + "Program-track mode follows the predicted orbital position of the satellite based on ephemeris data. This mode is used when the beacon signal is not available, during initial acquisition, or when the satellite is GEO stationary and we don't want the ACU to make constant adjustments.", pointPenalty: 10, }, mustMaintain: false, @@ -780,12 +769,13 @@ export const scenario1Data: ScenarioData = { question: 'What is the current polarization angle shown on the ACU, and why is it set to that value?', options: [ '14° - matched to TIDEMARK-1 satellite polarization', - '0° - default horizontal polarization', - '90° - vertical polarization', - '45° - circular polarization', + '0° - default horizontal polarization for C-band', + '90° - vertical polarization for the downlink', + '45° - midpoint setting for circular polarization', ], correctIndex: 0, - explanation: 'The polarization is set to 14° to match TIDEMARK-1\'s polarization angle. Proper polarization alignment maximizes signal strength and minimizes cross-pol interference.', + explanation: + "The polarization is set to 14° to match TIDEMARK-1's polarization angle. Proper polarization alignment maximizes signal strength and minimizes cross-pol interference.", pointPenalty: 10, }, mustMaintain: false, @@ -853,12 +843,13 @@ export const scenario1Data: ScenarioData = { question: 'What is the current alarm status shown on the dashboard?', options: [ 'No active alarms - all systems nominal', - 'Warning: LNB temperature high', - 'Error: GPSDO holdover mode', - 'Critical: Antenna tracking lost', + 'Warning alarm - LNB temperature high', + 'Error alarm - GPSDO in holdover mode', + 'Critical alarm - antenna tracking lost', ], correctIndex: 0, - explanation: 'A clean alarm dashboard with no active alarms confirms all equipment is operating within normal parameters. This is the final confirmation of a healthy ground station.', + explanation: + 'A clean alarm dashboard with no active alarms confirms all equipment is operating within normal parameters. This is the final confirmation of a healthy ground station.', pointPenalty: 10, }, mustMaintain: false, @@ -1206,4 +1197,4 @@ export const scenario1Data: ScenarioData = { }, }, }, -} \ No newline at end of file +}; diff --git a/src/campaigns/nats/scenario10.ts b/src/campaigns/nats/scenario10.ts index 34dd78a3..086636fd 100644 --- a/src/campaigns/nats/scenario10.ts +++ b/src/campaigns/nats/scenario10.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; @@ -6,7 +7,6 @@ import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { aurora7Satellite, tidemark1Satellite } from './satellites'; @@ -66,12 +66,7 @@ export const scenario10Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Customer Operations', description: `SeaLink has booked a 30-minute high-priority data window on AURORA-7 - a routine maritime synchronization burst Marcus Chen in Halifax is watching live from the spacecraft side. The link is up from the overnight shift on program-track with a conservative 10 dB HPA backoff.

For a sustained high-throughput window on an inclined-orbit bird, that's not the right configuration. You need step-track to hold beacon stable through the figure-8 drift, and a tighter HPA backoff to give the customer the EIRP margin they're paying for - without overdriving the amp.

Customer is on the line. Marcus will be watching payload telemetry on his end throughout the pass. Standard work - just don't break the link with a customer watching.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems'], timeLimitSeconds: 30 * 60, settings: { isSync: true, @@ -273,9 +268,9 @@ export const scenario10Data: ScenarioData = { question: 'What is the alarm state on VT-01 going into the pass?', options: [ 'No active alarms - clean board, link up on AURORA-7', - 'LNB reference unlocked', - 'HPA output fault', - 'GPSDO holdover', + 'LNB reference unlocked - RX alarm, link degraded on AURORA-7', + 'HPA output fault - TX alarm, uplink down on AURORA-7', + 'GPSDO holdover - timing alarm, link at risk on AURORA-7', ], correctIndex: 0, explanation: 'Clean board. Safe to push the link harder for the customer window.', @@ -305,12 +300,13 @@ export const scenario10Data: ScenarioData = { question: 'AURORA-7 is on program-track right now. Why is that wrong for a 30-minute high-throughput window?', options: [ 'Inclined orbit drift will degrade C/N across the pass - step-track holds beacon optimum continuously', - 'Program-track consumes more antenna motor cycles than step-track over a long pass', - 'Program-track is only certified for TIDEMARK birds, not AURORA', - 'Step-track is required by the customer contract regardless of orbit type', + 'Program-track burns more motor cycles over a long pass - step-track moves the dish in fewer, smaller steps', + 'Program-track is only certified for the TIDEMARK birds - step-track is the approved mode for AURORA-7', + 'The SeaLink contract requires step-track for every window - orbit type does not enter into the decision', ], correctIndex: 0, - explanation: 'Program-track follows ephemeris and lets the figure-8 drift bleed off C/N. For a sustained pass on an inclined bird, step-track keeps the beacon at peak.', + explanation: + 'Program-track follows ephemeris and lets the figure-8 drift bleed off C/N. For a sustained pass on an inclined bird, step-track keeps the beacon at peak.', pointPenalty: 5, }, mustMaintain: false, @@ -421,9 +417,9 @@ export const scenario10Data: ScenarioData = { question: 'Marcus wants confirmation his payload telemetry is clean. Which indicator set proves the data path is healthy end-to-end?', options: [ 'Frame sync locked + CRC valid + FEC engaged with no uncorrectables', - 'Receive power above -70 dBm alone', - 'Antenna beacon lock alone', - 'Modem temperature inside spec', + 'RX power above -70 dBm + AGC in range + C/N above the 8 dB threshold', + 'Antenna beacon lock + step-track holding + C/N at peak on the analyzer', + 'Modem temperature inside spec + fans nominal + no hardware alarms shown', ], correctIndex: 0, explanation: 'Beacon lock proves RF. Frame sync + CRC + FEC prove the data path. Different layers, different evidence - the customer cares about the data path.', @@ -496,9 +492,9 @@ export const scenario10Data: ScenarioData = { question: 'HPA is at 10 dB backoff. For a 30-minute customer window with sustained margin requirement, what is the operational reality?', options: [ 'Safe but conservative - tighter backoff gives the customer more EIRP without breaking the amp', - 'Already optimal - never lower backoff below 10 dB for any customer pass', - 'Too aggressive - raise backoff to 15 dB before the pass', - 'Backoff is irrelevant; only HPA output power matters', + 'Already optimal - never take backoff below 10 dB for a customer pass whatever the margin ask', + 'Too aggressive - raise backoff to 15 dB before the pass so the amp has room for the long window', + 'Irrelevant - only the HPA output power number matters and backoff is a bench setting for the crew', ], correctIndex: 0, explanation: '10 dB is a quiet-night default. For a paying customer expecting sustained margin, 6-7 dB buys real EIRP - so long as the amp stays linear.', @@ -560,8 +556,8 @@ export const scenario10Data: ScenarioData = { { id: 'imd-tradeoff-check', nice: ['K0740', 'S0675'], - title: 'Acknowledge the IMD Tradeoff', - description: 'Confirm the operational tradeoff between EIRP and amplifier linearity.', + title: 'Acknowledge the IMD Cost', + description: 'Confirm what extra EIRP costs in amplifier linearity.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['verify-no-overdrive'], timeLimitSeconds: 2 * 60, @@ -572,15 +568,16 @@ export const scenario10Data: ScenarioData = { description: 'EIRP vs Linearity', params: { character: Character.SYSTEM, - question: 'Lowering backoff from 10 dB to 6 dB increases EIRP. What is the tradeoff to watch during a sustained pass?', + question: 'Lowering backoff from 10 dB to 6 dB increases EIRP. What is the cost to watch during a sustained pass?', options: [ 'IMD products rise as the amp moves closer to saturation - monitor for overdrive across the window', - 'Backoff has no effect on intermodulation distortion', - 'Lower backoff cools the amp because gain is lower', - 'EIRP and IMD are independent and can be optimized separately', + 'Backoff has no effect on IMD at this power level - monitor the reflected power across the window', + 'Lower backoff cools the amp because the gain is lower - monitor the HPA temperature across the window', + 'EIRP and IMD are independent at 6 dB backoff - monitor the C/N on the return path across the window', ], correctIndex: 0, - explanation: 'Closer to saturation means more IMD. The decision is to spend a small amount of linearity for meaningful EIRP - then watch for overdrive across the window.', + explanation: + 'Closer to saturation means more IMD. The decision is to spend a small amount of linearity for meaningful EIRP - then watch for overdrive across the window.', pointPenalty: 5, }, mustMaintain: false, @@ -687,9 +684,9 @@ export const scenario10Data: ScenarioData = { question: 'Which line is the right disposition to give Marcus for the pass?', options: [ 'AURORA-7 pass complete - step-track held throughout, C/N margin sustained, no overdrive events, HPA returned to 10 dB', - 'Pass aborted - step-track failed to acquire', - 'Pass complete but link was on program-track the whole time', - 'Pass complete - HPA was overdriven briefly mid-window', + 'AURORA-7 pass aborted - step-track failed to acquire, C/N margin lost, no overdrive events, HPA returned to 10 dB', + 'AURORA-7 pass complete - program-track held throughout, C/N margin sustained, no overdrive events, HPA left at 6 dB', + 'AURORA-7 pass complete - step-track held throughout, C/N margin sustained, one brief overdrive event, HPA returned to 10 dB', ], correctIndex: 0, explanation: 'Clean delivery. That is what the customer is paying for - and what gets logged.', @@ -718,10 +715,10 @@ export const scenario10Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry correctly records this pass in the operations log?', options: [ - 'SeaLink 30-min high-priority pass on AURORA-7 - step-track engaged, HPA optimized to 6 dB backoff for window then restored to 10 dB. C/N margin sustained, no link events.', - 'AURORA-7 routine monitoring - no customer impact.', - 'SeaLink pass deferred - link not ready.', - 'AURORA-7 HPA fault during customer window.', + 'SeaLink pass on AURORA-7 - step-track, HPA 6 dB backoff for the window then restored to 10 dB. Margin held, no link events.', + 'AURORA-7 routine monitoring - program-track, HPA 10 dB backoff held throughout the shift. Margin held, no customer impact.', + 'SeaLink pass on AURORA-7 deferred - step-track engaged, HPA 6 dB backoff staged for the window. Link not ready, customer notified.', + 'SeaLink pass on AURORA-7 - step-track, HPA 6 dB backoff for the window and left there. Overdrive event, customer notified.', ], correctIndex: 0, explanation: 'Capture what changed (backoff), what was sustained (margin), and what was restored (resting state). Next operator picks up with full context.', diff --git a/src/campaigns/nats/scenario11.ts b/src/campaigns/nats/scenario11.ts index 329bd240..0096a346 100644 --- a/src/campaigns/nats/scenario11.ts +++ b/src/campaigns/nats/scenario11.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Character, Emotion } from '@app/modal/character-enum'; @@ -7,7 +8,6 @@ import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite, tidemark3Satellite } from './satellites'; @@ -67,13 +67,7 @@ export const scenario11Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Planned Operations', description: `Scheduled maintenance window opens at 10:00 for HPA waveguide gasket inspection on VT-01. Two hours of downtime, pre-coordinated with Maine.

Catherine has ME-02 standing by - already tracking TIDEMARK-1 in parallel, RX hot, waiting on your commit. Your job: verify her receive side, stage ME-02's transmit chain (cold - the transfer swaps RF authority), execute the handover, then safe VT-01 for the maintenance crew.

No fire, no weather, no surprise. This is procedural work and the grade is cleanliness. Maintenance crew arrives in thirty minutes.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Operational - RX Standing By', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Operational - RX Standing By'], timeLimitSeconds: 30 * 60, // 30 minutes settings: { isSync: true, @@ -138,36 +132,40 @@ export const scenario11Data: ScenarioData = { selectedTrace: 1, }, ], - transmitters: [{ - activeModem: 1, - modems: [{ - isPowered: true, - antenna_id: 1, - modem_number: 1, - isFaulted: false, - isTransmitting: false, - isTransmittingSwitchUp: false, - isFaultSwitchUp: false, - id: 1, - isLoopback: false, - ifSignal: { - signalId: 'TIDEMARK-1-Teleport', - serverId: 1, - noradId: 61525, - polarization: 'V', - feed: '', - isDegraded: false, - origin: SignalOrigin.TRANSMITTER, - noiseFloor: null, - gainInPath: 0 as dBi, - frequency: 1094e6 as IfFrequency, - power: -7 as dBm, - bandwidth: 36e6 as Hertz, - modulation: 'QPSK' as ModulationType, - fec: '3/4' as FECType, - }, - }], - }], + transmitters: [ + { + activeModem: 1, + modems: [ + { + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: false, + isTransmittingSwitchUp: false, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: 'TIDEMARK-1-Teleport', + serverId: 1, + noradId: 61525, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1094e6 as IfFrequency, + power: -7 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + }, + }, + ], + }, + ], receivers: [ { activeModem: 1, @@ -186,12 +184,7 @@ export const scenario11Data: ScenarioData = { ], }, ], - satellites: [ - tidemark1Satellite, - tidemark2Satellite, - tidemark3Satellite, - ses10Satellite, - ], + satellites: [tidemark1Satellite, tidemark2Satellite, tidemark3Satellite, ses10Satellite], trafficOwnership: [ { satelliteNoradId: 61525, // TIDEMARK-1 @@ -228,9 +221,9 @@ export const scenario11Data: ScenarioData = { question: 'What is the planned outcome of this shift?', options: [ 'TIDEMARK-1 traffic moves to ME-02 for a 2-hour VT-01 maintenance window, then comes back next shift', - 'TIDEMARK-1 traffic moves to ME-02 permanently and VT-01 is decommissioned', - 'TIDEMARK-1 is taken out of service for the duration of the maintenance window', - 'ME-02 takes over all VT-01 satellites for the maintenance window', + 'TIDEMARK-1 traffic moves to ME-02 permanently after the VT-01 maintenance window, then VT-01 is decommissioned', + 'TIDEMARK-1 traffic is taken off the air for the 2-hour VT-01 maintenance window, then comes back next shift', + 'All VT-01 satellites move to ME-02 for the 2-hour VT-01 maintenance window, then come back next shift', ], correctIndex: 0, explanation: 'Planned handover for a maintenance window. ME-02 holds TM-1 while the crew works VT-01. Return to service is the next shift.', @@ -290,10 +283,10 @@ export const scenario11Data: ScenarioData = { character: Character.SYSTEM, question: 'Why does a planned handover start with a baseline check of the source station?', options: [ - 'A pre-handover snapshot documents what the link looked like healthy, so any post-handover anomaly can be attributed correctly', - 'It is required by the satellite operator before they allow a transfer', - 'It gives the maintenance crew advance notice that work is starting', - 'It is needed to recover billing data', + 'It records what the link looked like healthy, so any anomaly after the transfer can be attributed', + 'It is required by the satellite operator, so the transfer request is approved before the crew arrives', + 'It gives the maintenance crew advance notice, so they know the work window is about to open', + 'It captures the billing counters at the switch, so the traffic hours can be recovered afterwards', ], correctIndex: 0, explanation: 'Baseline first. If something looks off on ME-02 after the transfer, you need to know whether it started before or after the handover.', @@ -448,7 +441,8 @@ export const scenario11Data: ScenarioData = { id: 'me-stage-tx-for-handover', nice: ['T0129', 'S0421', 'K0770'], title: 'Stage ME-02 Transmit', - description: 'Enable transmit on Modem 1 so the TX chain is staged - but leave the BUC muted and the HPA disabled. The handover transfer, not the operator, swaps RF authority between stations.', + description: + 'Enable transmit on Modem 1 so the TX chain is staged - but leave the BUC muted and the HPA disabled. The handover transfer, not the operator, swaps RF authority between stations.', groundStation: 'ME-02', prerequisiteObjectiveIds: ['me-verify-beacon-and-rx'], timeLimitSeconds: 4 * 60, @@ -497,13 +491,14 @@ export const scenario11Data: ScenarioData = { character: Character.SYSTEM, question: 'What makes ME-02 ready to commit to the TIDEMARK-1 handover?', options: [ - 'Antenna locked, RX carrier with C/N margin, TX chain staged - modem transmitting into a muted BUC, HPA disabled until the transfer swaps RF authority', - 'BUC unmuted and HPA enabled now - the station must be radiating before the transfer', - 'Antenna locked is sufficient - the rest happens during the transfer', - 'A successful loopback test before the transfer', + 'Antenna locked, RX carrier with C/N margin, TX staged cold - modem on, BUC muted, HPA disabled until the transfer', + 'Antenna locked, RX carrier with C/N margin, TX chain hot - modem on, BUC unmuted, HPA enabled before the transfer', + 'Antenna locked, beacon on the analyzer, TX chain untouched - the RX modem and TX chain come up during the transfer', + 'Antenna locked, RX carrier with C/N margin, loopback passed - modem on, BUC in loopback, HPA disabled until the transfer', ], correctIndex: 0, - explanation: 'Receive side proves the link works in; transmit side is staged but cold. If both stations radiated at the same transponder, the satellite would see two carriers - dual illumination. The handover stands the source down and brings the target up in one coordinated swap.', + explanation: + 'Receive side proves the link works in; transmit side is staged but cold. If both stations radiated at the same transponder, the satellite would see two carriers - dual illumination. The handover stands the source down and brings the target up in one coordinated swap.', pointPenalty: 5, }, mustMaintain: false, @@ -592,7 +587,7 @@ export const scenario11Data: ScenarioData = { id: 'catherine-confirm-hot', nice: ['K0718', 'K0741'], title: 'Confirm with Catherine', - description: 'Acknowledge Catherine\'s confirmation that ME-02 has the link clean.', + description: "Acknowledge Catherine's confirmation that ME-02 has the link clean.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['verify-me-traffic-owner'], timeLimitSeconds: 2 * 60, @@ -648,7 +643,8 @@ export const scenario11Data: ScenarioData = { id: 'vt-safe-tx-chain', nice: ['S0421', 'K0770', 'S0593'], title: 'Verify VT-01 TX Chain Safed', - description: 'The handover stood VT-01\'s transmit down automatically - HPA disabled, BUC muted. Verify it on the panel; never trust an automatic safing you have not looked at.', + description: + "The handover stood VT-01's transmit down automatically - HPA disabled, BUC muted. Verify it on the panel; never trust an automatic safing you have not looked at.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['switch-to-vermont-safing'], timeLimitSeconds: 3 * 60, @@ -731,9 +727,9 @@ export const scenario11Data: ScenarioData = { question: 'Which combination of conditions confirms VT-01 is safe for the maintenance crew to begin work?', options: [ 'HPA disabled, BUC muted, antenna at maintenance position - no RF energy on the feed, dish accessible', - 'HPA disabled alone is sufficient - the BUC cannot transmit without HPA', - 'Antenna stowed at 90° elevation is the correct maintenance position', - 'Powering off the entire RF rack is required before the crew approaches', + 'HPA disabled, BUC left unmuted, antenna at maintenance position - the BUC cannot radiate without the HPA', + 'HPA disabled, BUC muted, antenna stowed at 90° elevation - no RF energy on the feed, dish protected', + 'RF rack fully powered off, GPSDO off, antenna at maintenance position - nothing energized, dish accessible', ], correctIndex: 0, explanation: 'RF de-energized AND dish in a position the crew can reach. Stow at 90° protects against weather; maintenance at 5° lets a person work the feed.', @@ -766,10 +762,10 @@ export const scenario11Data: ScenarioData = { character: Character.SYSTEM, question: 'Which line correctly records this handover in the operations log?', options: [ - '1000 - Planned handover TM-1 VT-01 to ME-02 complete. VT-01 RF chain safed, antenna at maintenance position. Crew on-site for HPA waveguide inspection. ME-02 (Vega) carrying traffic. Return to service next shift.', - '1000 - Emergency handover TM-1 VT-01 to ME-02. Cause unknown. Investigation pending.', - '1000 - Maintenance crew arrived. Traffic handover deferred to next shift.', - '1000 - TM-1 traffic resumed on VT-01 after maintenance complete.', + '1000 - Planned TM-1 handover to ME-02 complete. VT-01 safed, antenna at maintenance position, crew on-site. Return to service next shift.', + '1000 - Emergency TM-1 handover to ME-02 complete. VT-01 fault cause unknown, antenna still on TM-1, crew on-site. Investigation pending.', + '1000 - Crew on-site for VT-01 HPA waveguide gasket. TM-1 handover to ME-02 deferred, VT-01 still carrying traffic. Return to service next shift.', + '1000 - Planned TM-1 handover to ME-02 complete. VT-01 maintenance finished, antenna back on TM-1, crew released. Traffic resumed on VT-01.', ], correctIndex: 0, explanation: 'Who, what, why, where it stands, who has the link. The next operator should be able to pick up the shift without asking a question.', diff --git a/src/campaigns/nats/scenario12.ts b/src/campaigns/nats/scenario12.ts index c7f58bcf..85785ca4 100644 --- a/src/campaigns/nats/scenario12.ts +++ b/src/campaigns/nats/scenario12.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Character, Emotion } from '@app/modal/character-enum'; @@ -6,7 +7,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -69,13 +69,7 @@ export const scenario12Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Maintenance Recovery', description: `Waveguide gasket inspection is done. Maintenance crew is clear of the antenna and signed out. VT-01 is stowed, RF chain cold, and Catherine is holding TM-1 traffic on ME-02.

Bring Vermont back the clean way: antenna on target, RX chain validated against the beacon, transmit side swept for any leftovers the maintenance crew left behind, then a coordinated handover return from Maine. Marcus will want to hear from us once SeaLink's link is back where it started.

No clock pressure. Just do it right.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Holding TM-1 traffic', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Holding TM-1 traffic'], timeLimitSeconds: 35 * 60, // 35 minutes settings: { isSync: true, @@ -223,11 +217,7 @@ export const scenario12Data: ScenarioData = { ], }, ], - satellites: [ - tidemark1Satellite, - tidemark2Satellite, - ses10Satellite, - ], + satellites: [tidemark1Satellite, tidemark2Satellite, ses10Satellite], trafficOwnership: [ { satelliteNoradId: 61525, // TIDEMARK-1 @@ -327,7 +317,8 @@ export const scenario12Data: ScenarioData = { 'All equipment powered down including GPSDO', ], correctIndex: 0, - explanation: 'You parked the dish at maintenance position (5°) for the crew; on sign-out they stowed it at 90° per post-work SOP. Stowed antenna + cold RF chain + GPSDO still up for timing continuity is the expected handback state. Anything else means somebody touched something they should not have.', + explanation: + 'You parked the dish at maintenance position (5°) for the crew; on sign-out they stowed it at 90° per post-work SOP. Stowed antenna + cold RF chain + GPSDO still up for timing continuity is the expected handback state. Anything else means somebody touched something they should not have.', pointPenalty: 5, }, mustMaintain: false, @@ -508,12 +499,13 @@ export const scenario12Data: ScenarioData = { question: 'BUC, HPA, and TX modem are all powered off as expected. What is out of operating spec?', options: [ 'BUC gain is at 50 dB - testing value left over from maintenance, operating value is 23 dB', - 'BUC LO is wrong - operating value should be 6,500 MHz, not 7,000 MHz', - 'HPA backoff is at 10 dB - should be 0 dB for full operation', - 'Nothing is out of spec - state matches the operating baseline', + 'BUC LO is at 7,000 MHz - testing value left over from maintenance, operating value is 6,500 MHz', + 'HPA backoff is at 10 dB - resting value left over from the crew, operating value is 0 dB', + 'Nothing is out of spec - gain, LO and backoff all match the operating baseline for this chain', ], correctIndex: 0, - explanation: 'The bench-test gain (50 dB) was never dialed back to 23 dB. With BUC powered off this is harmless, but bringing the chain up at this gain would over-drive the HPA the moment loopback or transmit was engaged. Catch it before you energize.', + explanation: + 'The bench-test gain (50 dB) was never dialed back to 23 dB. With BUC powered off this is harmless, but bringing the chain up at this gain would over-drive the HPA the moment loopback or transmit was engaged. Catch it before you energize.', pointPenalty: 10, }, mustMaintain: false, @@ -649,7 +641,8 @@ export const scenario12Data: ScenarioData = { id: 'verify-tx-staged', nice: ['T1567', 'K0645', 'S0421'], title: 'Confirm TX Staged Cold', - description: 'Confirm the transmit chain is staged for the handover return: modem transmitting into a muted BUC, HPA output disabled. The transfer brings VT-01 RF up as Maine stands down.', + description: + 'Confirm the transmit chain is staged for the handover return: modem transmitting into a muted BUC, HPA output disabled. The transfer brings VT-01 RF up as Maine stands down.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['start-modem-transmitting'], timeLimitSeconds: 2 * 60, @@ -682,15 +675,16 @@ export const scenario12Data: ScenarioData = { description: 'Why TX Stays Cold', params: { character: Character.SYSTEM, - question: 'ME-02 is still carrying TIDEMARK-1. Why does VT-01\'s transmit chain stay cold (BUC muted, HPA disabled) until the handover executes?', + question: "ME-02 is still carrying TIDEMARK-1. Why does VT-01's transmit chain stay cold (BUC muted, HPA disabled) until the handover executes?", options: [ - 'Two stations radiating at the same transponder is dual illumination - the handover swaps RF authority in one coordinated action so only one uplink is ever on the air', - 'The BUC cannot be unmuted while the antenna is in program-track', - 'It saves power until the customer confirms the return', - 'The HPA needs the maintenance crew to re-certify it before it can radiate', + 'Two uplinks on one transponder is dual illumination - the handover swaps RF authority so only one is ever on the air', + 'The BUC cannot be unmuted in program-track - the handover switches the ACU to step-track before RF comes up', + 'Radiating into a muted BUC wastes HPA hours - the handover brings RF up only once the customer confirms the return', + 'The HPA is not yet re-certified after the gasket work - the handover carries the crew sign-off that unlocks its output', ], correctIndex: 0, - explanation: 'Same rule that protected the S11 hand-off: the satellite must never see two carriers fighting on one transponder. Stage the chain, then let the transfer stand Maine down and bring Vermont up atomically.', + explanation: + 'Same rule that protected the S11 hand-off: the satellite must never see two carriers fighting on one transponder. Stage the chain, then let the transfer stand Maine down and bring Vermont up atomically.', pointPenalty: 5, }, mustMaintain: false, @@ -741,12 +735,13 @@ export const scenario12Data: ScenarioData = { question: 'Which combination is required on VT-01 before pulling TM-1 traffic back?', options: [ 'Beacon lock + RX modem lock with C/N margin + TX chain staged cold (modem on, BUC muted, HPA disabled)', - 'BUC unmuted and HPA radiating - the link must be hot before the transfer', - 'Antenna pointed at TM-1 only - the rest comes up when traffic transfers', - 'No specific criteria - planned handovers cannot fail', + 'Beacon lock + RX modem lock with C/N margin + TX chain hot (modem on, BUC unmuted, HPA radiating)', + 'Antenna pointed at TM-1 + program-track holding + TX chain off (modem off, BUC off, HPA off until transfer)', + 'Beacon lock + RX modem lock with C/N margin + loopback test passed (modem on, BUC in loopback, HPA disabled)', ], correctIndex: 0, - explanation: 'Symmetric to S11: the receiving station proves its receive side and stages its transmit side cold. The transfer stands Maine down and brings Vermont up in one swap - radiating early would put two carriers on the transponder.', + explanation: + 'Symmetric to S11: the receiving station proves its receive side and stages its transmit side cold. The transfer stands Maine down and brings Vermont up in one swap - radiating early would put two carriers on the transponder.', pointPenalty: 10, }, mustMaintain: false, @@ -795,7 +790,7 @@ export const scenario12Data: ScenarioData = { id: 'verify-handover-success', nice: ['T0153', 'K0741'], title: 'Confirm Return Complete', - description: 'Verify the handover return landed clean.', + description: 'Verify the handover return came back clean.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['execute-handover-return'], timeLimitSeconds: 2 * 60, @@ -825,9 +820,9 @@ export const scenario12Data: ScenarioData = { question: 'Traffic ownership has returned to VT-01. What confirms the return was clean?', options: [ 'VT-01 owns TM-1 traffic, ME-02 TX stood down, no packet loss reported, no new alarms', - 'ME-02 antenna automatically stowed', - 'VT-01 C/N ratio increased after the transfer', - 'Customer dialed in to manually re-register', + 'VT-01 owns TM-1 traffic, ME-02 antenna auto-stowed, RX modem re-locked, no new alarms', + 'VT-01 owns TM-1 traffic, C/N ratio up after the transfer, HPA enabled, no new alarms', + 'VT-01 owns TM-1 traffic, customer re-registered manually, packet loss cleared, no new alarms', ], correctIndex: 0, explanation: 'Clean return: target station owns the traffic, source station released, no service interruption, no alarms. From the customer side, nothing happened.', @@ -861,12 +856,13 @@ export const scenario12Data: ScenarioData = { question: 'Marcus is waiting for confirmation. What is the right message?', options: [ 'VT-01 restored, TM-1 traffic returned to primary station, no service interruption observed.', - 'Service restored, please rerun all customer health checks on your end.', - 'Brief outage during handover - apologies, please flag any data loss.', - 'No notification needed - the link was never down from their perspective.', + 'VT-01 restored, TM-1 traffic returned to primary station, please rerun all your health checks.', + 'VT-01 restored, brief outage during the handover return, please flag any data loss to us.', + 'No message sent, TM-1 traffic never dropped from the customer side, nothing to report.', ], correctIndex: 0, - explanation: 'Notify the spacecraft side of the change in source station and confirm no impact. They do not need to act, but they do need to know where the uplink is coming from now.', + explanation: + 'Notify the spacecraft side of the change in source station and confirm no impact. They do not need to act, but they do need to know where the uplink is coming from now.', pointPenalty: 5, }, mustMaintain: false, @@ -900,9 +896,9 @@ export const scenario12Data: ScenarioData = { question: 'Final VT-01 health check after restoration.', options: [ 'No active alarms - all systems nominal, TM-1 traffic on primary', - 'BUC over-temperature', - 'LNB reference unlocked', - 'HPA overdriven', + 'BUC over-temperature - TX chain alarm, TM-1 traffic on primary', + 'LNB reference unlocked - RX chain alarm, TM-1 traffic degraded', + 'HPA overdriven - TX chain alarm, TM-1 traffic still held on ME-02', ], correctIndex: 0, explanation: 'Clean restoration. The BUC gain catch earlier is the reason the HPA is not overdriven right now.', @@ -931,10 +927,10 @@ export const scenario12Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry correctly closes out the S11-S12 maintenance cycle?', options: [ - 'VT-01 returned to service post-waveguide-gasket inspection. TM-1 traffic returned from ME-02. Maintenance leftover (BUC gain 50 dB) corrected before energizing. No customer impact.', - 'VT-01 restored. Customer reported brief outage during handover.', - 'Maintenance window incomplete - HPA work deferred.', - 'Traffic transferred to ME-02 for weather event.', + 'VT-01 back in service after gasket inspection. TM-1 returned from ME-02. BUC gain leftover (50 dB) corrected before energizing. No customer impact.', + 'VT-01 back in service after gasket inspection. TM-1 returned from ME-02. Customer reported a brief outage during the return. Ticket opened.', + 'VT-01 maintenance window incomplete. TM-1 still on ME-02. HPA waveguide gasket work deferred to the next window. No customer impact.', + 'VT-01 back in service after gasket inspection. TM-1 returned from ME-02. Chain energized at the values the crew left, no leftovers found. No customer impact.', ], correctIndex: 0, explanation: 'Logging the leftover is the part that matters. Next shift sees what the maintenance crew did and what we caught - that is how the pattern gets fixed.', diff --git a/src/campaigns/nats/scenario13.ts b/src/campaigns/nats/scenario13.ts index cec5c68b..764af861 100644 --- a/src/campaigns/nats/scenario13.ts +++ b/src/campaigns/nats/scenario13.ts @@ -1,9 +1,9 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { maineGroundStation, vermontGroundStation } from './ground-stations'; import { tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -48,13 +48,7 @@ export const scenario13Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Trend Assessment', description: `Mid-shift on VT-01. TIDEMARK-1 carrying normal customer traffic. The trend display flagged something fifteen minutes ago: BUC temperature has been climbing roughly a third of a degree per minute - 57°C up to 62°C, no alarm yet, but the slope is unambiguous.

Nothing else has moved. GPSDO locked, RX beacon clean, HPA in backoff. The question is whether to act now, schedule a swap and keep going, switch to backup, or hold and monitor.

The right answer is judgment, not a checklist. Read the trend, pick a course of action, and execute it without putting the customer in the dark.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Operational', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Operational'], timeLimitSeconds: 30 * 60, settings: { isSync: true, @@ -182,9 +176,9 @@ export const scenario13Data: ScenarioData = { question: 'BUC over-temperature trip is at 70°C. Current reading is 62°C. What does that mean for your decision timeline?', options: [ 'Pre-alarm - you have time to choose a deliberate action instead of a reflexive one', - 'Already in alarm - mute the BUC immediately', - 'No relevance - the trip point is fixed and the trend is unrelated', - 'Threshold has been raised by the maintenance crew - ignore the reading', + 'Already in alarm - the 62°C reading means the BUC must be muted right now', + 'No relevance - the 70°C trip point is fixed and the slope does not change it', + 'Threshold raised - the maintenance crew moved the trip point, so ignore the reading', ], correctIndex: 0, explanation: 'Pre-alarm trends are the ideal time to act. Acting at alarm means you are already late.', @@ -266,10 +260,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'BUC current draw is 4.1A (nominal ~3.0A). What does the elevated current tell you?', options: [ - 'BUC is dissipating more electrical power - consistent with the thermal rise, not a separate fault', - 'Power supply is failing and pushing extra current into the module', - 'Current is unrelated to thermal state - look at LNB telemetry instead', - 'BUC is in over-current shutdown', + 'BUC is dissipating more power as heat - consistent with the thermal rise, not a separate fault', + 'Power supply is failing - it is pushing extra current into the module as a separate fault', + 'Current is unrelated to thermal state - the heat source is elsewhere, so check LNB telemetry instead', + 'BUC is in over-current shutdown - 4.1A is past the protection limit and the output is off', ], correctIndex: 0, explanation: 'Heat in an amplifier comes from electrical power that does not leave as RF. Higher current + higher temperature is one story, not two.', @@ -330,10 +324,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'Before you act, which set of readings belongs in the log to make this trend reconstructable later?', options: [ - 'Time, BUC temperature, BUC current, BUC gain, HPA backoff - so the next operator can rebuild the curve', - 'BUC temperature alone - the rest is derivable from it', - 'Customer SLA metrics only - the equipment state is irrelevant', - 'A screenshot of the dashboard', + 'Time, BUC temperature, BUC current, BUC gain, HPA backoff - the next operator can rebuild the curve', + 'BUC temperature and time only - current, gain and backoff are all derivable from the curve', + 'Customer SLA metrics, carrier C/N, modem lock - the equipment state is not what the log is for', + 'A dashboard screenshot at 62°C plus the 70°C trip point - one picture captures the whole state at once', ], correctIndex: 0, explanation: 'Trend records are only useful if someone else can replay them. Capture the inputs, not just the outputs.', @@ -366,10 +360,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'BUC gain is 33 dB. The operating value for this chain is 23 dB. What is the most likely root cause of the thermal trend?', options: [ - 'BUC gain is set higher than required - the module is dissipating the excess as heat instead of useful RF', - 'BUC is failing internally and should be swapped immediately - no other action is safe', - 'HPA is overdriven and bleeding heat backward into the BUC', - 'Ambient temperature in the equipment room is rising', + 'BUC gain is 10 dB above the operating value - the module is dissipating the excess as heat, not RF', + 'BUC is failing internally - the gain reading is a symptom, and only a swap makes the chain safe', + 'HPA is overdriven at 8 dB backoff - it is bleeding heat backward into the BUC through the drive path', + 'Ambient temperature in the equipment room is rising - the gain setting is a coincidence, not the cause', ], correctIndex: 0, explanation: 'When the gain stage is set above what the drive chain needs, the excess turns into heat. Classic over-gain dissipation.', @@ -398,14 +392,15 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'You have time. The trend is real but pre-alarm. Which response addresses the root cause at the lowest customer cost?', options: [ - 'De-rate now: reduce BUC gain ~10 dB to cut dissipation, monitor the trend reverse, schedule a swap during the next planned window', - 'Swap the BUC now - take the carrier down hard and let maintenance replace the module', - 'Mute and switch traffic to the backup modem chain immediately', - 'Hold and monitor - thermal trends usually flatten on their own', + 'De-rate now - reduce BUC gain ~10 dB, watch the trend reverse, schedule a swap for the next planned window', + 'Swap now - take the carrier down, have maintenance replace the module, restore service on the new unit', + 'Mute and switch - move traffic to the backup modem chain now, let the BUC cool with no drive on it', + 'Hold and monitor - 8°C of headroom is enough, thermal trends usually flatten before the trip point', ], correctIndex: 0, preserveOptionOrder: true, - explanation: 'De-rating addresses the cause (excess dissipation) without taking the customer down. Swap-now is over-spend; mute-and-switch is over-reaction; hold-and-monitor ignores a trend that has not flattened in 15 minutes.', + explanation: + 'De-rating cuts the dissipation at its source without taking the customer down. Swap-now is over-spend; mute-and-switch is over-reaction; hold-and-monitor ignores a trend that has not flattened in 15 minutes.', pointPenalty: 10, }, mustMaintain: false, @@ -431,10 +426,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'What is the correct sequence for the de-rate?', options: [ - 'Lower BUC gain ~10 dB, verify HPA still in linear region, verify carrier still nominal, then watch the temperature curve bend', - 'Mute the BUC, change the gain, then unmute - to be safe', - 'Lower BUC gain in a single step to 0 dB, then ramp back up while watching telemetry', - 'Change BUC gain only after physically swapping the module', + 'Lower BUC gain ~10 dB, verify HPA still linear, verify carrier still nominal, then watch the temperature curve', + 'Mute the BUC, lower the gain ~10 dB, verify carrier returns after unmute, then watch the temperature curve', + 'Lower BUC gain to 0 dB in one step, ramp back up to 23 dB, verify carrier, then watch the temperature curve', + 'Open the swap ticket first, hold the gain at 33 dB, change gain only on the new module, then watch the curve', ], correctIndex: 0, preserveOptionOrder: true, @@ -519,10 +514,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'How do you know the de-rate worked before you log the shift?', options: [ - 'Watch 5-10 minutes: temperature slope flattens then trends down, current draw drops toward nominal, carrier still locked downstream', - 'Temperature drops instantly the moment the gain knob moves', - 'Trust the gain change is correct and move on - thermal verification is not needed', - 'Mute the carrier and re-measure cold-start temperature', + 'Watch 5-10 minutes: temperature slope flattens then trends down, current drops toward 3.0A, carrier still locked', + 'Watch the first reading: temperature drops the moment the gain changes, current follows, carrier still locked', + 'Check the setpoint: gain reads 23 dB, HPA backoff has opened up, no thermal verification is needed beyond that', + 'Mute and re-measure: carrier off for a minute, cold-start temperature recorded, then unmute and compare readings', ], correctIndex: 0, preserveOptionOrder: true, @@ -556,10 +551,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'What belongs in the BUC swap ticket for the next planned maintenance window?', options: [ - 'Trend record (15-min curve), de-rate action taken, current BUC gain/backoff settings, recommendation to swap module during next planned window', - 'Just "BUC running hot" - the maintenance crew will figure out the rest', - 'A request to replace the entire RF front end', - 'A note that the trend was a false alarm and no action is needed', + 'Trend record (15-min curve), de-rate action taken, current gain/backoff settings, swap recommended for next planned window', + 'Symptom only (BUC running hot), current temperature reading, no settings - the crew will pull their own telemetry', + 'Full RF front end replacement (BUC, HPA, LNB), current temperature reading, request for the earliest available window', + 'Trend record (15-min curve), note that de-rate resolved it, no swap needed - close the ticket once the temperature settles', ], correctIndex: 0, preserveOptionOrder: true, @@ -597,9 +592,9 @@ export const scenario13Data: ScenarioData = { question: 'Final state of VT-01 after the action?', options: [ 'No active alarms, BUC running de-rated, carrier nominal, swap ticket open against next planned window', - 'BUC in alarm, customer down, ticket open', - 'BUC swapped on-shift, customer briefly down, ticket closed', - 'No change - hold and monitor still in effect', + 'BUC over-temperature alarm active, carrier muted, customer down, swap ticket open against next planned window', + 'No active alarms, BUC swapped on-shift, customer briefly down during the swap, maintenance ticket closed', + 'No active alarms, BUC gain unchanged at 33 dB, carrier nominal, hold and monitor still in effect for next shift', ], correctIndex: 0, preserveOptionOrder: true, @@ -629,10 +624,10 @@ export const scenario13Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry correctly records this action in the operations log?', options: [ - '1003 - VT-01 BUC thermal trend (57°C->62°C over 15 min) addressed by 10 dB gain de-rate. Trend reversing. Swap ticket opened for next planned window. Carrier nominal throughout.', - '1003 - BUC failure on VT-01, customer outage logged.', - '1003 - Hold and monitor - no action taken.', - '1003 - BUC swap completed on-shift.', + '1003 - VT-01 BUC thermal trend (57->62°C over 15 min) addressed by 10 dB gain de-rate. Trend reversing, swap ticket open, carrier nominal.', + '1003 - VT-01 BUC failure (over-temperature alarm at 70°C) forced BUC mute. Customer outage logged, swap ticket open, carrier restored.', + '1003 - VT-01 BUC thermal trend (57->62°C over 15 min) noted. Hold and monitor, no action taken, next shift to reassess, carrier nominal.', + '1003 - VT-01 BUC thermal trend (57->62°C over 15 min) addressed by on-shift BUC swap. Customer briefly down, ticket closed, carrier restored.', ], correctIndex: 0, preserveOptionOrder: true, diff --git a/src/campaigns/nats/scenario14.ts b/src/campaigns/nats/scenario14.ts index 78106b61..1ed2f3cf 100644 --- a/src/campaigns/nats/scenario14.ts +++ b/src/campaigns/nats/scenario14.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Receiver } from '@app/equipment/receiver/receiver'; @@ -7,7 +8,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -68,13 +68,7 @@ export const scenario14Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Weather Contingency', description: `Rain front moving over Vermont. Light to moderate, maybe twenty minutes through. The link will fade but it shouldn't black out.

The customer - James Okafor at SeaLink - has called ahead. Their SLA terms penalize handover events more than they penalize a few dB of margin loss, so he's asked us to hold VT-01 through the weather if we can. ME-02 is busy on TIDEMARK-2 and would have to drop its own customers to take TIDEMARK-1.

Your job: enable the feed heater, watch AGC headroom, track the beacon C/N, and make the call. Hold or hand off - the right answer is the one the link supports.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End (Feed Heater, AGC)', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Operational (TIDEMARK-2)', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End (Feed Heater, AGC)', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Operational (TIDEMARK-2)'], timeLimitSeconds: 35 * 60, // 35 minutes settings: { isSync: true, @@ -115,7 +109,7 @@ export const scenario14Data: ScenarioData = { ], rfFrontEnds: [ createRfFrontEnd(vermontGroundStation.rfFrontEnds[0], { - // ME-02 mirrors VT-01's LNB LO so TM-2 beacon lands at 1070 MHz IF. + // ME-02 mirrors VT-01's LNB LO so TM-2 beacon comes out at 1070 MHz IF. // ME-02 TX is active on its own TIDEMARK-2 traffic; player should // not touch it during this scenario. buc: { isMuted: true }, @@ -146,11 +140,7 @@ export const scenario14Data: ScenarioData = { receivers: [Receiver.getDefaultState()], }, ], - satellites: [ - tidemark1Satellite, - tidemark2Satellite, - ses10Satellite, - ], + satellites: [tidemark1Satellite, tidemark2Satellite, ses10Satellite], weatherEvents: [ { id: 'vermont-rain-front', @@ -231,12 +221,13 @@ export const scenario14Data: ScenarioData = { question: 'James from SeaLink is asking us to hold VT-01 through the rain rather than hand off. What is the operational reason?', options: [ 'Their SLA penalizes handover events more heavily than a few dB of margin loss', - 'ME-02 has not been certified to carry SeaLink traffic', - 'The handover process always causes a customer outage', - 'Maine is currently weathered in as well', + 'Their SLA does not allow ME-02 to carry SeaLink traffic without prior certification', + 'The handover process always drops their vessel telemetry, so every handover is a logged outage', + 'ME-02 is busy carrying TIDEMARK-2, so there is no station available to hand over to', ], correctIndex: 0, - explanation: 'SeaLink runs vessel telemetry that survives short C/N dips but logs a hard event on every uplink change. A handover event costs them more contractually than 3 dB of fade does. The customer is telling us what they value - listen.', + explanation: + 'SeaLink runs vessel telemetry that survives short C/N dips but logs a hard event on every uplink change. A handover event costs them more contractually than 3 dB of fade does. The customer is telling us what they value - listen.', pointPenalty: 5, }, mustMaintain: false, @@ -294,9 +285,9 @@ export const scenario14Data: ScenarioData = { question: 'What is VT-01 reporting before the front arrives?', options: [ 'No active alarms, link healthy - clean baseline to fade from', - 'BUC over-temperature, do not transmit', - 'Antenna tracking error', - 'GPSDO in holdover', + 'BUC over-temperature alarm - hold transmit until it cools', + 'Antenna tracking error - beacon lock lost before the front arrived', + 'GPSDO in holdover - reference alarm, timing at risk', ], correctIndex: 0, explanation: 'Clean baseline. Note the C/N now so you know what "nominal" looks like when the rain starts pulling it down.', @@ -351,13 +342,14 @@ export const scenario14Data: ScenarioData = { character: Character.SYSTEM, question: 'The heater is doing what for a rain event that is well above freezing?', options: [ - 'Keeping water from beading and sheeting on the feed - dry surfaces attenuate less than wet ones', - 'Heating the LNB to compensate for cold rain', - 'Preventing ice (rain at 12°C cannot freeze)', - 'Boosting RF gain through the feed', + 'Keeping water from sheeting on the feed - dry surfaces attenuate less than wet ones', + 'Keeping the LNB warm through the cold rain - noise temperature climbs as the LNB cools', + 'Preventing ice on the feed - the radome cools below air temperature and 12°C rain can freeze', + 'Raising RF gain through the feed - a warm feed horn passes more of the incoming signal', ], correctIndex: 0, - explanation: 'Standing water on the feed is itself an attenuator. The heater keeps surfaces above dew point so droplets evaporate instead of pooling. Same hardware as the anti-icing case in S3, different mechanism.', + explanation: + 'Standing water on the feed is itself an attenuator. The heater keeps surfaces above dew point so droplets evaporate instead of beading and pooling. Same hardware as the anti-icing case in S3, different mechanism - rain at 12°C is not going to freeze.', pointPenalty: 5, }, mustMaintain: false, @@ -446,12 +438,13 @@ export const scenario14Data: ScenarioData = { question: 'Why does noting the baseline C/N matter for this scenario specifically?', options: [ 'It defines how much fade the link can absorb before reaching the demodulation threshold', - 'It tells the customer how much power we are using', - 'It calibrates the spectrum analyzer for the storm', - 'It is required by the modem firmware', + 'It tells the customer how much uplink power we are spending to hold their carrier', + 'It calibrates the spectrum analyzer reference level before the rain shifts the noise floor', + 'It is required by the modem firmware before it will hold lock through a fade', ], correctIndex: 0, - explanation: 'Margin = baseline C/N minus demod threshold. With 10+ dB of headroom and 3 dB of expected fade, we are comfortable. Without baseline data, every dip looks scary.', + explanation: + 'Margin = baseline C/N minus demod threshold. With 10+ dB of headroom and 3 dB of expected fade, we are comfortable. Without baseline data, every dip looks scary.', pointPenalty: 5, }, mustMaintain: false, @@ -521,13 +514,14 @@ export const scenario14Data: ScenarioData = { character: Character.SYSTEM, question: 'Input level dropped a few dB as the rain hit, but the post-AGC output level is steady. What does that tell you?', options: [ - 'AGC is compensating - the demodulator still sees a usable signal, and we still have headroom in the gain stage', - 'The fade is over and the rain has stopped', - 'The LNB has automatically raised its gain', - 'The receiver has switched to a backup carrier', + 'AGC is compensating - the demodulator still sees a usable signal, and the gain stage still has headroom', + 'The fade is over - the rain has already passed and the input level will climb back within a minute', + 'The LNB has raised its own gain - the front end compensates before the signal reaches the receiver', + 'The receiver has switched to a backup carrier - the steady output is a different signal, not TM-1', ], correctIndex: 0, - explanation: 'AGC absorbs the first several dB of fade transparently. The thing to watch is not the output level (that is what AGC stabilizes) but how much gain the AGC is using. When it approaches its max, you are out of cushion.', + explanation: + 'AGC absorbs the first several dB of fade transparently. The thing to watch is not the output level (that is what AGC stabilizes) but how much gain the AGC is using. When it approaches its max, you are out of cushion.', pointPenalty: 5, }, mustMaintain: false, @@ -554,12 +548,13 @@ export const scenario14Data: ScenarioData = { question: 'AGC is currently using ~3 dB of its compensation range. What does that say about the decision to hold?', options: [ 'Plenty of headroom remaining - link is comfortable, hold is justified', - 'AGC at any non-zero value means we should hand over immediately', - 'AGC compensation has no relationship to handover decisions', - 'We must reduce HPA backoff to relieve the AGC', + 'Headroom is already spent - any non-zero AGC value means hand over now', + 'Headroom is irrelevant - AGC compensation has no bearing on the handover call', + 'Headroom is shrinking - reduce HPA backoff now to relieve the AGC', ], correctIndex: 0, - explanation: 'AGC near floor = wide cushion. The handover trigger is not AGC active - it is AGC near max with continued fade, or modem unlock. We are nowhere near either.', + explanation: + 'AGC near floor = wide cushion. The handover trigger is not AGC active - it is AGC near max with continued fade, or modem unlock. We are nowhere near either.', pointPenalty: 5, }, mustMaintain: false, @@ -605,18 +600,19 @@ export const scenario14Data: ScenarioData = { conditions: [ { type: 'status-check', - description: 'Backoff Tradeoff', + description: 'Backoff Cost', params: { character: Character.SYSTEM, question: 'The downlink C/N is holding above threshold with AGC headroom to spare. Should you reduce HPA backoff to push more uplink power?', options: [ 'No - the link is healthy; trading IMD risk for unused margin is a bad bargain', - 'Yes - always run the HPA hot during weather', - 'Yes - the customer requires maximum uplink power during a fade', - 'No - because the HPA cannot be adjusted while transmitting', + 'Yes - the fade is active; running the HPA hot during weather is standard practice', + 'Yes - the customer asked us to hold; maximum uplink power is what holding requires', + 'No - the HPA is carrying traffic; backoff cannot be adjusted while transmitting', ], correctIndex: 0, - explanation: 'Optimization is not "turn every dial to max." If the link has margin you do not need, the responsible move is to not spend it. Reduced backoff means higher IMD on neighbors and more amplifier stress for no operational benefit here. Keep the configuration nominal.', + explanation: + 'Optimization is not "turn every dial to max." If the link has margin you do not need, the responsible move is to not spend it. Reduced backoff means higher IMD on neighbors and more amplifier stress for no operational benefit here. Keep the configuration nominal.', pointPenalty: 5, }, mustMaintain: false, @@ -679,12 +675,13 @@ export const scenario14Data: ScenarioData = { question: 'The customer prefers no handover, but the customer does not get the last word on RF reality. Which condition flips the call to "hand off now"?', options: [ 'AGC at max with continued fade, or modem dropping lock', - 'Any drop in C/N from baseline', - 'The first raindrop hitting the dish', - 'When ME-02 reports it would be available', + 'Any drop in C/N from baseline, or AGC leaving its floor', + 'Rain reaching the dish, or the first dB of input fade', + 'ME-02 reporting available, or Dana asking for a status', ], correctIndex: 0, - explanation: 'The customer can ask us to favor "hold." The customer cannot ask us to keep serving on a dead link. The trigger is operational, not contractual: AGC at max and still fading, or actual lock loss.', + explanation: + 'The customer can ask us to favor "hold." The customer cannot ask us to keep serving on a dead link. The trigger is operational, not contractual: AGC at max and still fading, or actual lock loss.', pointPenalty: 5, }, mustMaintain: false, @@ -711,9 +708,9 @@ export const scenario14Data: ScenarioData = { question: 'Your call - what are we doing for the rest of this front?', options: [ 'Hold VT-01. AGC has headroom, modem locked, customer preference honored. Re-evaluate if state changes.', - 'Begin immediate handover to ME-02.', - 'Stow the antenna and accept the outage.', - 'Reduce HPA backoff to push more uplink power.', + 'Hand off to ME-02. AGC is active, fade still building, customer told we tried. Re-acquire on VT-01 after.', + 'Stow VT-01. Rain is heaviest at the peak, feed protected, customer notified. Re-acquire when the front clears.', + 'Hold VT-01. Reduce HPA backoff 2 dB, push uplink power up, customer preference honored. Restore after the front.', ], correctIndex: 0, explanation: 'Right call. We are holding. State the trigger conditions to yourself so you know when to flip - that is the discipline of holding, not just inertia.', @@ -789,12 +786,13 @@ export const scenario14Data: ScenarioData = { question: 'The rain is clearing. What do you expect to see in the next few minutes?', options: [ 'C/N recovers toward baseline; AGC backs its gain down; modem lock unchanged', - 'C/N stays depressed - the link has been permanently degraded', - 'AGC stays at max because it cannot reset', - 'Modem must be manually re-locked', + 'C/N stays depressed; AGC holds its gain; the wet feed keeps the link degraded', + 'C/N recovers toward baseline; AGC stays at max until reset; modem lock unchanged', + 'C/N recovers toward baseline; AGC backs its gain down; modem must be re-locked by hand', ], correctIndex: 0, - explanation: 'Rain fade is transient. The AGC tracks the recovery downward without intervention, and the demod has had lock the whole time. Nothing to do but watch it normalize.', + explanation: + 'Rain fade is transient. The AGC tracks the recovery downward without intervention, and the demod has had lock the whole time. Nothing to do but watch it normalize.', pointPenalty: 5, }, mustMaintain: false, @@ -820,10 +818,10 @@ export const scenario14Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry correctly logs this event for the next shift?', options: [ - 'Moderate rain over VT-01, ~3 dB fade. Held TM-1 service per customer SLA preference; AGC max 3 dB, modem lock maintained throughout, no handover.', - 'Emergency handover to ME-02 due to rain.', - 'Lost lock on TM-1 during storm; service restored after storm cleared.', - 'No weather event; routine shift.', + 'Moderate rain over VT-01, ~3 dB fade. Held TM-1 per customer SLA preference; AGC max 3 dB, modem lock maintained, no handover.', + 'Moderate rain over VT-01, ~3 dB fade. Handed TM-1 to ME-02 at fade onset; AGC max 3 dB, customer notified, service restored.', + 'Moderate rain over VT-01, ~3 dB fade. Lost lock on TM-1 near peak; AGC at max, modem re-locked after the front cleared.', + 'Light rain over VT-01, no measurable fade. Held TM-1 as routine; AGC at floor, modem lock maintained, no customer contact.', ], correctIndex: 0, explanation: 'Log what happened, what you chose, why, and the evidence the choice was correct. Next operator picks up with the trigger thresholds you were watching.', diff --git a/src/campaigns/nats/scenario15.ts b/src/campaigns/nats/scenario15.ts index 3550e769..90156393 100644 --- a/src/campaigns/nats/scenario15.ts +++ b/src/campaigns/nats/scenario15.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; @@ -5,7 +6,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite, tidemark3Satellite } from './satellites'; @@ -70,14 +70,8 @@ export const scenario15Data: ScenarioData = { duration: '25-30 min', difficulty: 'intermediate', missionType: 'Spectrum Coordination', - description: `A coordination notice from RedSky Teleport hit your inbox at 09:08. Their next scheduled uplink sits just 2 MHz from the edge of the SeaLink carrier currently riding TIDEMARK-3 TP-1. They want acknowledgment that our spectrum won't bleed into their slot when they light up in 25 minutes.

Standard inter-operator etiquette: verify the guard band, check that our transmit chain isn't producing IMD products that would land in the neighbor's passband, document, and confirm back. If our skirts are dirty, clean them up before they go on the air.

This isn't combat. It's good neighbor work.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End (TX active)', - 'Spectrum Analyzer', - 'TX Modem (carrying SeaLink traffic on TIDEMARK-3)', - 'HPA (online)', - ], + description: `A coordination notice from RedSky Teleport hit your inbox at 09:08. Their next scheduled uplink sits just 2 MHz from the edge of the SeaLink carrier currently riding TIDEMARK-3 TP-1. They want acknowledgment that our spectrum won't bleed into their slot when they light up in 25 minutes.

Standard inter-operator etiquette: verify the guard band, check that our transmit chain isn't producing IMD products that would fall in the neighbor's passband, document, and confirm back. If our skirts are dirty, clean them up before they go on the air.

This is good neighbor work, not combat.`, + equipment: ['9-meter C-band Antenna', 'RF Front End (TX active)', 'Spectrum Analyzer', 'TX Modem (carrying SeaLink traffic on TIDEMARK-3)', 'HPA (online)'], timeLimitSeconds: 30 * 60, settings: { isSync: true, @@ -162,12 +156,7 @@ export const scenario15Data: ScenarioData = { ], }, ], - satellites: [ - tidemark3Satellite, - tidemark1Satellite, - tidemark2Satellite, - ses10Satellite, - ], + satellites: [tidemark3Satellite, tidemark1Satellite, tidemark2Satellite, ses10Satellite], missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-15?content-only=true&dark=true', isExtraSatellitesVisible: true, }, @@ -246,13 +235,8 @@ export const scenario15Data: ScenarioData = { description: 'Partner Band', params: { character: Character.SYSTEM, - question: 'RedSky\'s notice: center 5961 MHz, bandwidth 8 MHz, V-pol on the TIDEMARK-3 cross-pol slot. What RF range will they occupy?', - options: [ - '5957 - 5965 MHz', - '5961 - 5969 MHz', - '5953 - 5969 MHz', - '5957 - 5961 MHz', - ], + question: "RedSky's notice: center 5961 MHz, bandwidth 8 MHz, V-pol on the TIDEMARK-3 cross-pol slot. What RF range will they occupy?", + options: ['5957 - 5965 MHz', '5961 - 5969 MHz', '5953 - 5969 MHz', '5957 - 5961 MHz'], correctIndex: 0, explanation: 'Center plus or minus half-bandwidth: 5961 plus or minus 4 MHz equals 5957 to 5965 MHz. Their upper edge is 5965.', pointPenalty: 5, @@ -279,12 +263,7 @@ export const scenario15Data: ScenarioData = { params: { character: Character.SYSTEM, question: 'Our SeaLink carrier on TIDEMARK-3 TP-1 is center 5985 MHz, 36 MHz BW, H-pol. What RF range are we occupying?', - options: [ - '5967 - 6003 MHz', - '5949 - 6021 MHz', - '5985 - 6021 MHz', - '5967 - 5985 MHz', - ], + options: ['5967 - 6003 MHz', '5949 - 6021 MHz', '5985 - 6021 MHz', '5967 - 5985 MHz'], correctIndex: 0, explanation: '5985 plus or minus 18 MHz equals 5967 to 6003 MHz. Our lower edge is 5967.', pointPenalty: 5, @@ -299,7 +278,7 @@ export const scenario15Data: ScenarioData = { id: 'compute-guard-band', nice: ['K0737', 'S0648'], title: 'Compute Guard Band', - description: 'Determine the edge-to-edge guard band between RedSky\'s upper edge and our lower edge.', + description: "Determine the edge-to-edge guard band between RedSky's upper edge and our lower edge.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['identify-our-band'], timeLimitSeconds: 2 * 60, @@ -311,12 +290,7 @@ export const scenario15Data: ScenarioData = { params: { character: Character.SYSTEM, question: 'RedSky upper edge: 5965 MHz. Our lower edge: 5967 MHz. What is the edge-to-edge guard band?', - options: [ - '2 MHz', - '4 MHz', - '24 MHz', - '0 MHz - we overlap', - ], + options: ['2 MHz', '4 MHz', '24 MHz', '0 MHz - we overlap'], correctIndex: 0, explanation: '5967 minus 5965 equals 2 MHz. Cross-pol isolation buys us additional margin, but the frequency gap is tight.', pointPenalty: 5, @@ -344,10 +318,10 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'A 2 MHz edge-to-edge guard with about 28 dB cross-pol isolation. What is the right next step?', options: [ - 'Verify our TX chain is producing clean spectrum - no spurs or IMD landing in RedSky\'s band', - 'Demand RedSky retune their carrier', - 'Increase our TX power so RedSky\'s carrier is irrelevant to us', - 'Take no action - 2 MHz guard plus cross-pol isolation is always sufficient', + "Verify our TX chain is producing clean spectrum - no spurs or IMD falling into RedSky's band", + 'Ask RedSky to retune their carrier - 2 MHz is below the minimum guard for a 36 MHz carrier', + "Raise our TX power now - a stronger SeaLink carrier holds its margin against RedSky's V-pol", + 'Confirm clear as-is - 2 MHz guard plus 28 dB cross-pol isolation is sufficient at any drive', ], correctIndex: 0, explanation: 'Guard-band math is necessary but not sufficient. Polarization isolation only buys margin if our own spectrum is clean. Inspect the TX chain.', @@ -428,9 +402,9 @@ export const scenario15Data: ScenarioData = { question: 'BUC LO 7000 MHz, RF target 5985 MHz. Does the displayed TX IF match the expected value?', options: [ 'Yes - 1015 MHz IF matches 7000 minus 5985, carrier on the right slot', - 'No - TX IF should be at 5985 MHz', - 'No - TX IF should be at 1057 MHz', - 'Cannot determine without checking the receiver', + 'No - TX IF should read 5985 MHz, the BUC LO is not subtracted on the TX side', + 'No - 1057 MHz IF is what 7000 minus 5943 gives, carrier is on the wrong slot', + 'Cannot tell - the RX IF at 1490 MHz has to be checked before the TX slot is known', ], correctIndex: 0, explanation: 'TX IF equals BUC LO minus uplink RF: 7000 minus 5985 equals 1015 MHz. The TX modem is on the right slot.', @@ -466,13 +440,14 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'The HPA is currently at 5 dB backoff. What does that imply for the transmit spectrum?', options: [ - 'Operating close to saturation - third-order IMD products will be elevated and extend several MHz beyond the carrier edges', - 'Operating with comfortable margin - IMD will be well below the noise floor', - 'Operating in pure linear region - no IMD generated', - 'Backoff has no bearing on adjacent-channel emissions', + 'Operating close to saturation - third-order IMD will be elevated and spill several MHz past the carrier edges', + 'Operating with comfortable margin - 5 dB keeps third-order IMD below the noise floor at the carrier edges', + 'Operating in the linear region - a modulated carrier at 5 dB backoff generates no third-order IMD', + 'Operating at the design point - backoff sets output power only and has no bearing on adjacent emissions', ], correctIndex: 0, - explanation: '5 dB is a marginal backoff for a modulated carrier. Third-order IMD scales aggressively as the HPA approaches compression - spectral skirts widen and adjacent-channel power rises.', + explanation: + '5 dB is a marginal backoff for a modulated carrier. Third-order IMD scales aggressively as the HPA approaches compression - spectral skirts widen and adjacent-channel power rises.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -499,13 +474,14 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'Why does a wideband modulated carrier driven near HPA saturation produce energy outside the nominal channel?', options: [ - 'Nonlinearity in the amplifier mixes spectral components, generating intermodulation products that fall just outside the carrier edges', - 'The wideband carrier physically expands due to thermal effects in the waveguide', - 'The BUC LO drifts when the HPA heats up under heavy drive', - 'The antenna feed re-radiates energy into adjacent passbands', + 'Amplifier nonlinearity mixes spectral components, generating intermodulation products just outside the carrier edges', + 'Waveguide heating under heavy drive widens the carrier, spreading modulation energy just outside the carrier edges', + 'BUC LO drift under heavy HPA drive smears the carrier, pushing modulation energy just outside the carrier edges', + 'Feed horn re-radiation under high drive couples energy into adjacent passbands just outside the carrier edges', ], correctIndex: 0, - explanation: 'Near saturation, the HPA stops behaving linearly. Spectral components mix and produce IMD - the dominant third-order products fall just outside the carrier edges and drop with frequency offset.', + explanation: + 'Near saturation, the HPA stops behaving linearly. Spectral components mix and produce IMD - the dominant third-order products fall just outside the carrier edges and drop with frequency offset.', pointPenalty: 5, }, mustMaintain: false, @@ -535,13 +511,14 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'SeaLink traffic must continue. Which mitigation is the right tool?', options: [ - 'Increase HPA backoff to 10 dB - reduces IMD without dropping the carrier and without requiring customer coordination', - 'Cut TX modem power to zero - simplest fix', - 'Retune our TIDEMARK-3 carrier to avoid RedSky entirely - requires SeaLink SLA work', - 'Engage a notch filter on the RX side at 5961 MHz - protects our receiver', + 'Increase HPA backoff to 10 dB - suppresses IMD without dropping the carrier or coordinating with the customer', + 'Cut TX modem power to zero - clears the IMD instantly and the carrier returns when RedSky is up', + 'Retune our TIDEMARK-3 carrier away from RedSky - widens the guard band past any IMD skirt we produce', + 'Engage a notch filter on the RX side at 5961 MHz - keeps their carrier out of our demodulator', ], correctIndex: 0, - explanation: 'Backoff is the correct lever: it linearizes the amplifier, suppresses IMD, and is reversible. Cutting power kills the customer. Retuning needs SLA renegotiation. An RX notch does nothing for the neighbor.', + explanation: + 'Backoff is the correct lever: it linearizes the amplifier, suppresses IMD, and is reversible. Cutting power kills the customer. Retuning needs SLA renegotiation. An RX notch does nothing for the neighbor.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -652,7 +629,8 @@ export const scenario15Data: ScenarioData = { id: 'tune-speca-to-downlink', nice: ['K0737', 'S0421'], title: 'Tune Spectrum to TIDEMARK-3 Downlink', - description: 'Reconfigure the spectrum analyzer to view the TIDEMARK-3 downlink IF region (1490 MHz with VT-01 LNB LO at 5250 and downlink RF 3760). Use a wide span so RedSky\'s adjacent slot is visible too.', + description: + "Reconfigure the spectrum analyzer to view the TIDEMARK-3 downlink IF region (1490 MHz with VT-01 LNB LO at 5250 and downlink RF 3760). Use a wide span so RedSky's adjacent slot is visible too.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['open-rx-analysis'], timeLimitSeconds: 3 * 60, @@ -728,10 +706,10 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'With HPA backoff now at 10 dB, what should be true about the TIDEMARK-3 downlink at 1490 MHz IF?', options: [ - 'Wideband carrier still present at slightly reduced power - customer link healthy, IMD skirts dropped well below the adjacent slot noise floor', - 'Carrier gone - the backoff change effectively muted the BUC', - 'Carrier doubled in width - backoff caused bandwidth expansion', - 'Beacon disappeared - HPA backoff affects beacon visibility', + 'Wideband carrier still present at slightly lower power - link healthy and IMD skirts below the adjacent noise floor', + 'Wideband carrier gone from 1490 MHz - the backoff change dropped the drive below what the BUC passes through', + 'Wideband carrier now twice as wide - the backoff change spread the modulation energy across the whole 36 MHz transponder', + 'Beacon gone from the span - the HPA backoff change affects the beacon before it affects the carrier', ], correctIndex: 0, explanation: 'Backing off the HPA reduces output power a few dB and dramatically suppresses IMD. Customer link remains operational with cleaner spectral skirts.', @@ -772,7 +750,7 @@ export const scenario15Data: ScenarioData = { id: 'confirm-spectrum-clean-for-partner', nice: ['S0648', 'K0792'], title: 'Draft Confirmation to RedSky', - description: 'Decide what to send back to RedSky\'s coordinator.', + description: "Decide what to send back to RedSky's coordinator.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['verify-receiver-locked'], timeLimitSeconds: 2 * 60, @@ -785,13 +763,14 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'After raising HPA backoff to 10 dB, what is the correct status to send back to RedSky?', options: [ - 'Confirmed clear. TIDEMARK-3 TP-1 carrier holds 5967-6003 MHz H-pol with adjacent-channel emissions well below your planned slot. Proceed with your 5961 MHz V-pol uplink as scheduled.', - 'Confirmed clear. We have muted our carrier to give you the spectrum.', - 'Cannot confirm. Recommend RedSky delay their uplink until further notice.', - 'Confirmed clear, but we will be retuning to 5950 MHz to give wider margin.', + 'Confirmed clear. TP-1 carrier 5967-6003 MHz H-pol, adjacent-channel emissions below your slot. Proceed 5961 MHz V-pol as scheduled.', + 'Confirmed clear. TP-1 carrier 5967-6003 MHz H-pol muted for your window, spectrum released. Proceed 5961 MHz V-pol as scheduled.', + 'Cannot confirm. TP-1 carrier 5967-6003 MHz H-pol, adjacent-channel emissions still under review. Recommend delaying your 5961 MHz uplink.', + 'Confirmed clear. TP-1 carrier retuning to 5950 MHz H-pol for wider margin, adjacent emissions unchanged. Proceed 5961 MHz V-pol as scheduled.', ], correctIndex: 0, - explanation: 'Factual coordination response: state your occupied band, confirm adjacent-channel emissions are below the neighbor\'s threshold, and clear them to proceed.', + explanation: + "Factual coordination response: state your occupied band, confirm adjacent-channel emissions are below the neighbor's threshold, and clear them to proceed.", pointPenalty: 5, preserveOptionOrder: true, }, @@ -822,13 +801,13 @@ export const scenario15Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry correctly records this event for the next shift?', options: [ - '0937 - RedSky coordination notice received and confirmed. TIDEMARK-3 TP-1 HPA backoff raised from 5 to 10 dB to suppress adjacent-channel IMD. Cleared RedSky for 5961 MHz V-pol uplink. SeaLink carrier remains nominal.', - '0937 - RedSky coordination request denied. Recommended they relocate to a different orbital slot.', - '0937 - No action taken. RedSky\'s uplink does not affect us.', - '0937 - TIDEMARK-3 SeaLink customer traffic dropped to accommodate RedSky.', + '0937 - RedSky notice confirmed. TP-1 HPA backoff raised 5 to 10 dB to suppress adjacent IMD. RedSky cleared for 5961 MHz V-pol, SeaLink nominal.', + '0937 - RedSky notice denied. TP-1 HPA backoff held at 5 dB, guard band judged too tight. RedSky advised to relocate slot, SeaLink nominal.', + '0937 - RedSky notice confirmed. TP-1 HPA backoff held at 5 dB, cross-pol isolation judged sufficient. RedSky cleared for 5961 MHz V-pol, SeaLink nominal.', + '0937 - RedSky notice confirmed. TP-1 carrier muted for the RedSky window to clear adjacent IMD. RedSky cleared for 5961 MHz V-pol, SeaLink down.', ], correctIndex: 0, - explanation: 'The log captures what we did, why, and what we cleared. Next shift picks up with full context if RedSky\'s uplink behaves unexpectedly.', + explanation: "The log captures what we did, why, and what we cleared. Next shift picks up with full context if RedSky's uplink behaves unexpectedly.", pointPenalty: 5, preserveOptionOrder: true, }, diff --git a/src/campaigns/nats/scenario16.ts b/src/campaigns/nats/scenario16.ts index 134371b3..e0d0c0f3 100644 --- a/src/campaigns/nats/scenario16.ts +++ b/src/campaigns/nats/scenario16.ts @@ -1,9 +1,9 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { maineGroundStation, vermontGroundStation } from './ground-stations'; import { tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -69,13 +69,7 @@ export const scenario16Data: ScenarioData = { difficulty: 'advanced', missionType: 'Incident Response', description: `Two minutes into your shift and the alarm board is lit up. BUC over-temperature, LNB reference unlocked, HPA overdriven - all on VT-01, all at once. The TIDEMARK-1 customer link is degraded and James Okafor from SeaLink is on the line.

None of these faults are related. You have to triage them in the right order: protect the equipment and the spectrum first, restore customer impact next, then methodically clear each fault.

Dana is in the admin office. She trusts you with this one.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Operational (TIDEMARK-2)', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Operational (TIDEMARK-2)'], timeLimitSeconds: 45 * 60, // 45 minutes settings: { isSync: true, @@ -156,9 +150,9 @@ export const scenario16Data: ScenarioData = { question: 'Three concurrent alarms on VT-01 and SeaLink is on the line. How will you proceed?', options: [ 'Triage all alarms first, then act in priority order: RF safety, customer impact, equipment health', - 'Fix whichever fault is easiest to clear first to reduce the alarm count', - 'Wake Dana and wait for direction before touching anything', - 'Power cycle the entire RF chain to reset everything at once', + 'Clear the alarms in order of effort: easiest fault first, alarm count down, then the hard ones', + 'Report to Dana first, then act on her direction: no equipment changes until she calls the order', + 'Power cycle the whole RF chain first, then triage whatever is left: BUC, LNB, HPA all reset at once', ], correctIndex: 0, explanation: 'Triage before action. Knowing all three faults lets you pick the order that protects equipment and the spectrum first.', @@ -219,12 +213,13 @@ export const scenario16Data: ScenarioData = { question: 'Which alarms are active on VT-01 right now?', options: [ 'BUC over-temperature and high current, LNB reference unlocked, HPA overdriven', - 'BUC reference unlock and LNB high current draw only', - 'HPA over-temperature and antenna tracking lost', - 'GPSDO holdover and modem fault on TX Modem 1', + 'BUC reference unlocked and high current, LNB over-temperature, HPA output disabled', + 'HPA over-temperature and high current, antenna tracking lost, BUC reference unlocked', + 'GPSDO in holdover and LNB reference unlocked, TX Modem 1 fault, HPA overdriven', ], correctIndex: 0, - explanation: 'Three concurrent, unrelated alarms. The BUC and HPA conditions threaten the equipment and the uplink spectrum; the LNB condition is degrading the customer receive.', + explanation: + 'Three concurrent, unrelated alarms. The BUC and HPA conditions threaten the equipment and the uplink spectrum; the LNB condition is degrading the customer receive.', pointPenalty: 10, }, mustMaintain: false, @@ -251,12 +246,13 @@ export const scenario16Data: ScenarioData = { question: 'In what order should the three faults be addressed?', options: [ 'RF safety first (HPA overdrive + BUC) → customer impact next (LNB / RX) → final verification', - 'LNB first because the customer is calling', - 'BUC first because the over-temperature could be permanent damage', - 'Whichever one is easiest to clear to reduce the alarm count', + 'Customer impact first (LNB / RX) → RF safety next (HPA overdrive + BUC) → final verification', + 'Equipment damage first (BUC over-temp) → RF safety next (HPA overdrive) → customer impact (LNB / RX)', + 'Easiest clear first (LNB power cycle) → BUC cooldown next → HPA back-off last (longest to settle)', ], correctIndex: 0, - explanation: 'An overdriven HPA is putting a dirty signal on the spectrum and risking the amplifier. Disable the HPA output, mute the BUC for cooldown, fix the backoff, then turn to the LNB to restore the customer downlink.', + explanation: + 'An overdriven HPA is putting a dirty signal on the spectrum and risking the amplifier. Disable the HPA output, mute the BUC for cooldown, fix the backoff, then turn to the LNB to restore the customer downlink.', pointPenalty: 10, }, mustMaintain: false, @@ -294,7 +290,8 @@ export const scenario16Data: ScenarioData = { id: 'disable-hpa-for-safety', nice: ['S0593', 'S0677'], title: 'Disable the HPA Output', - description: 'Disable the HPA output to take the overdriven signal off the antenna. Amplifier comes down before its drive does - same sequencing rule as a planned power-down.', + description: + 'Disable the HPA output to take the overdriven signal off the antenna. Amplifier comes down before its drive does - same sequencing rule as a planned power-down.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['navigate-tx-chain'], timeLimitSeconds: 2 * 60, @@ -319,13 +316,14 @@ export const scenario16Data: ScenarioData = { character: Character.SYSTEM, question: 'Why is disabling the HPA output the correct first action - before muting the BUC?', options: [ - 'It takes the dirty uplink off the air immediately, and an enabled HPA must never be left without BUC drive - it would amplify raw noise into the feed', - 'The HPA controls the BUC - disabling it automatically mutes the BUC too', - 'Disabling the HPA clears the LNB reference fault as a side effect', - 'Order does not matter as long as both end up off', + 'It takes the dirty uplink off the air, and an enabled HPA left without BUC drive would amplify raw noise into the feed', + 'It takes the dirty uplink off the air, and the HPA controls the BUC - disabling it mutes the BUC at the same time', + 'It takes the dirty uplink off the air, and the HPA shares the 10 MHz reference - disabling it lets the LNB relock', + 'It takes the dirty uplink off the air, but the order does not matter - both end up off before the BUC cools either way', ], correctIndex: 0, - explanation: 'Same rule as every planned power-down: amplifier off before drive off. Killing the HPA stops the overdriven signal AND keeps the chain safe for the BUC mute that comes next. Mute the BUC first and the enabled HPA amplifies noise into the feed.', + explanation: + 'Same rule as every planned power-down: amplifier off before drive off. Killing the HPA stops the overdriven signal AND keeps the chain safe for the BUC mute that comes next. Mute the BUC first and the enabled HPA amplifies noise into the feed.', pointPenalty: 10, }, mustMaintain: false, @@ -378,12 +376,13 @@ export const scenario16Data: ScenarioData = { question: 'The HPA back-off has drifted to 2 dB. What does that mean for the output?', options: [ 'Output is too close to saturation - IMD products are rising and the amplifier is at risk', - 'Output is too low - the amplifier is starving and will trip offline', - 'Output is correct - 2 dB is standard back-off for full-power uplinks', - 'Output is muted automatically by the HPA controller', + 'Output is too low - the amplifier is starving for drive and will trip offline within minutes', + 'Output is correct - 2 dB is the standard back-off for a full-power uplink on a 36 MHz carrier', + 'Output is muted - the HPA controller trips the output automatically below 3 dB back-off', ], correctIndex: 0, - explanation: 'Below 3 dB back-off, the HPA is overdriven. IMD products spill into adjacent transponders and the amplifier wears prematurely. Restore back-off to the standard 10 dB margin.', + explanation: + 'Below 3 dB back-off, the HPA is overdriven. IMD products spill into adjacent transponders and the amplifier wears prematurely. Restore back-off to the standard 10 dB margin.', pointPenalty: 10, }, mustMaintain: false, @@ -512,9 +511,9 @@ export const scenario16Data: ScenarioData = { question: 'The GPSDO is locked and the LNB is powered, but the LNB shows reference unlocked. What is the right corrective action?', options: [ 'Power cycle the LNB to clear the sticky reference lock fault', - 'Replace the LNB - the reference input has failed', - 'Increase LNB gain to compensate for the unlock', - 'Switch the LNB to internal reference mode', + 'Replace the LNB - the reference input has failed and will not relock', + 'Increase LNB gain to compensate for the unlocked reference', + 'Switch the LNB to internal reference to bypass the fault', ], correctIndex: 0, explanation: 'A sticky reference lock fault clears on power cycle. The 10 MHz reference is available upstream (GPSDO locked); the LNB just needs to re-acquire.', @@ -690,9 +689,9 @@ export const scenario16Data: ScenarioData = { question: 'With back-off at 10 dB and the BUC cool, what does the uplink look like to neighboring transponders now?', options: [ 'Clean - IMD products are back below coordination limits and we are no longer interfering', - 'Still overdriven - back-off does not affect IMD on the spectrum', - 'Inverted polarization - the mute/unmute cycle flipped the OMT', - 'Reduced power - the customer link cannot meet SLA at 10 dB back-off', + 'Still overdriven - back-off changes output power but does not pull IMD back below the limits', + 'Inverted polarization - the mute/unmute cycle flipped the OMT and we are now interfering cross-pol', + 'Reduced power - IMD is down but the customer link cannot meet SLA power at 10 dB back-off', ], correctIndex: 0, explanation: 'Standard 10 dB back-off keeps IMD products well below the coordination floor while still meeting SLA power.', @@ -755,9 +754,9 @@ export const scenario16Data: ScenarioData = { question: 'Summarize the post-recovery state of VT-01.', options: [ 'All three faults cleared - BUC thermal normal, LNB locked, HPA within back-off - link operational', - 'BUC cleared, LNB still degraded - acceptable to return to service', - 'HPA cleared, BUC and LNB still degraded - mark as partial recovery', - 'All faults still active - escalate immediately', + 'Two faults cleared - BUC thermal normal, HPA within back-off, LNB still degraded - link returned to service', + 'One fault cleared - HPA within back-off, BUC still warm, LNB still unlocked - link marked partial recovery', + 'No faults cleared - BUC still hot, LNB still unlocked, HPA still overdriven - link down, escalate to Dana', ], correctIndex: 0, explanation: 'Clean board. All three independent faults addressed in priority order. Ready to brief the customer.', @@ -786,10 +785,10 @@ export const scenario16Data: ScenarioData = { character: Character.SYSTEM, question: 'James Okafor is still on the line. Which message is the right one?', options: [ - 'Three concurrent faults identified and cleared in priority order. Link is operational. Will follow up with a written impact report within the hour.', - 'Everything looks fine on our end. Probably a problem on your side.', - 'We had a major outage and the link is still degraded. Stand by.', - 'Equipment failure - we are escalating to vendor support and have no ETA.', + 'Three concurrent faults identified and cleared in priority order. Link is operational. Written impact report to follow within the hour.', + 'No faults found on our end after a full board check. Link is operational. Suggest you check the vessel terminals on your side.', + 'Three faults hit VT-01 at once and the link is still degraded. Recovery is under way. Stand by for an update within the hour.', + 'Equipment failure on VT-01 across three subsystems. Link is down. Escalating to vendor support, no restoration ETA at this time.', ], correctIndex: 0, explanation: 'Acknowledge the impact, state what was done, commit to written follow-up. Customer comms during incidents is half technical, half credibility.', @@ -818,13 +817,14 @@ export const scenario16Data: ScenarioData = { character: Character.SYSTEM, question: 'Which log entry correctly captures this incident?', options: [ - 'Concurrent unrelated faults VT-01: BUC over-temp (72°C, high I), LNB ref unlock (sticky), HPA back-off drift (2 dB). Recovery in order: HPA output disabled, BUC muted for cooldown, back-off restored to 10 dB, LNB power cycled, BUC unmuted, HPA restored. Customer (SeaLink) notified.', - 'VT-01 alarms cleared. Routine maintenance complete.', - 'Single fault: HPA failure. Replaced amplifier.', - 'Customer reported issue - no fault found at ground station.', + 'VT-01 concurrent unrelated faults: BUC over-temp (72°C), LNB ref unlock (sticky), HPA back-off 2 dB. Cleared in priority order: HPA, BUC, LNB. SeaLink notified.', + 'VT-01 routine maintenance: BUC thermal check (72°C peak), LNB reference reseated, HPA back-off reset to 10 dB. All alarms cleared. SeaLink not notified.', + 'VT-01 single fault: HPA failure (back-off drift to 2 dB) cascaded into BUC over-temp and LNB unlock. HPA replaced, link restored. SeaLink notified.', + 'VT-01 customer report: SeaLink intermittent drops on TM-1. Board checked, BUC, LNB and HPA all within limits. No fault found. SeaLink notified.', ], correctIndex: 0, - explanation: 'Concurrent unrelated faults must be logged separately with the order and rationale of recovery. The next operator inherits the trend data and the customer follow-up.', + explanation: + 'Concurrent unrelated faults must be logged separately with the order and rationale of recovery: HPA output disabled, BUC muted for cooldown, back-off restored to 10 dB, LNB power cycled, BUC unmuted, HPA restored. The next operator inherits the trend data and the customer follow-up.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario17.ts b/src/campaigns/nats/scenario17.ts index 5e11463e..640a0562 100644 --- a/src/campaigns/nats/scenario17.ts +++ b/src/campaigns/nats/scenario17.ts @@ -40,8 +40,9 @@ import { ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satell * spacecraft-side confirmation. All quizzes SYSTEM. 4 clips. * * Sim notes: - * - weatherEvents 'sun-transit': starts T+300s, 300s duration, 12 dB peak. - * Profile sin^2 -> degradation >6 dB roughly T+375..525s, clear by ~T+585s. + * - weatherEvents 'sun-transit': starts 20 s after 'observe-onset' activates + * (startAfterObjectiveId), 300s duration, 12 dB peak. Profile sin^2 -> + * >2 dB from ~A+60s, >6 dB ~A+95..245s, clear (<1 dB) by ~A+292s. * - 12 dB peak takes beacon and carrier C/N below threshold: demod genuinely * unlocks near peak and self-recovers. Uplink is unaffected by design. * - Custom-evaluator conditions read skyNoiseDegradation_dB via the @@ -60,7 +61,7 @@ const vt01SkyNoise = (): number => { }; }; }; - const gs = w.signalRange?.simulationManager?.groundStations?.find(g => g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); return gs?.antennas?.[0]?.state?.skyNoiseDegradation_dB ?? 0; }; @@ -76,13 +77,7 @@ export const scenario17Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Environmental Operations', description: `The semiannual sun transit window arrives this morning: for about five minutes the Sun passes directly behind TIDEMARK-1 as seen from Vermont, and the antenna stares into a 20,000-kelvin noise source at full gain. The noise floor climbs, the margin collapses, and near the peak the demodulator will lose the carrier.

Nothing is broken. Nothing can be fixed. The event was predicted to the minute by the ephemeris service, the SLA excuses it with advance notice, and the geometry resolves itself.

Your job is the discipline: notify the customer before the window, hold the configuration through it, verify the recovery, and document what actually happened. The only way to fail a sun transit is to fight one.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'Sun Transit Prediction Sheet', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'Sun Transit Prediction Sheet'], timeLimitSeconds: 30 * 60, settings: { isSync: true, @@ -98,7 +93,11 @@ export const scenario17Data: ScenarioData = { groundStationId: 'VT-01', type: 'sun-transit', severity: 'severe', - startTime: 300, // Window opens 5 minutes into the shift + // Anchored to the onset objective: a mission-start schedule ran the + // whole transit while slower players were still on the quizzes, and + // 'Sky Noise Rising' could never tick + startAfterObjectiveId: 'observe-onset', + startTime: 20, // Window opens 20 s after the onset objective comes up duration: 300, // ~5-minute transit linkMarginDegradation: 12, // Peak dB - enough to break demod lock near center }, @@ -130,7 +129,7 @@ export const scenario17Data: ScenarioData = { description: 'Acknowledge the Window', params: { character: Character.SYSTEM, - question: 'Transit window opens five minutes into the shift. Ready?', + question: 'The transit window opens this morning - the pre-event checklist has to be done before it does. Ready?', options: ['Acknowledged - prediction sheet reviewed, pre-event checklist starting now.'], correctIndex: 0, explanation: 'The window does not move for anyone. Everything before it is preparation; everything during it is discipline.', @@ -190,13 +189,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'Why does the pre-transit baseline matter more than usual today?', options: [ - 'Anything abnormal AFTER the window starts will be attributed to the Sun - a fault hiding under the transit would survive unnoticed unless the board was provably clean before', - 'The baseline calibrates the prediction sheet', - 'The SLA requires a baseline screenshot', - 'It does not - the transit makes the baseline meaningless', + 'Anything abnormal after window-open gets blamed on the Sun - a hidden fault only shows if the board was provably clean before', + 'The baseline calibrates the prediction sheet - the peak time only holds if the C/N reading matches the sheet before onset', + 'The SLA exclusion requires a baseline record - a clean board timestamped before window-open is the contractual proof', + 'It matters less than usual - the transit resets the noise floor, so the pre-window baseline stops applying at onset', ], correctIndex: 0, - explanation: 'Predictable events make perfect camouflage. Clean board before the window means everything during it is the Sun - and anything still wrong after it is not.', + explanation: + 'Predictable events make perfect camouflage. Clean board before the window means everything during it is the Sun - and anything still wrong after it is not.', pointPenalty: 5, }, mustMaintain: false, @@ -222,13 +222,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'What actually degrades the link during a sun transit?', options: [ - 'The Sun (a ~20,000 K noise source at C-band) passes through the antenna main beam behind the satellite - system noise temperature soars and C/N collapses, with the signal itself unchanged', - 'Solar radiation pressure pushes the satellite off station', - 'The Sun physically blocks the radio path to the satellite', - 'Solar heating detunes the LNB local oscillator', + 'The Sun (~20,000 K at C-band) passes through the main beam behind the satellite - noise soars, C/N collapses, signal unchanged', + 'Solar radiation pressure (peaking near equinox) pushes the satellite off station - the beam misses it and C/N collapses', + 'The Sun (behind the satellite) physically blocks the radio path - the carrier is shadowed, noise unchanged, C/N collapses', + 'Solar heating of the feed (~5 minutes at full sun) detunes the LNB local oscillator - the carrier walks off the demod', ], correctIndex: 0, - explanation: 'The carrier power never changes - the noise under it rises. That is why nothing on the ground or the spacecraft can fix it: the antenna is pointed at the satellite, and the Sun is standing directly behind it.', + explanation: + 'The carrier power never changes - the noise under it rises. That is why nothing on the ground or the spacecraft can fix it: the antenna is pointed at the satellite, and the Sun is standing directly behind it.', pointPenalty: 5, }, mustMaintain: false, @@ -254,13 +255,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'When do sun transits occur for a GEO satellite, and for how long?', options: [ - 'Twice a year near the equinoxes - a few minutes a day for several consecutive days, at a time computable years in advance from the station/satellite geometry', - 'Randomly, whenever solar activity peaks', - 'Once a year at the summer solstice, for several hours', - 'Only during solar eclipses', + 'Twice a year near the equinoxes - a few minutes a day for several days, at a time computable years ahead from the geometry', + 'Whenever solar activity peaks - a few minutes at a time during a flare, at a time only the space weather feed can call', + 'Once a year at the summer solstice - several hours around local noon, at a time computable from the station latitude', + 'Only during solar eclipses - a few minutes while the Moon shadows the satellite, at a time computable years ahead', ], correctIndex: 0, - explanation: 'Pure geometry: the Sun crosses the geostationary arc as seen from your latitude around each equinox. Every teleport publishes its transit calendar - which is why a late customer notification is an operator failure, never a surprise.', + explanation: + 'Pure geometry: the Sun crosses the geostationary arc as seen from your latitude around each equinox. Every teleport publishes its transit calendar - which is why a late customer notification is an operator failure, never a surprise.', pointPenalty: 5, }, mustMaintain: false, @@ -330,13 +332,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'A handover to ME-02 would dodge the transit. Why is riding through still the right call?', options: [ - 'The outage is brief, predicted, and SLA-excluded with notice - a handover trades that for two transfer events, and ME-02 inherits its own transit on its own schedule anyway', - 'ME-02 cannot receive TIDEMARK-1', - 'Handover during any weather event is prohibited', - 'It is not - handover is always the safer choice', + 'Brief, predicted, SLA-excluded with notice - a handover costs two transfer events, and ME-02 gets its own transit anyway', + 'ME-02 cannot see TIDEMARK-1 at a usable elevation - a handover would swap a five-minute dip for a link with no margin', + 'Handover during any weather event is prohibited by SOP - the transfer itself is what the SLA counts, not the fade', + 'It is not the right call - a handover dodges the dip entirely, and two clean transfer events cost less than a lock loss', ], correctIndex: 0, - explanation: 'Same lesson as the S14 rain fade, sharpened: escape has a price, and here the thing escaped costs less than the escape. Every station on the arc takes its transits; the constellation-level answer is notification discipline, not musical chairs.', + explanation: + 'Same lesson as the S14 rain fade, sharpened: escape has a price, and here the thing escaped costs less than the escape. Every station on the arc takes its transits; the constellation-level answer is notification discipline, not musical chairs.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -363,13 +366,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'Which notification goes to SeaLink ops right now?', options: [ - 'Predicted solar transit on TIDEMARK-1 from Vermont, window and peak times attached; expect degraded margin and a possible 1-3 minute carrier interruption near peak; service recovers without intervention; this message constitutes SLA advance notice.', - 'TIDEMARK-1 will have an outage today. Will advise.', - 'No notification - the SLA excludes solar events automatically.', - 'Notification after the event, with the measured impact attached.', + 'Predicted solar transit on TIDEMARK-1, window and peak times attached; possible 1-3 minute carrier loss near peak, self-recovering; this is SLA advance notice.', + 'TIDEMARK-1 will see an outage this morning, times to follow; carrier loss possible, duration unknown; we will advise when it clears.', + 'No notification before the window; the SLA excludes solar events automatically, so the transit record goes in the monthly report.', + 'Notification after the window closes, measured impact attached; actual lock-loss duration and peak degradation; this is the SLA record.', ], correctIndex: 0, - explanation: 'Specific, timed, actionable, and BEFORE the event. The SLA exclusion is conditional on advance notice - a notification timestamped after window-open is contractually worthless.', + explanation: + 'Specific, timed, actionable, and BEFORE the event: expect degraded margin, service recovers without intervention. The SLA exclusion is conditional on advance notice - a notification timestamped after window-open is contractually worthless.', pointPenalty: 10, }, mustMaintain: false, @@ -407,13 +411,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'The elevated sky-noise alarm is climbing on schedule. What does "on schedule" buy you?', options: [ - 'Confidence this is the predicted transit and not a coincidental fault - the alarm tracking the prediction sheet IS the diagnosis', - 'Nothing - every alarm requires the full fault-isolation procedure', - 'Permission to mute the transmit chain', - 'A reason to repoint and check the geometry', + 'Confidence this is the predicted transit, not a coincidental fault - the alarm tracking the sheet IS the diagnosis', + 'Nothing - every alarm gets the full fault-isolation procedure, and the sheet only tells you when to start it', + 'Permission to mute the transmit chain - the sheet says the satellite cannot hear us for the next five minutes', + 'A reason to repoint and check the geometry - the sheet gives the Sun position, so the dish can step off it', ], correctIndex: 0, - explanation: 'The prediction is the baseline. Onset within a minute of the sheet, profile shaped like the sheet - that is a healthy station experiencing astronomy. Deviation from the sheet is what would demand investigation.', + explanation: + 'The prediction is the baseline. Onset within a minute of the sheet, profile shaped like the sheet - that is a healthy station experiencing astronomy. Deviation from the sheet is what would demand investigation.', pointPenalty: 5, }, mustMaintain: false, @@ -482,13 +487,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'Near peak the receiver loses carrier lock. What is true about the link right now?', options: [ - 'The downlink is buried in solar noise at OUR antenna only - the satellite still hears our uplink perfectly, and the demod will relock on its own as the Sun moves off boresight', - 'The link is down in both directions until the transit ends', - 'The satellite transponder is saturated by solar energy', - 'The carrier is gone and must be re-acquired manually after the window', + 'The downlink is buried in solar noise at OUR antenna only - the uplink is fine, and the demod relocks on its own as the Sun moves off', + 'The link is down in both directions at OUR station only - the uplink is blinded too, and both relock when the Sun moves off', + 'The transponder is saturated by solar energy at the satellite - every station on TM-1 is down, and it relocks after the peak', + 'The carrier is gone from the demod - the uplink is fine, but the receiver must be re-acquired by hand after the window', ], correctIndex: 0, - explanation: 'Sun transit is a receive-side event at one station. The uplink never flinched - which is exactly why the transmit chain stays untouched. Anything you "fix" now becomes a real problem you created during a fake one.', + explanation: + 'Sun transit is a receive-side event at one station. The uplink never flinched - which is exactly why the transmit chain stays untouched. Anything you "fix" now becomes a real problem you created during a fake one.', pointPenalty: 5, }, mustMaintain: false, @@ -560,12 +566,13 @@ export const scenario17Data: ScenarioData = { question: 'Post-window board check: what are you specifically looking for?', options: [ 'Any alarm that survived the window - the transit excuses exactly five minutes of sky noise and nothing else', - 'Confirmation the sky-noise alarm is latched for the report', - 'Nothing - recovery was verified at the receiver already', - 'Elevated BUC temperature from the solar exposure', + 'Confirmation the sky-noise alarm is latched - the report needs the peak value held on the board for the record', + 'Nothing new - recovery was verified at the receiver already, and the board only repeats what RX Analysis showed', + 'Elevated BUC temperature from the solar exposure - five minutes of full sun on the feed heats the whole chain', ], correctIndex: 0, - explanation: 'The window is a clean five-minute box. Healthy before, healthy after, astronomy in between. An alarm that outlives the box was never the Sun - and now is when you catch it.', + explanation: + 'The window is a clean five-minute box. Healthy before, healthy after, astronomy in between. An alarm that outlives the box was never the Sun - and now is when you catch it.', pointPenalty: 5, }, mustMaintain: false, @@ -591,10 +598,10 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'What should Halifax have seen during our transit window?', options: [ - 'Nothing abnormal on the spacecraft - our uplink steady throughout, vehicle telemetry nominal; the event existed only at our antenna', - 'A matching outage on the spacecraft bus', - 'Loss of our uplink during the peak minutes', - 'Elevated transponder temperature', + 'Nothing abnormal on the spacecraft - our uplink steady, vehicle telemetry nominal; the event existed only at our antenna', + 'A matching outage on the spacecraft bus - transponder off, bus telemetry flagged for the peak minutes; the event existed at both ends', + 'Loss of our uplink during the peak minutes - carrier gone at the satellite, vehicle telemetry nominal; the event existed on both legs', + 'Elevated transponder temperature - the Sun behind the antenna heats the satellite; the event existed on the spacecraft', ], correctIndex: 0, explanation: 'If Halifax saw anything, it was not (only) a transit. Their nominal telemetry is the final cross-check that closes the event as pure geometry.', @@ -623,13 +630,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'Which set of facts belongs in the impact record?', options: [ - 'Predicted vs actual window times, peak degradation observed, carrier lock-loss duration, notification timestamp (pre-window), and customer impact statement', - 'Just "sun transit occurred as predicted"', - 'The full spectrum analyzer trace history for the day', - 'Only the lock-loss duration - the rest was predicted anyway', + 'Predicted vs actual window times, peak degradation, lock-loss duration, notification timestamp (pre-window), customer impact statement', + 'Event type (sun transit), predicted window times, confirmation it occurred as predicted, and a note that no action was taken', + 'Full spectrum analyzer trace history for the day, dashboard alarm log, receiver lock log, and the raw sky-noise curve', + 'Lock-loss duration only, with a pointer to the prediction sheet (pre-window) for the times, peak and customer statement', ], correctIndex: 0, - explanation: 'Predicted-vs-actual is what makes the next prediction trustworthy, the notification timestamp is what makes the SLA exclusion stick, and the impact statement is what the account team quotes. Three audiences, one log entry.', + explanation: + 'Predicted-vs-actual is what makes the next prediction trustworthy, the notification timestamp is what makes the SLA exclusion stick, and the impact statement is what the account team quotes. Three audiences, one log entry.', pointPenalty: 5, }, mustMaintain: false, @@ -655,13 +663,14 @@ export const scenario17Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry correctly records this event?', options: [ - 'Predicted solar transit TM-1/VT-01 executed per SOP-SX-001. Customer notified pre-window. Peak ~12 dB sky noise, brief demod loss near peak, self-recovered to baseline. No operator intervention, no residual alarms. Day 2 of 4 in this transit series - next window tomorrow, ~4 minutes earlier.', - 'TM-1 outage this morning. Resolved.', - 'Sun transit - no entry needed, event was excused.', - 'Emergency response to solar interference completed successfully.', + 'TM-1 transit per SOP-SX-001, customer notified pre-window. Peak ~12 dB, brief demod loss, self-recovered, no residual alarms. Next window tomorrow, ~4 min earlier.', + 'TM-1 outage this morning, cause solar. Customer notified after recovery. Peak ~12 dB, demod loss ~2 min, self-recovered, no residual alarms. Resolved, series closed.', + 'TM-1 transit per SOP-SX-001, event SLA-excused. No customer notification required. Peak ~12 dB, brief demod loss, self-recovered. No further entries this series.', + 'TM-1 solar interference, emergency response per SOP-SX-001. Customer notified pre-window. Uplink muted at peak, demod re-acquired by hand, no residual alarms.', ], correctIndex: 0, - explanation: 'Including tomorrow\'s window in today\'s entry is the mark of someone who understands the series. The next operator walks in pre-briefed.', + explanation: + "Including tomorrow's window in today's entry is the mark of someone who understands the series - this is day 2 of 4, and no operator intervention was needed. The next operator walks in pre-briefed.", pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario18.ts b/src/campaigns/nats/scenario18.ts index 61ab2c27..4a2a26a4 100644 --- a/src/campaigns/nats/scenario18.ts +++ b/src/campaigns/nats/scenario18.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Satellite, TransponderConfig } from '@app/equipment/satellite/satellite'; @@ -8,7 +9,6 @@ import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType, RfFrequency } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite } from './satellites'; @@ -136,13 +136,7 @@ export const scenario18Data: ScenarioData = { difficulty: 'advanced', missionType: 'Anomaly Response', description: `Halifax called at 0850: TIDEMARK-2's station-keeping thrusters are degraded and north-south burns are suspended while the vehicle team investigates. Residual inclination is building - from Maine's dish the bird now traces a growing daily figure-eight that the published ephemeris no longer predicts.

Program-track follows the ephemeris. The ephemeris is wrong. You can watch the C/N bleed in real time.

You have flown an inclined bird before - AURORA-7 taught you everything this needs. The difference is that this time the satellite is the patient, the spacecraft team is the surgeon, and the ground operator's whole job is to keep the monitors attached: hold the lock, feed Halifax your track data, and know exactly where the ground role ends.`, - equipment: [ - '9-meter C-band Antenna (ME-02)', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'Halifax vehicle status feed', - ], + equipment: ['9-meter C-band Antenna (ME-02)', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'Halifax vehicle status feed'], timeLimitSeconds: 35 * 60, settings: { isSync: true, @@ -258,7 +252,7 @@ export const scenario18Data: ScenarioData = { id: 'review-mission-brief', nice: ['K0645'], title: 'Review Anomaly Brief', - description: 'Open the brief and Halifax\'s 0850 vehicle status report.', + description: "Open the brief and Halifax's 0850 vehicle status report.", groundStation: 'ME-02', freezesScenarioTimer: true, prerequisiteObjectiveIds: [], @@ -277,9 +271,9 @@ export const scenario18Data: ScenarioData = { question: 'Vehicle anomaly on TIDEMARK-2, ground link degrading. What is YOUR job this shift?', options: [ 'Keep the link alive and feed Halifax ground observations - the vehicle is theirs, the lock is mine', - 'Diagnose the thruster fault from the ground telemetry', - 'Hand TIDEMARK-2 traffic to VT-01 preemptively', - 'Command the satellite back to its slot', + 'Diagnose the thruster fault from the ground telemetry - the beacon trace is the data, the diagnosis is mine', + 'Hand TIDEMARK-2 traffic to VT-01 before the drift grows - the vehicle is theirs, the customer is mine', + 'Work with Halifax to command the satellite back to its slot - the burn plan is theirs, the uplink is mine', ], correctIndex: 0, explanation: 'Role clarity is the first decision of a vehicle anomaly. Halifax flies the spacecraft; the ground station flies the antenna.', @@ -339,10 +333,10 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'The board shows degraded link margin but every equipment indicator is green. What does that combination tell you?', options: [ - 'The ground segment is healthy - the degradation is on the space side or in the geometry, which matches Halifax\'s report exactly', - 'A hidden equipment fault - green indicators cannot be trusted during an anomaly', - 'The dashboard is stale and needs a refresh', - 'Weather - check the precipitation sensor', + "The ground segment is healthy - the degradation is on the space side or in the geometry, matching Halifax's report", + 'A hidden equipment fault - green indicators lag a real degradation, so the ground segment needs a full sweep first', + 'The dashboard is stale - the link margin figure is minutes old, and a refresh will bring it back in line with the board', + 'Weather over Maine - rain fade degrades the link while every indicator stays green, so check the precipitation sensor', ], correctIndex: 0, explanation: 'Healthy equipment + sick link = look up, not down. The S16 cascade taught fault isolation on the ground; this is the same discipline pointed at the sky.', @@ -371,13 +365,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'With N-S station-keeping burns suspended, what happens to TIDEMARK-2 from our point of view?', options: [ - 'Residual inclination accumulates - the bird traces a daily figure-8 in az/el that grows over weeks, and the published ephemeris becomes progressively more wrong', - 'The satellite falls out of orbit within days', - 'The satellite drifts east along the GEO arc into another operator\'s slot immediately', - 'Nothing observable - station-keeping only matters for collision avoidance', + 'Residual inclination builds - the bird traces a daily figure-8 in az/el that grows over weeks, and the ephemeris gets steadily more wrong', + 'Orbital altitude decays - the bird drops below the GEO belt within days, and the ephemeris range figures get steadily more wrong', + 'Longitude drifts east - the bird slides along the GEO arc toward the SES-10 slot within days, and the ephemeris azimuth goes wrong first', + 'Nothing observable from Maine - N-S station-keeping only matters for collision avoidance, and the ephemeris stays valid for months', ], correctIndex: 0, - explanation: 'Exactly AURORA-7\'s life story, fast-forwarded. Inclination is the axis N-S burns fight; without them the figure-8 starts small and grows roughly 0.8-0.9° per year - except this drift is days old and already past the prediction.', + explanation: + "Exactly AURORA-7's life story, fast-forwarded. Inclination is the axis N-S burns fight; without them the figure-8 starts small and grows roughly 0.8-0.9° per year - except this drift is days old and already past the prediction.", pointPenalty: 5, }, mustMaintain: false, @@ -410,13 +405,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'Beacon C/N is several dB below its usual value and wandering. Program-track reports it is exactly on target. Reconcile that.', options: [ - 'Program-track IS on target - the ephemeris target. The satellite is somewhere else, and the gap between prediction and reality is being paid in pattern loss', - 'The beacon transmitter on the satellite is failing', - 'The ACU encoder is slipping and the dish is not where it reports', - 'Cross-pol interference from SES-10', + 'Program-track IS on the ephemeris target - the satellite is somewhere else, and the gap between prediction and reality is paid in pattern loss', + 'The beacon transmitter is failing on the satellite - pointing is fine, and the wander is the beacon output sagging with the vehicle power bus', + 'The ACU encoder is slipping - the dish is not where it reports, and the gap between commanded and actual pointing is paid in pattern loss', + 'Cross-pol interference from SES-10 - pointing is fine, and the wander is the SES-10 carrier beating against our beacon C/N', ], correctIndex: 0, - explanation: 'A 9-meter C-band dish has a half-degree-class beamwidth. A few tenths of a degree of ephemeris error puts the bird on the shoulder of the beam - present, degraded, and wandering with the figure-8.', + explanation: + 'A 9-meter C-band dish has a half-degree-class beamwidth. A few tenths of a degree of ephemeris error puts the bird on the shoulder of the beam - present, degraded, and wandering with the figure-8.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -443,13 +439,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'The figure-8 is ±1.3° and growing; the dish beamwidth is ~0.5°. What does that mean for program-track as a strategy?', options: [ - 'Already lost - the excursion is several beamwidths and only gets worse; any fix based on following the ephemeris fails until Halifax publishes a corrected one', - 'Fine for another month - the margins are comfortable', - 'Acceptable if we raise LNB gain to compensate', - 'Program-track will self-correct as it learns the drift', + 'Already lost - the excursion is several beamwidths and growing; any fix that follows the ephemeris fails until Halifax publishes a corrected one', + 'Fine for another month - the excursion is under a beamwidth at the extremes; the margin holds until Halifax publishes a corrected set', + 'Acceptable with more gain - a few dB of LNB gain covers the pattern loss; the strategy holds until Halifax publishes a corrected set', + 'Self-correcting - program-track refines its ephemeris from the beacon as the drift grows; it converges before Halifax publishes a fix', ], correctIndex: 0, - explanation: 'No gain knob recovers pointing loss. When reality has left the prediction by multiple beamwidths, the only thing worth tracking is the satellite itself - which is what the beacon is for.', + explanation: + 'No gain knob recovers pointing loss. When reality has left the prediction by multiple beamwidths, the only thing worth tracking is the satellite itself - which is what the beacon is for.', pointPenalty: 5, }, mustMaintain: false, @@ -560,13 +557,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'A colleague suggests nudging the dish manually every few minutes instead of trusting step-track. Why is that worse?', options: [ - 'A human chases where the bird WAS; the loop tracks where it IS - manual nudges add pointing error between corrections, fatigue guarantees a missed one, and a bad nudge can drop the beacon entirely', - 'Manual mode disables the LNB', - 'It is not worse - manual tracking is more precise than step-track', - 'Manual pointing voids the antenna warranty', + 'A human chases where the bird WAS; the loop tracks where it IS - nudges add error between corrections, and a bad nudge can drop the beacon', + 'Manual mode disables the beacon receiver; the ACU stops reporting C/N - nudges are made blind, and a bad nudge can drop the carrier', + 'It is not worse; a human reads the C/N faster than the loop steps - nudges every few minutes stay inside the beamwidth all day', + 'Manual pointing loads the drive motors; the ACU logs every nudge - a stalled axis is the risk, and a stall drops the beacon entirely', ], correctIndex: 0, - explanation: 'Step-track corrects continuously against the measured beacon. The day automation actually dies you will fly manual because you must (that day comes in this campaign) - but choosing manual while the loop works is choosing worse performance at higher risk.', + explanation: + 'Step-track corrects continuously against the measured beacon; a human nudging every few minutes accumulates pointing error between corrections, and fatigue guarantees a missed one. The day automation actually dies you will fly manual because you must (that day comes in this campaign) - but choosing manual while the loop works is choosing worse performance at higher risk.', pointPenalty: 5, }, mustMaintain: false, @@ -615,7 +613,7 @@ export const scenario18Data: ScenarioData = { id: 'ground-observations-quiz', nice: ['T1314', 'K1032'], title: 'Feed the Vehicle Team', - description: 'Choose what ground data actually helps Halifax\'s investigation.', + description: "Choose what ground data actually helps Halifax's investigation.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['sustained-hold'], timeLimitSeconds: 2 * 60, @@ -628,13 +626,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'Which ground-side dataset is most valuable to the spacecraft team right now?', options: [ - 'Timestamped step-track pointing history - the dish is physically following the satellite, so its az/el trace IS an independent measurement of the actual orbit', - 'The receiver\'s frame error counters', - 'BUC and HPA temperatures', - 'The spectrum analyzer screenshot archive', + 'Timestamped step-track pointing history - the dish is physically following the satellite, so its az/el trace is a measurement of the actual orbit', + 'Timestamped receiver frame error counters - the demod is riding the carrier, so its error rate trace tracks the vehicle bus health', + 'Timestamped BUC and HPA temperatures - the uplink chain is loading against the drift, so its thermal trace tracks the range change', + 'Timestamped spectrum analyzer screenshots - the beacon level is tracking the excursion, so its amplitude trace maps the figure-8', ], correctIndex: 0, - explanation: 'When step-track follows the beacon, the antenna becomes a tracking instrument. Halifax can fit your pointing trace against their dynamics model - ground stations have refined orbits this way for decades.', + explanation: + 'When step-track follows the beacon, the antenna becomes a tracking instrument. Halifax can fit your pointing trace against their dynamics model - ground stations have refined orbits this way for decades.', pointPenalty: 5, }, mustMaintain: false, @@ -660,13 +659,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'Service is fully restored under step-track. What does the customer impact assessment say?', options: [ - 'No current impact; service nominal under contingency tracking. Elevated risk posture while the vehicle anomaly is open - next decision points are loss of step-track margin or a vehicle-status change from Halifax', - 'No impact, case closed', - 'Severe impact - recommend customers migrate immediately', - 'Impact unknown - decline to assess until Halifax finishes', + 'No current impact; service nominal under step-track. Elevated risk while the anomaly is open; decision points are step-track margin loss or a Halifax status change', + 'No impact; service nominal under step-track. Risk posture normal now that the link is recovered; no further decision points until Halifax closes the anomaly', + 'Severe impact; service at risk under step-track. Elevated risk while the anomaly is open; recommend customers migrate to TIDEMARK-1 before margin is lost', + 'Impact unknown; service nominal under step-track for now. Assessment deferred until Halifax closes the anomaly; no decision points set on the ground side', ], correctIndex: 0, - explanation: 'Honest posture: green today, amber risk, named tripwires. The S22 board work later in this phase grades exactly this skill - say what you know, label what you are watching.', + explanation: + 'Honest posture: green today, amber risk, named tripwires. The S22 board work later in this phase grades exactly this skill - say what you know, label what you are watching.', pointPenalty: 5, }, mustMaintain: false, @@ -679,7 +679,7 @@ export const scenario18Data: ScenarioData = { id: 'escalation-boundary-quiz', nice: ['S0593', 'K0721'], title: 'Escalation Tripwires', - description: 'Define when this stops being a ride-along and starts being Dana\'s call.', + description: "Define when this stops being a ride-along and starts being Dana's call.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['impact-assessment-quiz'], timeLimitSeconds: 2 * 60, @@ -692,13 +692,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'Which events escalate beyond the ground-operator role?', options: [ - 'Step-track losing the beacon, C/N trending below demod threshold despite good tracking, or Halifax declaring the vehicle unsafe - anything where keeping the link stops being possible or stops being wise', - 'Any change in the figure-8 size', - 'Each hourly mark, automatically', - 'Nothing - the operator owns the incident end to end', + 'Step-track losing the beacon, C/N below demod threshold despite good tracking, or Halifax declaring the vehicle unsafe', + 'Any growth in the figure-8 size, beacon C/N moving more than 1 dB, or Halifax publishing a corrected ephemeris', + 'Each hourly mark automatically, every step-track correction logged, or any time the pointing history is sent to Halifax', + 'Nothing on the ground side - the operator owns the link, the pointing feed, and the incident from open to close', ], correctIndex: 0, - explanation: 'You own the link while the link is holdable. The moment the question becomes "should this traffic move?" or "should this bird carry traffic at all?" - that is supervision and spacecraft authority, with your data underneath it.', + explanation: + 'You own the link while the link is holdable - the tripwires are anything where keeping the link stops being possible or stops being wise. The moment the question becomes "should this traffic move?" or "should this bird carry traffic at all?" - that is supervision and spacecraft authority, with your data underneath it.', pointPenalty: 5, }, mustMaintain: false, @@ -724,13 +725,14 @@ export const scenario18Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry hands this off correctly?', options: [ - 'TM-2 vehicle anomaly (Halifax NOC ref): N-S station-keeping suspended, ephemeris stale. ME-02 transitioned to step-track 0935, beacon and carrier recovered, no customer impact. Pointing history streaming to Halifax. Tripwires: step-track margin, vehicle status change. Anomaly OPEN.', - 'TM-2 had tracking problems, fixed with step-track.', - 'Vehicle anomaly resolved - returned to program-track.', - 'See Halifax for details.', + 'TM-2 vehicle anomaly (Halifax ref): N-S burns suspended, ephemeris stale. ME-02 step-track from 0935, carrier recovered, no impact. Pointing history to Halifax. Anomaly OPEN.', + 'TM-2 tracking anomaly (ME-02 ref): program-track lost margin, ephemeris stale. Step-track from 0935, carrier recovered, no impact. Fix confirmed, no further action. Anomaly CLOSED.', + 'TM-2 vehicle anomaly (Halifax ref): N-S burns suspended, ephemeris stale. ME-02 step-track 0935 to 1010, carrier recovered, no impact. Returned to program-track. Anomaly CLOSED.', + 'TM-2 vehicle anomaly (Halifax ref): thruster degradation under investigation. ME-02 carrier nominal, no impact. Halifax holds all detail and next steps. Anomaly OPEN, no ground actions.', ], correctIndex: 0, - explanation: 'OPEN in capital letters is the load-bearing word. The next operator inherits a healthy link inside an unresolved anomaly - the log must make both halves of that true.', + explanation: + 'OPEN in capital letters is the word that matters, and the tripwires (step-track margin, vehicle status change) belong in the handoff. The next operator inherits a healthy link inside an unresolved anomaly - the log must make both halves of that true.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario19.ts b/src/campaigns/nats/scenario19.ts index ae361ac0..8cfc389b 100644 --- a/src/campaigns/nats/scenario19.ts +++ b/src/campaigns/nats/scenario19.ts @@ -54,13 +54,7 @@ export const scenario19Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Knowledge Transfer', description: `A new hire starts ground school next month, and Dana wants a one-page quick-reference card for the AURORA-7 acquisition and step-track procedure - written by someone who actually flies it.

The method: run the procedure live this afternoon. At each step, choose the one callout, formula, or warning that belongs on the card. The card builds in front of you as you work - and at the end it gets handed to someone for whom it may be the only thing between a clean acquisition and a lost afternoon.

Anyone can execute a procedure. Teaching it means knowing which mistakes are actually waiting for the person who comes after you.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'Working Document panel (the card)', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'Working Document panel (the card)'], timeLimitSeconds: 35 * 60, settings: { isSync: true, @@ -120,7 +114,7 @@ export const scenario19Data: ScenarioData = { question: 'Ready to fly the AURORA-7 procedure and build the card as you go?', options: ['Ready - procedure live, card building in the Working Document panel.'], correctIndex: 0, - explanation: 'Every teaching choice you make appends to the card. Choose like the reader\'s first solo shift depends on it.', + explanation: "Every teaching choice you make appends to the card. Choose like the reader's first solo shift depends on it.", pointPenalty: 0, }, mustMaintain: false, @@ -152,7 +146,7 @@ export const scenario19Data: ScenarioData = { { id: 'card-scope-quiz', nice: ['T1334', 'T1411'], - title: 'Set the Card\'s Scope', + title: "Set the Card's Scope", description: 'Decide what kind of document this is before writing a line of it.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['select-vermont-station'], @@ -166,13 +160,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'What is a quick-reference card, as opposed to the SOP it accompanies?', options: [ - 'The numbers someone needs under pressure plus the mistakes with the highest local base rate - one page, taped to the console; the procedure itself stays in the SOP', - 'A condensed copy of every SOP step in smaller type', - 'A vendor datasheet excerpt', - 'A training syllabus for the first month', + 'The numbers needed under pressure plus the highest-base-rate local mistakes - one page; the procedure stays in the SOP', + 'Every SOP step condensed into smaller type - one page; the reader never has to open the SOP itself', + 'The vendor datasheet values for the AURORA-7 chain - one page; the procedure stays in the SOP', + 'The first-month training syllabus as a checklist - one page, taped to the console; the SOP is retired once it is learned', ], correctIndex: 0, - explanation: 'Selection is the work. Everything on the card competes for attention during a real acquisition - a line that does not earn its place costs the reader time when they can least afford it.', + explanation: + 'Selection is the work. Everything on the card competes for attention during a real acquisition - a line that does not earn its place costs the reader time when they can least afford it. The card is a pressure kit taped to the console, not a condensed SOP, a datasheet, or a syllabus.', pointPenalty: 5, documentSection: 'Header', documentLine: 'AURORA-7 ACQUIRE & TRACK - console copy | Audience: first-solo operator | The SOP is the procedure; this card is the pressure kit', @@ -237,13 +232,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which line teaches the acquisition step best?', options: [ - 'Program-track FIRST - it puts you inside beacon capture range. Nominal: Az 190, El 32, but the bird rides a ±3° figure-8', - 'Acquire AURORA-7 using the standard acquisition procedure', - 'The antenna must be powered before acquisition can begin', - 'AURORA-7 was launched over nineteen years ago and is approaching end of life', + 'Program-track FIRST - it puts you inside beacon capture range. Nominal Az 190, El 32, but the bird rides a ±3° figure-8', + 'Step-track FIRST - it hunts the beacon from any starting point. Nominal Az 190, El 32, and the figure-8 takes care of itself', + 'Manual FIRST - slew to nominal and wait for the beacon to appear. Nominal Az 190, El 32, and the bird holds within ±3° of it', + 'Program-track FIRST - it follows the standard acquisition procedure. Nominal Az 190, El 32, launched over nineteen years ago', ], correctIndex: 0, - explanation: 'Numbers plus the why. "Use the standard procedure" teaches nothing; trivia about launch dates costs card space the reader pays for at 2 AM.', + explanation: + 'Numbers plus the why. "Use the standard procedure" teaches nothing; trivia about launch dates costs card space the reader pays for at 2 AM. Step-track cannot start without a beacon in the cone, and a manual park loses a bird that wanders ±3°.', pointPenalty: 5, documentSection: 'Acquire', documentLine: 'Program-track FIRST - puts you inside beacon capture range. Nominal Az 190 / El 32, bird rides a ±3° figure-8', @@ -311,12 +307,13 @@ export const scenario19Data: ScenarioData = { question: 'Which beacon line belongs on the card?', options: [ 'Beacon IF = LO − RF = 5250 − 4165 = 1085 MHz. Weak CW (aging bird) - use a narrow span, ~2 kHz', - 'The beacon is at 1085', - 'Beacon frequencies are listed in the satellite operator\'s handbook, appendix C', - 'IF = |LO − RF| (see SOP for values)', + 'Beacon IF = LO − RF = 6080 − 4165 = 1915 MHz. Weak CW (aging bird) - use a narrow span, ~2 kHz', + 'Beacon IF = LO − RF = 5250 − 4165 = 1085 MHz. Weak CW (aging bird) - use a wide span, ~24 MHz', + 'Beacon IF = |LO − RF| per the SOP values, currently 1085 MHz. Weak CW (aging bird) - use a narrow span, ~2 kHz', ], correctIndex: 0, - explanation: 'The formula WITH the worked numbers, plus the trap (weak CW needs a narrow span). A bare "1085" breaks the day the LO changes; a bare formula breaks at 2 AM.', + explanation: + 'The formula WITH the worked numbers, plus the trap (weak CW needs a narrow span). A bare "1085" breaks the day the LO changes; a bare formula breaks at 2 AM. 6080 is the Maine LO, and a 24 MHz span buries a CW line in the noise.', pointPenalty: 5, documentSection: 'Acquire', documentLine: 'Beacon IF = LO − RF = 5250 − 4165 = 1085 MHz. Weak CW - use ~2 kHz span', @@ -331,7 +328,7 @@ export const scenario19Data: ScenarioData = { id: 'mistake-lo-quiz', nice: ['T1334', 'K0645'], title: 'Card Line: First Watch-Out', - description: 'Pick the highest-value warning for the acquisition phase from the station\'s actual history.', + description: "Pick the highest-value warning for the acquisition phase from the station's actual history.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['beacon-formula-quiz'], timeLimitSeconds: 2 * 60, @@ -344,13 +341,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Station history lists five past mistakes. Which acquisition-phase warning earns card space?', options: [ - 'Beacon "missing" at 1085? Check the LNB LO = 5250 - an operator fresh from Maine duty once hunted a healthy beacon for an hour with the LO still at Maine\'s 6080 default', - 'Do not look directly into the feed horn', - 'Remember that AURORA-7 is older than the other satellites', - 'Double-check that the spectrum analyzer is powered on', + 'Beacon "missing" at 1085? Check the LNB LO = 5250 - an operator fresh from Maine duty once hunted a healthy beacon for an hour', + 'Beacon "missing" at 1085? Check the analyzer is powered on - a dark screen has fooled more than one operator at the end of a shift', + 'Beacon "missing" at 1085? Remember AURORA-7 is the oldest bird - its beacon runs weaker than the TIDEMARK beacons and is easy to miss', + 'Beacon "missing" at 1085? Check the dish is powered and out of stow - nothing reaches the feed until the drives are up', ], correctIndex: 0, - explanation: 'It actually happened here, it costs an hour, and the check takes five seconds. That ratio - cost of mistake over cost of check - is what earns a warning its card space.', + explanation: + "It actually happened here, it costs an hour, and the check takes five seconds. That ratio - cost of mistake over cost of check - is what earns a warning its card space. The LO was still at Maine's 6080 default. A dark analyzer or a stowed dish is caught by the acquisition step before it; a weaker beacon is a fact, not a mistake.", pointPenalty: 5, documentSection: 'Watch Out', documentLine: 'Beacon "missing"? Verify LNB LO = 5250 - the Maine default (6080) bites cross-site operators', @@ -409,13 +407,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which step-track line goes on the card?', options: [ - 'Step-track RIDES program-track - engage it as an optimization on an acquired beacon, never from MANUAL (the loop needs a beacon to optimize)', - 'Enable step-track when tracking AURORA-7', - 'Step-track was invented to track inclined-orbit satellites', - 'Step-track may be engaged in any antenna mode', + 'Step-track RIDES program-track - engage it on an acquired beacon, never from MANUAL (the loop needs a beacon to optimize)', + 'Step-track REPLACES program-track - engage it as soon as the dish is powered, from any mode (the loop finds the beacon itself)', + 'Step-track FOLLOWS the ephemeris - engage it before acquisition whenever AURORA-7 is the target (the loop pre-positions the dish)', + 'Step-track NEEDS manual first - park on nominal Az 190 / El 32, then engage from MANUAL (the loop optimizes from a still start)', ], correctIndex: 0, - explanation: 'A new hire here once engaged step-track from manual with no beacon in the capture cone and watched the dish wander. The rule plus the reason makes the mistake impossible to repeat.', + explanation: + 'A new hire here once engaged step-track from manual with no beacon in the capture cone and watched the dish wander. The rule plus the reason makes the mistake impossible to repeat. The loop has no ephemeris and no search - it only optimizes a beacon it can already see.', pointPenalty: 5, documentSection: 'Track', documentLine: 'Step-track RIDES program-track - engage on an acquired beacon, never from MANUAL', @@ -463,13 +462,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which description of a healthy step-track belongs on the card?', options: [ - 'Healthy = beacon C/N steady at its peak while Az/El visibly wander the figure-8. Moving dish + flat C/N is the loop WORKING, not a fault', - 'Healthy step-track means the antenna position never changes', - 'Check the step-track LED is illuminated', - 'C/N values are documented in the link budget on file', + 'Healthy = beacon C/N steady at its peak while Az/El wander the figure-8. Moving dish + flat C/N is the loop WORKING', + 'Healthy = Az/El frozen on nominal 190 / 32 while beacon C/N rides the figure-8. Still dish + moving C/N is the loop WORKING', + 'Healthy = beacon C/N climbing steadily as Az/El converge on the peak. Slowing dish + rising C/N is the loop WORKING', + 'Healthy = step-track LED lit and Az/El matching the ephemeris exactly. Matched dish + any C/N is the loop WORKING', ], correctIndex: 0, - explanation: 'The counterintuitive part IS the lesson: motion is health. A new operator who expects a still dish will "fix" a working loop - this line inoculates them.', + explanation: + 'The counterintuitive part IS the lesson: motion is health. A new operator who expects a still dish will "fix" a working loop - this line inoculates them. A still dish on an inclined bird means the loop is NOT following it, and the LED only says the mode is selected.', pointPenalty: 5, documentSection: 'Track', documentLine: 'Healthy = C/N steady at peak while Az/El wander the figure-8. Moving dish + flat C/N = loop WORKING', @@ -497,13 +497,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which tracking warning earns the card space?', options: [ - 'C/N sagging mid-track? Verify step-track is still ON before touching the axes - hand-chasing the figure-8 is a losing game an operator here once played for twenty minutes', - 'Do not unplug the ACU during tracking', - 'AURORA-7\'s transponder bandwidth is narrower than TIDEMARK\'s', - 'Step-track parameters are configured by engineering and should not be modified', + 'C/N sagging mid-track? Verify step-track is still ON before touching the axes - hand-chasing the figure-8 is a losing game', + 'C/N sagging mid-track? Nudge the axes toward the peak before the loop loses it - step-track recovers faster from a good start', + 'C/N sagging mid-track? Leave the step-track parameters alone until engineering clears it - the loop is tuned per axis', + 'C/N sagging mid-track? Narrow the span before judging the carrier - the AURORA-7 transponder is narrower than TIDEMARK', ], correctIndex: 0, - explanation: 'Again the local base rate: the chase mistake has actually happened, has a cheap check (is the loop on?), and an expensive failure mode (losing the beacon entirely).', + explanation: + 'Again the local base rate: the chase mistake has actually happened (an operator here hand-chased the figure-8 for twenty minutes), has a cheap check (is the loop on?), and an expensive failure mode (losing the beacon entirely). Nudging the axes under a live loop fights it; the other two are not tracking faults at all.', pointPenalty: 5, documentSection: 'Watch Out', documentLine: 'C/N sagging? Check step-track is ON before touching axes - never hand-chase the figure-8', @@ -568,13 +569,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which verification line belongs on the card?', options: [ - 'Proof chain, in order: beacon at 1085 (pointing + LO) → RX locked at 1422 MHz / 24 MHz (carrier) → C/N ≥ 8 (margin). Each link proves something the others don\'t', - 'Verify the link is working before reporting completion', - 'Run the full link budget calculation and compare against the design values', - 'If the dashboard shows green, the link is verified', + 'Proof chain, in order: beacon at 1085 (pointing + LO) → RX locked at 1422 / 24 MHz (carrier) → C/N ≥ 8 dB (margin)', + 'Proof chain, in order: C/N ≥ 8 dB (margin) → RX locked at 1422 / 24 MHz (carrier) → beacon at 1085 (pointing + LO)', + 'Proof chain, in order: dashboard green (link) → RX locked at 1422 / 24 MHz (carrier) → link budget matches design (margin)', + 'Proof chain, in order: beacon at 1085 (pointing + LO) → RX locked at 1447 / 24 MHz (carrier) → C/N ≥ 8 dB (margin)', ], correctIndex: 0, - explanation: 'Ordered, numeric, and explains what each check proves - the S9 lesson (beacon proves RF, lock proves data, margin proves durability) compressed to one card line.', + explanation: + 'Ordered, numeric, and explains what each check proves - the S9 lesson (beacon proves RF, lock proves data, margin proves durability) compressed to one card line. Each link proves something the others do not, and the order matters: no margin without lock, no lock without pointing. 1447 is the TX IF, not the receive carrier; a green dashboard proves nothing by itself.', pointPenalty: 5, documentSection: 'Verify', documentLine: 'Proof chain: beacon 1085 (pointing+LO) → RX locked 1422 / 24 MHz (carrier) → C/N ≥ 8 (margin)', @@ -602,13 +604,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Last warning slot on the card - which one?', options: [ - 'Carrier "gone" but beacon fine? Widen the span - a 24 MHz carrier is invisible at the 2 kHz span you used for the beacon. (Operator here once declared an outage over this)', - 'The spectrum analyzer reference level should be checked weekly', - 'Always log out of the console at end of shift', - 'AURORA-7 may be retired in the coming years - check the operations bulletin', + 'Carrier "gone" but beacon fine? Widen the span - a 24 MHz carrier is invisible at the 2 kHz span you used for the beacon', + 'Carrier "gone" but beacon fine? Check the reference level - a 24 MHz carrier hides under a level set for a narrow CW beacon line', + 'Carrier "gone" but beacon fine? Check the operations bulletin - AURORA-7 is near retirement and its transponder may be off', + 'Carrier "gone" but beacon fine? Re-acquire in program-track - the dish has drifted off the transponder beam but not the beacon', ], correctIndex: 0, - explanation: 'The span trap is the natural sequel to the card\'s own beacon advice ("use 2 kHz span") - a good card warns about the mistakes its own instructions set up.', + explanation: + 'The span trap is the natural sequel to the card\'s own beacon advice ("use 2 kHz span") - a good card warns about the mistakes its own instructions set up. An operator here once declared an outage over it. Beacon and carrier ride the same beam, so pointing is already proven; the reference level moves the trace, not the carrier.', pointPenalty: 5, documentSection: 'Watch Out', documentLine: 'Carrier "gone" but beacon fine? WIDEN THE SPAN - 24 MHz won\'t show at the beacon\'s 2 kHz', @@ -623,7 +626,7 @@ export const scenario19Data: ScenarioData = { id: 'tx-numbers-quiz', nice: ['T1411', 'K0773'], title: 'Card Line: the TX Number', - description: 'The card needs the transmit-side number even though today\'s run was receive-only.', + description: "The card needs the transmit-side number even though today's run was receive-only.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['mistake-span-quiz'], timeLimitSeconds: 2 * 60, @@ -636,13 +639,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which transmit-side line belongs on the card?', options: [ - 'TX IF = BUC LO − uplink RF = 7500 − 6053 = 1447 MHz. AURORA\'s chain uses BUC LO 7500 - NOT the TIDEMARK 7000', - 'Transmit procedures are covered in a separate SOP', - 'TX IF = 1447 MHz', - 'The BUC local oscillator converts IF to RF', + "TX IF = BUC LO − uplink RF = 7500 − 6053 = 1447 MHz. AURORA's chain uses BUC LO 7500 - NOT the TIDEMARK 7000", + "TX IF = BUC LO − uplink RF = 7000 − 6053 = 947 MHz. AURORA's chain uses BUC LO 7000 - the same as the TIDEMARK chain", + "TX IF = uplink RF − LNB LO = 6053 − 5250 = 803 MHz. AURORA's chain uses the one LO 5250 - the same on both sides", + "TX IF = 1447 MHz per the transmit SOP. AURORA's chain uses its own BUC LO - see the separate transmit procedure for the value", ], correctIndex: 0, - explanation: 'The 7500-vs-7000 trap has burned a real operator here (carrier 500 MHz off). The worked formula carries both the number and the trap in one line.', + explanation: + 'The 7500-vs-7000 trap has burned a real operator here (carrier 500 MHz off). The worked formula carries both the number and the trap in one line. The LNB LO belongs to the receive chain, and a bare number with no working breaks the day the LO changes.', pointPenalty: 5, documentSection: 'Numbers', documentLine: 'TX IF = 7500 − 6053 = 1447 MHz. AURORA BUC LO is 7500 - NOT the TIDEMARK 7000', @@ -674,13 +678,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'The card intentionally omits launch history, link-budget math, and the full SOP steps. Why is leaving things OFF the card a teaching decision?', options: [ - 'Card space is the reader\'s attention under pressure - every line they scan past to find the one they need is time on a degraded link; the card earns trust by containing only what earns its place', - 'Shorter documents are cheaper to print', - 'The omitted material is classified', - 'It is not a decision - the card was simply finished', + "Card space is the reader's attention under pressure - every line they scan past is time lost on a degraded link", + 'Card space is a printing budget - every extra line pushes the card past one page and onto a second sheet', + 'Card space is not the question - the omitted material lives in the SOP, so the card was simply finished when the run ended', + 'Card space is a security boundary - launch history and link-budget math are restricted to engineering distribution', ], correctIndex: 0, - explanation: 'Dana\'s test for the card is whether SHE would tape it to a console. Editing is the difference between a reference and a re-printed manual.', + explanation: + "Dana's test for the card is whether SHE would tape it to a console. Editing is the difference between a reference and a re-printed manual. The card earns trust by containing only what earns its place; the omitted material is neither restricted nor an afterthought.", pointPenalty: 5, }, mustMaintain: false, @@ -706,13 +711,14 @@ export const scenario19Data: ScenarioData = { character: Character.SYSTEM, question: 'Which log entry closes the assignment?', options: [ - 'AURORA-7 quick-reference card complete - built against a live procedure run (acquire, step-track, verify, all green). Sections: Acquire / Track / Verify / Numbers / 3x Watch-Out from station history. Delivered to Dana for the new-hire packet.', - 'Training card finished.', - 'Procedure run complete, no anomalies.', - 'Card drafted - pending engineering review of all values.', + 'AURORA-7 card complete - built against a live procedure run, all green; delivered to Dana for the new-hire packet', + 'AURORA-7 card drafted - values copied from the vendor manual, procedure not run; held pending engineering review', + 'AURORA-7 procedure run complete - acquire, step-track and verify all green; no anomalies, card to follow next shift', + 'AURORA-7 training card finished - built from the SOP step list, condensed to one page; delivered to Dana for ground school', ], correctIndex: 0, - explanation: '"Built against a live run" is the card\'s provenance - it is what separates this document from the vendor manual it replaces.', + explanation: + '"Built against a live run" is the card\'s provenance - it is what separates this document from the vendor manual it replaces. Sections: Acquire / Track / Verify / Numbers / three Watch-Outs from station history, every one flown green today.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario2.ts b/src/campaigns/nats/scenario2.ts index e69c57b4..4656fb08 100644 --- a/src/campaigns/nats/scenario2.ts +++ b/src/campaigns/nats/scenario2.ts @@ -1,10 +1,10 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; import type { ScenarioData } from '@app/ScenarioData'; import type { dBm, Hertz, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { maineGroundStation, vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite } from './satellites'; @@ -61,13 +61,7 @@ export const scenario2Data: ScenarioData = { difficulty: 'beginner', missionType: 'Routine Operations', description: `The maintenance crew needs to perform work on the TIDEMARK-1 antenna feed assembly. You'll power down the transmit chain in the proper sequence to ensure safety (don't radiate the maintenance crew), move the antenna to maintenance position for access, then restore service after the maintenance window.

This is your first time actually controlling the equipment. Charlie will provide all frequency values and configuration settings - you just need to execute the procedures in the correct order.

Key lesson: Sequence matters. RF safety protocols exist for a reason.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Unavailable', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Unavailable'], timeLimitSeconds: 35 * 60, // 35 minutes settings: { isSync: true, @@ -90,10 +84,7 @@ export const scenario2Data: ScenarioData = { ], missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-2?content-only=true&dark=true', isExtraSatellitesVisible: true, - satellites: [ - tidemark1Satellite, - ses10Satellite, - ], + satellites: [tidemark1Satellite, ses10Satellite], }, objectives: [ // ============================================================ @@ -121,9 +112,7 @@ export const scenario2Data: ScenarioData = { description: 'Ready to Proceed', params: { question: 'Have you reviewed the mission brief and RF safety procedures?', - options: [ - 'Yes, I have read the mission brief and I am ready to proceed.', - ], + options: ['Yes, I have read the mission brief and I am ready to proceed.'], correctIndex: 0, explanation: 'The mission timer has started. Good luck!', pointPenalty: 0, @@ -152,12 +141,12 @@ export const scenario2Data: ScenarioData = { type: 'status-check', description: 'RF Safety Briefing Acknowledged', params: { - question: 'I need you to confirm you understand the RF safety briefing for today\'s maintenance work. Company policy requires verbal acknowledgment before we proceed. Lawyers and such...', - options: [ - 'I have received and understood the RF safety briefing for today\'s maintenance work.', - ], + question: + "I need you to confirm you understand the RF safety briefing for today's maintenance work. Company policy requires verbal acknowledgment before we proceed. Lawyers and such...", + options: ["I have received and understood the RF safety briefing for today's maintenance work."], correctIndex: 0, - explanation: 'Acknowledging the RF safety briefing ensures all personnel understand the hazards and procedures before maintenance work begins. An HPA can output several hundred watts - enough to cause serious RF burns.', + explanation: + 'Acknowledging the RF safety briefing ensures all personnel understand the hazards and procedures before maintenance work begins. An HPA can output several hundred watts - enough to cause serious RF burns.', pointPenalty: 0, }, mustMaintain: false, @@ -251,12 +240,13 @@ export const scenario2Data: ScenarioData = { question: 'Before we shut down, confirm the current HPA state. What does the HPA panel show?', options: [ 'HPA is enabled and transmitting with 10 dB backoff', - 'HPA is powered on but output is disabled', - 'HPA is powered off completely', - 'HPA shows fault condition - red alarm', + 'HPA is powered on but output is disabled for standby', + 'HPA is powered off completely and cooling down', + 'HPA shows a fault condition with a red alarm active', ], correctIndex: 0, - explanation: 'The HPA is currently enabled and transmitting at 10 dB backoff. This confirms there is active RF output that we need to safely shut down before maintenance personnel approach the antenna.', + explanation: + 'The HPA is currently enabled and transmitting at 10 dB backoff. This confirms there is active RF output that we need to safely shut down before maintenance personnel approach the antenna.', pointPenalty: 10, }, mustMaintain: false, @@ -322,12 +312,13 @@ export const scenario2Data: ScenarioData = { question: 'The HPA output is now disabled. What should you observe on the HPA panel?', options: [ 'HPA Enable indicator shows OFF - no RF output, but amplifier still energized', - 'HPA completely powered down - all indicators off', - 'HPA still transmitting at reduced power', - 'HPA showing warning alarm', + 'HPA Power indicator shows OFF - amplifier de-energized, all indicators dark', + 'HPA Enable indicator shows ON - reduced RF output, amplifier still energized', + 'HPA Alarm indicator shows WARNING - output tripped, amplifier still energized', ], correctIndex: 0, - explanation: 'The HPA Enable indicator shows OFF, meaning no RF is being transmitted. However, the amplifier is still powered and components are hot - we need to power it off completely before it\'s safe.', + explanation: + "The HPA Enable indicator shows OFF, meaning no RF is being transmitted. However, the amplifier is still powered and components are hot - we need to power it off completely before it's safe.", pointPenalty: 10, }, mustMaintain: false, @@ -425,9 +416,9 @@ export const scenario2Data: ScenarioData = { question: 'The BUC is now powered off. What does the BUC status show?', options: [ 'BUC power indicator is OFF - completely de-energized', - 'BUC is muted but still powered', - 'BUC still outputting at low power', - 'BUC reference unlocked', + 'BUC mute indicator is ON - silenced but still powered', + 'BUC power indicator is ON - outputting at low power', + 'BUC reference indicator is UNLOCKED - lost 10 MHz lock', ], correctIndex: 0, explanation: 'The BUC power indicator is OFF - the upconverter is completely de-energized. No RF energy can be generated from this equipment.', @@ -549,12 +540,13 @@ export const scenario2Data: ScenarioData = { question: 'The RF chain should now be completely cold. Which equipment is still powered?', options: [ 'GPSDO and control systems only - all RF equipment is off', - 'LNB is still receiving signals passively', - 'BUC is still energized but muted', - 'HPA is still warming up', + 'GPSDO and LNB only - the receive side still passively powered', + 'GPSDO and BUC only - the upconverter still energized but muted', + 'GPSDO and HPA only - the amplifier still warming up on standby', ], correctIndex: 0, - explanation: 'Correct. The GPSDO and control systems remain powered for timing and monitoring, but all RF equipment (LNB, BUC, HPA) is completely de-energized. The antenna is safe for maintenance personnel to approach.', + explanation: + 'Correct. The GPSDO and control systems remain powered for timing and monitoring, but all RF equipment (LNB, BUC, HPA) is completely de-energized. The antenna is safe for maintenance personnel to approach.', pointPenalty: 10, }, mustMaintain: false, @@ -667,12 +659,13 @@ export const scenario2Data: ScenarioData = { question: 'The antenna is now at maintenance position. Why do we use 5° elevation instead of 0°?', options: [ 'Low enough for crew access, high enough to clear obstructions', - 'Antenna cannot physically reach 0° elevation', - 'To maintain satellite lock during maintenance', - 'Required by FCC regulations', + 'Low enough for crew access, but the mount cannot reach 0° elevation', + 'High enough to keep satellite lock, low enough for feed access', + 'Low enough for crew access, and the minimum FCC rules allow', ], correctIndex: 0, - explanation: '5° elevation gives maintenance personnel access to the feed assembly while keeping the antenna clear of any ground-level obstructions. This is the standard maintenance position for this facility.', + explanation: + '5° elevation gives maintenance personnel access to the feed assembly while keeping the antenna clear of any ground-level obstructions. This is the standard maintenance position for this facility.', pointPenalty: 10, }, mustMaintain: false, @@ -709,7 +702,8 @@ export const scenario2Data: ScenarioData = { 'Request a second maintenance crew for inspection', ], correctIndex: 0, - explanation: 'Before restoring RF power, you must confirm all personnel are clear of the antenna. Never re-energize equipment while people could be in the RF radiation zone.', + explanation: + 'Before restoring RF power, you must confirm all personnel are clear of the antenna. Never re-energize equipment while people could be in the RF radiation zone.', pointPenalty: 10, }, mustMaintain: false, @@ -872,7 +866,7 @@ export const scenario2Data: ScenarioData = { type: 'status-check', description: 'Verify LNB Status', params: { - question: 'The LNB is now powered and configured. What key indicator confirms it\'s ready for operation?', + question: "The LNB is now powered and configured. What key indicator confirms it's ready for operation?", options: [ 'Thermal stability indicator shows green - temperature stabilized', 'LO frequency shows exactly 5250.000 MHz - no drift', @@ -880,7 +874,8 @@ export const scenario2Data: ScenarioData = { 'All of the above should be confirmed', ], correctIndex: 3, - explanation: 'All three indicators should be confirmed: thermal stability ensures consistent gain and noise performance, LO frequency accuracy ensures correct downconversion, and reference lock ensures frequency stability from the GPSDO.', + explanation: + 'All three indicators should be confirmed: thermal stability ensures consistent gain and noise performance, LO frequency accuracy ensures correct downconversion, and reference lock ensures frequency stability from the GPSDO.', pointPenalty: 10, preserveOptionOrder: true, }, @@ -965,12 +960,13 @@ export const scenario2Data: ScenarioData = { question: 'You see the TIDEMARK-1 beacon at 1,074.5 MHz IF. The RF beacon frequency is 4,175.5 MHz. Which calculation confirms the LNB is set correctly?', options: [ 'LO (5,250 MHz) - RF (4,175.5 MHz) = IF (1,074.5 MHz)', - 'RF (4,175.5 MHz) + IF (1,074.5 MHz) = LO (5,250 MHz)', - 'IF (1,074.5 MHz) × 4 = RF (4,298 MHz)', - 'The frequencies are coincidentally correct', + 'RF (4,175.5 MHz) + LO (5,250 MHz) = IF (9,425.5 MHz)', + 'RF (4,175.5 MHz) - IF (1,074.5 MHz) = LO (3,101 MHz)', + 'LO (5,250 MHz) + IF (1,074.5 MHz) = RF (6,324.5 MHz)', ], correctIndex: 0, - explanation: 'The LNB performs downconversion by mixing the incoming RF signal with its Local Oscillator. LO (5,250 MHz) minus RF (4,175.5 MHz) equals IF (1,074.5 MHz). This confirms the LO is set correctly and the receive path is working.', + explanation: + 'The LNB performs downconversion by mixing the incoming RF signal with its Local Oscillator. LO (5,250 MHz) minus RF (4,175.5 MHz) equals IF (1,074.5 MHz). This confirms the LO is set correctly and the receive path is working.', pointPenalty: 10, }, mustMaintain: false, @@ -1153,15 +1149,16 @@ export const scenario2Data: ScenarioData = { type: 'status-check', description: 'Confirm Service Restoration', params: { - question: 'TIDEMARK-1 should now be back in full service. What\'s the correct sequence for future scheduled maintenance?', + question: "TIDEMARK-1 should now be back in full service. What's the correct sequence for future scheduled maintenance?", options: [ 'Shutdown: HPA → BUC → Modem TX → LNB → Antenna. Restore: Antenna → LNB → Modem TX → BUC → HPA', - 'Shutdown: Antenna → LNB → BUC → HPA. Restore: HPA → BUC → LNB → Antenna', - 'Shutdown: LNB → BUC → HPA → Antenna. Restore: Antenna → HPA → BUC → LNB', - 'Sequence doesn\'t matter as long as all equipment is powered down', + 'Shutdown: Antenna → LNB → Modem TX → BUC → HPA. Restore: HPA → BUC → Modem TX → LNB → Antenna', + 'Shutdown: LNB → Modem TX → BUC → HPA → Antenna. Restore: Antenna → HPA → BUC → Modem TX → LNB', + 'Shutdown: HPA → BUC → Modem TX → LNB → Antenna. Restore: HPA → BUC → Modem TX → LNB → Antenna', ], correctIndex: 0, - explanation: 'Correct! Shutdown sequence is HPA (high-power) → BUC (low-power) → Modem TX → LNB → Antenna. Restoration is the reverse: Antenna → LNB → Modem TX → BUC → HPA. Always shut down high-power equipment first for safety, and restore low-power equipment first to verify signal before applying high power.', + explanation: + 'Correct! Shutdown sequence is HPA (high-power) → BUC (low-power) → Modem TX → LNB → Antenna. Restoration is the reverse: Antenna → LNB → Modem TX → BUC → HPA. Always shut down high-power equipment first for safety, and restore low-power equipment first to verify signal before applying high power. Restoring in the same order as shutdown would energize the HPA before anything upstream is verified.', pointPenalty: 10, }, mustMaintain: false, @@ -1599,4 +1596,4 @@ export const scenario2Data: ScenarioData = { }, }, }, -}; \ No newline at end of file +}; diff --git a/src/campaigns/nats/scenario20.ts b/src/campaigns/nats/scenario20.ts index 50dd07fd..e15037b8 100644 --- a/src/campaigns/nats/scenario20.ts +++ b/src/campaigns/nats/scenario20.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Character, Emotion } from '@app/modal/character-enum'; @@ -6,7 +7,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz, IfFrequency, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -65,7 +65,7 @@ const vt01Ice = (): number => { }; }; }; - const gs = w.signalRange?.simulationManager?.groundStations?.find(g => g.state?.id === 'VT-01'); + const gs = w.signalRange?.simulationManager?.groundStations?.find((g) => g.state?.id === 'VT-01'); return gs?.antennas?.[0]?.state?.iceAccumulation_dB ?? 99; }; @@ -81,13 +81,7 @@ export const scenario20Data: ScenarioData = { difficulty: 'advanced', missionType: 'Incident Response', description: `Two boards lit at once. Vermont is in the front edge of a winter storm and the feed is icing - the heater that should have been running since last night is off, and the RX margin is bleeding toward the demod floor. Maine just threw an HPA overdrive with an over-temperature alarm stacked on top: the back-off walked all the way down to 1 dB.

Unrelated problems. Same shift. One operator. Dana is forty minutes out on bad roads, and James Okafor is already asking whether two stations failing at once is something worse than bad luck.

Triage them: the slow recovery you can start costs nothing to start first; the dangerous fault gets your full attention immediately after; and the question James asked deserves an answer built from evidence.`, - equipment: [ - '9-meter C-band Antennas (both sites)', - 'RF Front Ends (both sites)', - 'Spectrum Analyzers', - 'RX/TX Modems', - 'Weather radar feed', - ], + equipment: ['9-meter C-band Antennas (both sites)', 'RF Front Ends (both sites)', 'Spectrum Analyzers', 'RX/TX Modems', 'Weather radar feed'], timeLimitSeconds: 45 * 60, settings: { isSync: true, @@ -234,9 +228,9 @@ export const scenario20Data: ScenarioData = { question: 'Two sites degrading at once and Dana is 40 minutes out. First move?', options: [ 'Read both boards before fixing either - triage is a decision about order, and order needs the whole picture', - 'Fix whichever site you happen to be looking at', - 'Wait for Dana - dual outages exceed solo authority', - 'Hand all traffic to whichever site is healthier', + 'Fix the board in front of you first - a live fault beats a plan, and the second site waits its turn', + 'Call Dana before touching either site - dual outages exceed solo authority, and the order is hers to set, not yours', + 'Hand all traffic to the healthier site first - customers see one clean trunk, and the diagnosis can wait', ], correctIndex: 0, explanation: 'The S16 rule scaled up: triage before action. Thirty seconds of reading both boards buys the order that costs the customers least.', @@ -296,13 +290,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'Ice is accumulating on the VT-01 feed during an active storm. What is the actual failure here?', options: [ - 'The feed heater is OFF - it should have been running before the front arrived; ice is the consequence, the cold heater is the fault', - 'The storm itself - no operator action is relevant', - 'The antenna drive is frozen', - 'The LNB has failed in the cold', + 'The feed heater is OFF - it should have been running before the front; ice is the consequence, the cold heater is the fault', + 'The storm itself - no heater outruns an active front; ice is the fault, and no operator action changes it', + 'The antenna drive is frozen - the axes stalled when the front hit; ice is the symptom, the seized motor is the fault', + 'The LNB has failed in the cold - its noise figure collapsed with the temperature; ice is a bystander, the receiver is the fault', ], correctIndex: 0, - explanation: 'Weather is not a fault; being unprepared for forecast weather is. The S3/S14 discipline - heater before the front - was missed on the previous shift, and now the recovery costs minutes instead of nothing.', + explanation: + 'Weather is not a fault; being unprepared for forecast weather is. The S3/S14 discipline - heater before the front - was missed on the previous shift, and now the recovery costs minutes instead of nothing.', pointPenalty: 5, }, mustMaintain: false, @@ -345,7 +340,7 @@ export const scenario20Data: ScenarioData = { id: 'select-maine-station', nice: ['S0421'], title: 'Open ME-02', - description: 'Vermont\'s recovery is running. Now the dangerous one.', + description: "Vermont's recovery is running. Now the dangerous one.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['vt-enable-heater'], timeLimitSeconds: 1 * 60, @@ -385,13 +380,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'ME-02 shows HPA overdrive and HPA over-temperature together. What is the relationship?', options: [ - 'One fault, two symptoms: back-off at 1 dB drives the amplifier near saturation - IMD rises (overdrive alarm) and the output stage dissipates harder (thermal alarm). Fix the back-off and both clear', - 'Two independent HPA faults that must be worked separately', - 'The thermal alarm caused the back-off to drift', - 'Sensor error - an amplifier cannot be hot and overdriven at once', + 'One fault, two symptoms: 1 dB back-off drives the HPA near saturation - IMD rises (overdrive) and dissipation climbs (thermal)', + 'Two faults, two symptoms: a drive-level fault pushes the HPA into IMD (overdrive) and a cooling fault lets it run hot (thermal)', + 'One fault, two symptoms: the output stage overheated first - gain drifted up with temperature (overdrive) and stayed hot (thermal)', + 'No fault, two readings: a failed sensor board reports both - an HPA cannot be saturated (overdrive) and hot (thermal) at once', ], correctIndex: 0, - explanation: 'S13\'s lesson in an HPA jacket: trace symptoms to the single input that explains them all. Back-off is the input; heat and IMD are the outputs.', + explanation: + "S13's lesson in an HPA jacket: trace symptoms to the single input that explains them all. Back-off is the input; heat and IMD are the outputs. Fix the back-off and both clear.", pointPenalty: 5, }, mustMaintain: false, @@ -417,13 +413,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'Two sites degrading simultaneously. How do you treat the "is this an attack?" question right now?', options: [ - 'Hold it open and collect the rule-out evidence as you work: independent causes that each fully explain their own site, no unexplained RF on either spectrum. Answer it with evidence after the fixes, not with a shrug before them', - 'Dismiss it - storms and config drift happen all the time', - 'Assume attack and disconnect both stations from the network', - 'It cannot be answered at the operator level', + 'Hold it open and collect rule-out evidence as you work: independent causes for each site, no unexplained RF on either spectrum', + 'Dismiss it and work the faults: storms and config drift happen every week, and simultaneity on its own proves nothing', + 'Assume attack and isolate both stations: disconnect the network links, hold the uplinks down, and wait for a security review', + 'Park it and finish the fixes: attribution is above the operator level, so the question goes to Dana with the incident report', ], correctIndex: 0, - explanation: 'Simultaneity is what coordinated interference would look like - and what a Friday in January looks like. The discipline is neither paranoia nor dismissal: keep the question open exactly as long as the evidence takes.', + explanation: + 'Simultaneity is what coordinated interference would look like - and what a Friday in January looks like. The discipline is neither paranoia nor dismissal: keep the question open exactly as long as the evidence takes. Answer it with evidence after the fixes, not with a shrug before them.', pointPenalty: 10, }, mustMaintain: false, @@ -436,7 +433,7 @@ export const scenario20Data: ScenarioData = { id: 'triage-order-quiz', nice: ['S0807', 'S0671'], title: 'Defend the Order', - description: 'You started Vermont\'s heater before coming here. Make the triage logic explicit.', + description: "You started Vermont's heater before coming here. Make the triage logic explicit.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['coincidence-quiz'], timeLimitSeconds: 2 * 60, @@ -449,13 +446,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'Why was "flip VT\'s heater, then work ME\'s HPA end-to-end" the right order?', options: [ - 'VT\'s recovery is slow but starts with one switch - starting it first costs ME nothing. ME\'s fault is actively dangerous (spectrum pollution + amplifier stress) and deterministic to fix, so it gets full attention immediately after', - 'Vermont is the primary station and always comes first', - 'Alphabetical order by station identifier', - 'The HPA fault could have waited - the order was arbitrary', + "VT's fix is one switch that runs unattended; ME's fault is dangerous (spectrum pollution + HPA stress) and needs full attention", + "VT is the primary station and always comes first; ME's fault is a config value (back-off drift) and can wait for the second pass", + "VT comes first alphabetically by station identifier; ME's fault is the same size (one alarm pair) and the order was arbitrary", + "VT's ice is the customer-visible fault; ME's HPA was still passing traffic (carrier locked) and could have waited for Dana", ], correctIndex: 0, - explanation: 'Triage is about clock management: start what runs unattended, then serialize your attention on what needs it. Ten seconds at VT bought minutes of parallel recovery.', + explanation: + "Triage is about clock management: start what runs unattended, then serialize your attention on what needs it. Ten seconds at VT bought minutes of parallel recovery. ME's fault is also deterministic to fix, which is why it gets full attention immediately after.", pointPenalty: 5, }, mustMaintain: false, @@ -568,13 +566,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'Back-off is at 10 dB and the output is re-enabled. What does the thermal alarm do, and why?', options: [ - 'It clears on its own - output power dropped ~9 dB, so the output stage dissipates a fraction of the heat; the temperature falls with the dissipation that caused it', - 'It stays latched until maintenance resets it physically', - 'It clears only after the BUC is power-cycled', - 'It stays on as a 24-hour cooldown precaution', + 'It clears on its own - output power dropped ~9 dB, so the output stage dissipates a fraction of the heat and cools', + 'It stays latched - the over-temperature flag is a hardware latch, so maintenance has to reset it physically on site', + 'It clears only after a power cycle - the BUC and HPA share the alarm bus, so the fault holds until both restart', + 'It stays on for 24 hours - the thermal alarm runs a cooldown timer, so the output stage is protected until it expires', ], correctIndex: 0, - explanation: 'Confirmation that the diagnosis was right: one input (back-off), two symptoms, both gone. If the thermal alarm had stayed up, the single-fault story would be wrong - and you would start looking for the second fault.', + explanation: + 'Confirmation that the diagnosis was right: one input (back-off), two symptoms, both gone. The temperature falls with the dissipation that caused it. If the thermal alarm had stayed up, the single-fault story would be wrong - and you would start looking for the second fault.', pointPenalty: 5, }, mustMaintain: false, @@ -663,13 +662,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'The front is still overhead. What keeps Vermont healthy for the rest of it?', options: [ - 'Nothing new - the heater holds ice at bay as fast as it forms; the steady state is heater ON plus periodic margin checks until the front clears', - 'Repeating the melt cycle every thirty minutes', - 'A precautionary handover of TM-1 to Maine', - 'Raising LNB gain to compensate for the storm', + 'Nothing new - the heater holds ice at bay as fast as it forms; heater ON plus periodic margin checks until the front clears', + 'A melt cycle every thirty minutes - the heater cannot run continuously; heater cycled OFF and ON until the front clears', + 'A precautionary handover to Maine - the heater only slows the ice; TM-1 moved to ME-02 until the front clears', + 'Raising LNB gain to cover the loss - the heater holds but margin sags; gain stepped up until the front clears', ], correctIndex: 0, - explanation: 'The S14 lesson holds: with the right protections running, weather is something you monitor, not something you fight. The failure this morning was a cold heater, not a strong storm.', + explanation: + 'The S14 lesson holds: with the right protections running, weather is something you monitor, not something you fight. The failure this morning was a cold heater, not a strong storm.', pointPenalty: 5, }, mustMaintain: false, @@ -699,13 +699,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'What does James get?', options: [ - 'Both trunks restored: Vermont was storm icing (heater now running, holding), Maine was an amplifier config fault (corrected, verified). Causes independent and fully explained - the simultaneity was coincidence, and here is why we are confident.', - 'Everything is fixed. Bad luck, like I said.', - 'Technical incident report attached, 14 pages, conclusions on page 11.', - 'Still investigating, will update within 24 hours.', + 'Both trunks up: Vermont was storm icing (heater on), Maine an amplifier config fault (fixed) - coincidence, and here is why', + 'Both trunks up: Vermont was the storm (bad luck), Maine a config drift (fixed) - like I said, nothing to worry about', + 'Both trunks up: full incident report attached (14 pages), conclusions on page 11 (both sites) - call back after you read it', + 'Both trunks up: Vermont was storm icing (heater on), Maine an amplifier fault (fixed) - still investigating, update in 24 hours', ], correctIndex: 0, - explanation: 'Cause, action, status for each site - then the answer to the question he actually asked, with the reasoning shown. "Here is why we are confident" is what makes "coincidence" a finding instead of a hope.', + explanation: + 'Cause, action, status for each site - then the answer to the question he actually asked, with the reasoning shown. "Here is why we are confident" is what makes "coincidence" a finding instead of a hope. A 14-page attachment is not an answer, and "still investigating" after the rule-out is done leaves him worried for nothing.', pointPenalty: 5, }, mustMaintain: false, @@ -717,7 +718,7 @@ export const scenario20Data: ScenarioData = { { id: 'adversarial-ruleout-quiz', nice: ['K0751', 'S0807'], - title: 'Close the Question Honestly', + title: 'Close the Question Cleanly', description: 'Write the rule-out into the record - the evidence, not the conclusion.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['james-comms-quiz'], @@ -731,13 +732,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'Which evidence set closes the "coincidence or attack" question?', options: [ - 'VT degradation tracked the storm exactly (radar + precip sensor agree, heater-on fixed it); ME fault was a config value with mundane history; no unexplained signals on either spectrum; both recoveries behaved as their diagnoses predicted', - 'Both faults were fixed, therefore neither was an attack', - 'No attacker would target a teleport in Vermont', - 'The question cannot be closed without a federal investigation', + 'VT tracked the storm (radar + precip agree, heater fixed it); ME was a config value with mundane history; both spectra clean', + 'VT and ME were both fixed (heater on, back-off restored); a fixed fault is a benign fault; no attack survives its own repair', + 'VT is a rural teleport (no strategic customer); ME carries the same traffic; nobody targets either, so the question closes itself', + 'VT and ME failed within minutes (0712 both); simultaneity needs a federal investigation; the operator cannot close it', ], correctIndex: 0, - explanation: 'Each cause independently explains its own site, the fixes behaved as predicted, and the spectra are clean. Document it every time it IS coincidence - that record is what makes you credible the day it is not.', + explanation: + 'Each cause independently explains its own site, the fixes behaved as predicted, and the spectra are clean. Document it every time it IS coincidence - that record is what makes you credible the day it is not. "Fixed" alone proves nothing, and "nobody would bother" is not evidence.', pointPenalty: 10, }, mustMaintain: false, @@ -763,13 +765,14 @@ export const scenario20Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry records this incident correctly?', options: [ - 'Concurrent site degradation 0712: VT-01 feed icing (heater off ahead of front - corrected 0716, melt verified) | ME-02 HPA back-off drift to 1 dB (output disabled, 10 dB restored, re-enabled clean by 0734). Causes independent - rule-out documented. Customers notified. Heater discipline flagged for shift-change checklist.', - 'Both stations had problems this morning. Fixed.', - 'Weather event at VT-01. See separate ME-02 ticket.', - 'Dual outage resolved - details available on request.', + 'Dual degradation 0712: VT-01 icing (heater on 0716, melted) | ME-02 HPA back-off 1 dB (10 dB restored 0734); causes independent', + 'Dual degradation 0712: VT-01 weather event (storm, no action) | ME-02 HPA fault (see separate ticket); both resolved by 0734', + 'Dual degradation 0712: VT-01 icing (heater on 0716, melted) | ME-02 HPA back-off 1 dB (10 dB restored 0734); details on request', + 'Dual degradation 0712: VT-01 icing (storm, heater on) | ME-02 HPA fault (fixed); both stations had problems, both fixed by 0734', ], correctIndex: 0, - explanation: 'Timeline, both causes, both fixes, the rule-out, and the process fix (heater on the shift-change checklist) so the inherited failure stops being inheritable.', + explanation: + 'Timeline, both causes, both fixes, the rule-out, and the process fix (heater on the shift-change checklist) so the inherited failure stops being inheritable. In full: heater off ahead of the front, corrected 0716 and melt verified; output disabled, 10 dB restored, re-enabled clean by 0734; customers notified; heater discipline flagged for the shift-change checklist.', pointPenalty: 5, }, mustMaintain: false, @@ -817,7 +820,7 @@ export const scenario20Data: ScenarioData = { 'adversarial-ruleout-quiz': { text: `

- Appreciate the callback - and the homework behind it. "Here's why we're confident" lands a lot better with my board than "trust us." Both trunks look clean from our side. We're good. + Appreciate the callback - and the homework behind it. "Here's why we're confident" goes over a lot better with my board than "trust us." Both trunks look clean from our side. We're good.

`, character: Character.JAMES_OKAFOR, diff --git a/src/campaigns/nats/scenario21.ts b/src/campaigns/nats/scenario21.ts index 4d8f14eb..29bf45df 100644 --- a/src/campaigns/nats/scenario21.ts +++ b/src/campaigns/nats/scenario21.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Character, Emotion } from '@app/modal/character-enum'; @@ -6,7 +7,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz, IfFrequency, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -73,13 +73,7 @@ export const scenario21Data: ScenarioData = { difficulty: 'advanced', missionType: 'Threat Response', description: `Catherine's day shift flagged it before she left: intermittent broadband noise riding the TIDEMARK-2 downlink, on-off-on, gone before she could characterize it. It's back this morning. It doesn't match the cross-pol neighbors and it doesn't match weather.

Treat it as an interference incident from the first minute. Characterize the signature, discriminate jamming from a benign accident, mitigate what you can on the receive side, and build the regulator package as you go. And verify the half of this that matters most: an RF interferer denies your signal - it does not touch your data. Prove the data layer is intact, or you are no longer working an interference event.

If it turns out to be somebody's misaligned uplink, the same evidence resolves it. If it's deliberate, the evidence IS the response.`, - equipment: [ - '9-meter C-band Antenna (ME-02)', - 'RF Front End (notch filter)', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'Crypto module (data-layer integrity)', - ], + equipment: ['9-meter C-band Antenna (ME-02)', 'RF Front End (notch filter)', 'Spectrum Analyzer', 'RX/TX Modems', 'Crypto module (data-layer integrity)'], timeLimitSeconds: 35 * 60, settings: { isSync: true, @@ -205,7 +199,7 @@ export const scenario21Data: ScenarioData = { id: 'review-mission-brief', nice: ['K0645'], title: 'Review the Interference Flag', - description: 'Open the brief and Catherine\'s end-of-shift note.', + description: "Open the brief and Catherine's end-of-shift note.", groundStation: 'ME-02', freezesScenarioTimer: true, prerequisiteObjectiveIds: [], @@ -223,13 +217,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'An intermittent interferer is on TM-2. How do you treat it from minute one?', options: [ - 'As an interference incident: characterize, discriminate, mitigate, document - and verify the data layer separately. The same evidence resolves an accident or builds the case for deliberate interference', - 'Wait to see if it clears on its own before doing anything', - 'Assume it is jamming and immediately disconnect TM-2', - 'Power-cycle the LNB - intermittent usually means a flaky receiver', + 'As an interference incident: characterize, discriminate, mitigate, document - and verify the data layer separately', + 'As a transient: watch, wait, time-stamp, log - and act only if it is still there at the end of the shift', + 'As a jamming attack: disconnect TM-2, notify Dana, isolate the network, escalate - and characterize it afterward', + 'As a receiver fault: power-cycle the LNB, re-lock, re-verify, log - and treat intermittent as a flaky front end', ], correctIndex: 0, - explanation: 'Evidence first, conclusion last. Whether it ends as "misaligned partner uplink" or "deliberate denial," the work is identical until the evidence decides - and that discipline is what makes the eventual conclusion defensible.', + explanation: + 'Evidence first, conclusion last. Whether it ends as "misaligned partner uplink" or "deliberate denial," the work is identical until the evidence decides - and that discipline is what makes the eventual conclusion defensible. The same evidence resolves an accident or builds the case for deliberate interference.', pointPenalty: 5, }, mustMaintain: false, @@ -318,13 +313,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'The interferer runs a regular cycle - roughly ninety seconds on, a minute off, repeating. What does that timing argue?', options: [ - 'Against accident, toward deliberation: natural and accidental sources are continuous or random; a clean repeating on/off cadence on the scale of a minute implies a hand on a switch or an automated transmitter, not weather or a stuck oscillator', - 'Toward radar - rotating antennas produce exactly this minute-scale cadence', - 'Toward a failing power supply on a neighbor\'s uplink', - 'Nothing - duty cycle carries no diagnostic information', + 'Against accident, toward deliberation: accidents are continuous or random; a clean minute-scale on/off cadence implies a switch', + 'Toward radar, away from intent: rotating antennas sweep past on a fixed period; a minute-scale on/off cadence is a scan rate', + "Toward a failing supply on a neighbor's uplink: a dying regulator cycles as it heats; a minute-scale cadence is thermal", + "Toward nothing: duty cycle is not diagnostic; on/off timing depends on the receiver's AGC, not on the source of the noise", ], correctIndex: 0, - explanation: 'Radar duty cycles are seconds, not minutes. Accidental interference is continuous or erratic. A deliberate, minute-scale on/off pattern is one of the strongest behavioral indicators of intent - though it is an indicator, not proof.', + explanation: + 'Radar duty cycles are seconds, not minutes. Accidental interference is continuous or erratic. A deliberate, minute-scale on/off pattern implies a hand on a switch or an automated transmitter, not weather or a stuck oscillator - one of the strongest behavioral indicators of intent, though it is an indicator, not proof.', pointPenalty: 5, }, mustMaintain: false, @@ -350,13 +346,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'You confirm VT-01 and Halifax see the same interferer on TM-2 TP-1. What does that establish?', options: [ - 'It is in the UPLINK - the satellite is relaying it to every receiving station, so the source is transmitting at the bird, not a local terrestrial source at Maine. That rules out radar/5G/our own equipment and points at a misaligned or deliberate uplink', - 'It is local terrestrial interference at all three sites simultaneously', - 'It proves the satellite transponder itself is malfunctioning', - 'Nothing - other stations seeing it is expected for any signal', + 'It is in the UPLINK - the satellite relays it to every station, so the source is transmitting at the bird, not local to Maine', + 'It is LOCAL terrestrial - all three sites sit under the same radar and 5G footprint, so each is hearing its own ground source', + 'It is the TRANSPONDER - a failing payload puts noise on every downlink, so the satellite itself is the source, not an uplink', + 'It is EXPECTED - every station on the bird sees every signal the bird relays, so cross-station visibility tells you nothing', ], correctIndex: 0, - explanation: 'This is the highest-information test in the matrix. Local interference appears at one station; uplink interference is relayed to all. Every-station visibility moves the source from "somewhere near Maine" to "transmitting at the satellite."', + explanation: + 'This is the highest-information test in the matrix. Local interference appears at one station; uplink interference is relayed to all. Every-station visibility moves the source from "somewhere near Maine" to "transmitting at the satellite." That rules out radar, 5G, and our own equipment, and points at a misaligned or deliberate uplink.', pointPenalty: 5, }, mustMaintain: false, @@ -380,15 +377,17 @@ export const scenario21Data: ScenarioData = { description: 'Shape Read', params: { character: Character.SYSTEM, - question: 'The interferer is broadband-ish, sized close to our carrier, sitting inside our transponder near our allocation. How does that compare to benign explanations?', + question: + 'The interferer is broadband-ish, sized close to our carrier, sitting inside our transponder near our allocation. How does that compare to benign explanations?', options: [ - 'It matches nothing in the coordination database: cross-pol leakage would mirror a known neighbor\'s carrier, an errant uplink would look like a modulated carrier at a coordinated slot - broadband noise shaped to our carrier inside our passband fits neither', - 'It is a textbook cross-pol leakage signature', - 'It matches a coordinated adjacent-satellite carrier exactly', - 'Bandwidth and placement carry no diagnostic weight', + "It matches nothing in the database: cross-pol would mirror a neighbor's carrier, an errant uplink would sit at a known slot", + "It matches cross-pol leakage: a neighbor's carrier on the other polarization bleeds in, sized like ours because it is a peer", + 'It matches a coordinated adjacent-satellite carrier: the database lists a peer at this slot, sized close to ours by design', + 'It matches nothing because shape means nothing: bandwidth and placement vary with the analyzer span, not with the source', ], correctIndex: 0, - explanation: 'Accidents look like known things in the wrong place. Noise deliberately shaped and placed to deny a specific carrier looks like nothing the coordination database can account for - the absence of a benign match is itself evidence.', + explanation: + 'Accidents look like known things in the wrong place. Noise deliberately shaped and placed to deny a specific carrier looks like nothing the coordination database can account for - the absence of a benign match is itself evidence. Broadband noise shaped to our carrier inside our passband fits neither benign case.', pointPenalty: 5, }, mustMaintain: false, @@ -418,13 +417,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'If this is deliberate, what KIND of attack is RF jamming, and why does the distinction matter?', options: [ - 'It is a DENIAL attack on the RF layer - it degrades or blocks the signal but does not touch the data, crypto, or keys. The distinction matters because an intrusion (data-layer compromise) has a different escalation path and you must verify which you are facing', - 'It is an intrusion - jamming always implies the attacker has system access', - 'It is malware on the modem', - 'The distinction is academic - all attacks get the same response', + 'A DENIAL attack on the RF layer - it blocks the signal but never touches data, crypto, or keys; intrusion escalates differently', + 'An INTRUSION on the data layer - a jammer with this much control has system access; every jamming event escalates as a breach', + 'A MALWARE attack on the modem - the on/off cadence is a compromised firmware loop; crypto and keys must be assumed lost', + 'An ATTACK, and the class is academic - denial or intrusion, the response is the same; escalation does not change with the label', ], correctIndex: 0, - explanation: 'Jamming denies availability; it does not breach confidentiality or integrity. Conflating denial with intrusion sends you down the wrong escalation path - you must check the data layer to know which incident you actually have.', + explanation: + 'Jamming denies availability; it does not breach confidentiality or integrity. Conflating denial with intrusion sends you down the wrong escalation path - you must check the data layer to know which incident you actually have.', pointPenalty: 5, }, mustMaintain: false, @@ -476,13 +476,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'Crypto is ACTIVE, keys Valid, no frame anomalies outside the jamming windows. What does that confirm - and what would flip the incident?', options: [ - 'Confirms RF-layer denial only: the link is being degraded, not breached. If crypto state changed or keys went invalid/mismatched mid-incident, it would become a security incident with a different escalation (Dana then security officer; nothing holding keys gets power-cycled)', - 'Confirms nothing - crypto is unrelated to interference', - 'Confirms the interferer has been decrypted and identified', - 'Means the jamming has stopped', + 'Confirms RF denial only - degraded, not breached; a crypto change or invalid keys mid-incident flips it to a security incident', + 'Confirms nothing - crypto is a data-layer state; interference lives on the RF layer, so no crypto reading could flip the incident', + 'Confirms the interferer is identified - its frames decrypted under our keys; a key mismatch would flip it back to unknown source', + 'Confirms the jamming has stopped - frames pass clean when the noise is off; a return of the noise flips it back to an active incident', ], correctIndex: 0, - explanation: 'Two layers, two verifications, never conflated. Intact crypto bounds this to a denial-of-availability event. The hypothetical - a crypto change mid-incident - is exactly the tripwire that would escalate it to an intrusion.', + explanation: + 'Two layers, two verifications, never conflated. Intact crypto bounds this to a denial-of-availability event. The hypothetical - a crypto change mid-incident - is exactly the tripwire that would escalate it to an intrusion, with a different escalation: Dana, then the security officer, and nothing holding keys gets power-cycled.', pointPenalty: 5, }, mustMaintain: false, @@ -512,13 +513,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'The interferer is a band of noise ~12 MHz off the carrier center, inside our passband. Best receive-side mitigation?', options: [ - 'Notch filter on the interferer band - excise its energy at the cost of a little SNR in that slice; reversible, customer-preserving, and it does nothing to neighbors', - 'Increase our uplink power to "burn through" the interferer', - 'Retune the customer carrier to a clean frequency immediately', - 'Mute the receiver until the interferer stops', + 'Notch filter on the interferer band - excise its energy for a little SNR in that slice; reversible and customer-preserving', + 'Raise uplink power to "burn through" - out-drive the noise for a few dB of HPA headroom; fast and needs no receiver change', + 'Retune the customer carrier to a clean slot - move away from the noise for a coordination request; permanent and neighbor-safe', + 'Mute the receiver until the interferer stops - drop the noise at the cost of the carrier during each burst; simple and reversible', ], correctIndex: 0, - explanation: 'A notch is the surgical tool: it removes the interferer\'s energy from the receive path. Power escalation degrades neighbors, violates coordination, and feeds the incident - there is NO transmit-side mitigation for uplink jamming available to a ground station.', + explanation: + "A notch is the surgical tool: it removes the interferer's energy from the receive path. Power escalation degrades neighbors, violates coordination, and feeds the incident - there is NO transmit-side mitigation for uplink jamming available to a ground station.", pointPenalty: 5, preserveOptionOrder: true, }, @@ -600,13 +602,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'A colleague suggests cranking the uplink to overpower the interferer. Why is that wrong?', options: [ - 'You cannot out-shout an uplink jammer from the ground without violating coordination and degrading every neighbor on the transponder - a power war escalates the incident, harms bystanders, and still loses. Receive-side mitigation plus regulatory action is the path', - 'It would work but takes too long to ramp the HPA', - 'The customer contract forbids power changes', - 'It is fine - more power always improves the link', + 'It breaks coordination and degrades every neighbor on the transponder - a power war escalates the incident and still loses', + "It would work but the HPA cannot ramp fast enough - by the time the drive is up, the jammer's ninety-second burst is already over", + "It breaks the customer contract, which fixes uplink power - a power change needs SeaLink's sign-off before it can be commanded", + 'It is fine in principle, since more power always improves the link - the objection is only that it costs HPA lifetime', ], correctIndex: 0, - explanation: 'Burn-through is a denial response that creates more denial. The operator\'s tools are receive-side mitigation and the regulator; offense belongs to the spectrum authority and geolocation services, not the victim\'s HPA.', + explanation: + "Burn-through is a denial response that creates more denial: you cannot out-shout an uplink jammer from the ground, and trying harms bystanders. The operator's tools are receive-side mitigation and the regulator; offense belongs to the spectrum authority and geolocation services, not the victim's HPA.", pointPenalty: 5, }, mustMaintain: false, @@ -636,13 +639,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'Which set of fields makes a regulator interference report actionable?', options: [ - 'Victim (satellite/transponder/pol/center freq/BW), interferer (center freq, BW, measured duty cycle, first/last seen), spectrum captures at documented settings, cross-station confirmation (transponder vs local), service impact, and mitigation applied - all timestamped', - 'A one-line summary: "being jammed, please help"', - 'Only our internal trouble-ticket number', - 'The interferer\'s identity and location - which we determine ourselves', + 'Victim and interferer parameters (freq, BW, duty cycle), spectrum captures, cross-station confirmation, impact - all timestamped', + 'Interferer identity and location (operator, site, uplink dish), our ticket number, service impact - all determined here before filing', + 'A one-line summary (transponder, time, "being jammed"), our ticket number, a request for help - all the regulator needs to start', + 'Our internal ticket (number, opened time, owner), the notch settings, the customer name - all filed, details available on request', ], correctIndex: 0, - explanation: 'Per ITU-R practice the report is evidence, not conclusion: measured parameters, captures, cross-station confirmation, and impact - everything timestamped. Attribution (who, where) belongs to the regulator and geolocation providers, not the ground operator.', + explanation: + 'Per ITU-R practice the report is evidence, not conclusion: victim (satellite, transponder, pol, center frequency, bandwidth), interferer (center frequency, bandwidth, measured duty cycle, first and last seen), captures at documented settings, cross-station confirmation, impact, and mitigation applied - everything timestamped. Attribution (who, where) belongs to the regulator and geolocation providers, not the ground operator.', pointPenalty: 5, }, mustMaintain: false, @@ -666,15 +670,16 @@ export const scenario21Data: ScenarioData = { description: 'Finding Language', params: { character: Character.SYSTEM, - question: 'How should the operator\'s written finding be worded?', + question: "How should the operator's written finding be worded?", options: [ - '"Signature consistent with deliberate uplink interference" - a confidence-bounded characterization backed by the evidence; attribution and intent are for the regulator to determine, not the ground operator to assert', - '"Confirmed hostile jamming by [named actor]" - state it plainly', - '"Probably just interference, no action needed"', - 'Avoid any characterization - just file the raw data', + '"Signature consistent with deliberate uplink interference" - confidence-bounded; attribution is for the regulator, not us', + '"Confirmed hostile jamming by [named actor]" - state it plainly; the evidence is strong enough to name the source ourselves', + '"Probably just interference, no action needed" - keep it low-key; the regulator can characterize it if it ever returns', + '"Interference event, raw data attached" - no characterization; the regulator draws every conclusion from the captures', ], correctIndex: 0, - explanation: 'The evidence supports "consistent with deliberate" - it does not support naming an actor or declaring intent as fact. Disciplined language is what makes your evidence usable; overreach is what gets it dismissed.', + explanation: + 'The evidence supports "consistent with deliberate" - it does not support naming an actor or declaring intent as fact. Intent, like attribution, is for the regulator to determine. Disciplined language is what makes your evidence usable; overreach is what gets it dismissed.', pointPenalty: 5, }, mustMaintain: false, @@ -700,13 +705,14 @@ export const scenario21Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry records this correctly?', options: [ - 'TM-2 TP-1 interference: broadband noise ~1470 MHz IF, ~90s/60s duty cycle, confirmed uplink (seen VT-01 + Halifax). Data layer intact (crypto ACTIVE, keys Valid) - RF denial, not intrusion. Notch applied, carrier holding. Signature consistent with deliberate interference - regulator package filed. Monitoring; escalate on any data-layer change.', - 'TM-2 had some interference, applied a notch, fixed.', - 'Confirmed hostile jamming, link saved by burn-through.', - 'Intermittent RX issue, power-cycled LNB.', + 'TM-2 TP-1 interference: noise ~1470 MHz IF, 90s/60s duty, uplink-confirmed (VT-01 + Halifax); crypto intact - RF denial, notched', + 'TM-2 TP-1 interference: noise ~1470 MHz IF, 90s/60s duty, local source suspected (Maine only); LNB power-cycled - RX issue, cleared', + 'TM-2 TP-1 interference: hostile jamming confirmed, actor named, burned through (HPA raised); link saved - regulator not needed', + 'TM-2 TP-1 interference: some noise near the carrier, intermittent, source unknown (not chased); notch applied - fixed, no follow-up', ], correctIndex: 0, - explanation: 'Everything load-bearing: signature, duty cycle, the uplink confirmation, the intact data layer (denial not intrusion), the mitigation, the disciplined finding, and the escalation tripwire. The next shift inherits a complete, defensible picture.', + explanation: + 'Everything that matters: signature, duty cycle, the uplink confirmation, the intact data layer (crypto ACTIVE, keys Valid - denial not intrusion), the mitigation with the carrier holding, the disciplined finding (signature consistent with deliberate interference, regulator package filed), and the escalation tripwire (monitoring; escalate on any data-layer change). The next shift inherits a complete, defensible picture.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario22.ts b/src/campaigns/nats/scenario22.ts index d293c118..0537c038 100644 --- a/src/campaigns/nats/scenario22.ts +++ b/src/campaigns/nats/scenario22.ts @@ -54,14 +54,8 @@ export const scenario22Data: ScenarioData = { duration: '25-30 min', difficulty: 'intermediate', missionType: 'Analysis & Reporting', - description: `AURORA-7's beacon power has been declining for months - the power subsystem on a nineteen-year-old bird running out of watts - and the slope is steepening. The board wants a recommendation on retirement timing, from operations rather than from a spreadsheet.

Run one more data-collection pass this morning, then build the assessment. The Working Document panel is set up as the report template; it fills in as you make your calls. Francis Martin wants a recommendation he can forward without editing: how long the spacecraft can responsibly carry service, what degrades first, and what the migration window looks like.

Light on RF today. Heavy on reading a trend honestly and saying what you know without false precision.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'Working Document panel (the report)', - ], + description: `AURORA-7's beacon power has been declining for months - the power subsystem on a nineteen-year-old bird running out of watts - and the slope is steepening. The board wants a recommendation on retirement timing, from operations rather than from a spreadsheet.

Run one more data-collection pass this morning, then build the assessment. The Working Document panel is set up as the report template; it fills in as you make your calls. Francis Martin wants a recommendation he can forward without editing: how long the spacecraft can responsibly carry service, what degrades first, and what the migration window looks like.

Light on RF today. Heavy on reading a trend straight and saying what you know without false precision.`, + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'Working Document panel (the report)'], timeLimitSeconds: 30 * 60, settings: { isSync: true, @@ -89,7 +83,7 @@ export const scenario22Data: ScenarioData = { satellites: [aurora7Satellite, tidemark1Satellite], workingDocument: { title: 'AURORA-7 End-of-Life Assessment', - description: 'Operations recommendation for the SeaLink board - built from today\'s data run and trend analysis.', + description: "Operations recommendation for the SeaLink board - built from today's data run and trend analysis.", }, missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-22?content-only=true&dark=true', isExtraSatellitesVisible: true, @@ -102,7 +96,7 @@ export const scenario22Data: ScenarioData = { id: 'review-mission-brief', nice: ['K0645'], title: 'Review the Tasking', - description: 'Open the brief and Francis\'s request.', + description: "Open the brief and Francis's request.", groundStation: 'VT-01', freezesScenarioTimer: true, prerequisiteObjectiveIds: [], @@ -120,13 +114,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'Francis wants a retirement recommendation "from operations." What does that mean you must produce?', options: [ - 'A defensible recommendation grounded in measured data and an honest trend, with assumptions labeled - not a single date with false precision', - 'A precise retirement date to the day', - 'A decision to retire immediately to avoid risk', - 'A restatement of the vendor\'s end-of-life bulletin', + 'A defensible recommendation grounded in measured data and an honest trend, with assumptions labeled - not a single date', + 'A precise retirement date to the day, with the math shown - not a vague window the board cannot budget against', + 'A decision to retire now, with the risk stated - not a recommendation that leaves a failing bird in service for a quarter', + "A restatement of the vendor's end-of-life bulletin, with our data appended - not an operator's opinion on spacecraft health", ], correctIndex: 0, - explanation: 'The board can handle uncertainty; it cannot handle a confident number that unravels in a quarter. "From operations" means built from what the dish actually measures.', + explanation: + 'The board can handle uncertainty; it cannot handle a confident number that unravels in a quarter - a single date carries false precision. "From operations" means built from what the dish actually measures.', pointPenalty: 5, }, mustMaintain: false, @@ -221,8 +216,8 @@ export const scenario22Data: ScenarioData = { { id: 'measure-beacon', nice: ['T1429', 'K0773'], - title: 'Measure Today\'s Beacon', - description: 'Tune the spectrum analyzer to the beacon IF and capture today\'s level - the newest point on the trend.', + title: "Measure Today's Beacon", + description: "Tune the spectrum analyzer to the beacon IF and capture today's level - the newest point on the trend.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['engage-step-track'], timeLimitSeconds: 3 * 60, @@ -260,8 +255,8 @@ export const scenario22Data: ScenarioData = { { id: 'record-data-point-quiz', nice: ['T1429', 'K0740'], - title: 'Report: Today\'s Measurement', - description: 'Record today\'s data point in the assessment.', + title: "Report: Today's Measurement", + description: "Record today's data point in the assessment.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['measure-beacon'], timeLimitSeconds: 2 * 60, @@ -272,15 +267,16 @@ export const scenario22Data: ScenarioData = { description: 'Data Point', params: { character: Character.SYSTEM, - question: 'What goes into the report as today\'s measured data?', + question: "What goes into the report as today's measured data?", options: [ - 'Beacon at -4.0 dB relative to the 24-month reference; step-track held lock at this level; carrier C/N still above demod threshold - measured, not estimated', - 'Beacon is fine', - 'The satellite will fail soon', - 'Beacon power is declining (no number)', + 'Beacon at -4.0 dB relative to the 24-month reference; step-track held lock at this level; carrier C/N above demod threshold', + 'Beacon at -4.0 dB relative to the 24-month reference; step-track lost lock at this level; carrier C/N below demod threshold', + 'Beacon declining, no number needed at this stage; step-track engaged as usual; carrier C/N nominal per the link budget on file', + 'Beacon at -1.1 dB relative to the 12-month point; step-track held lock at this level; satellite will fail within the quarter', ], correctIndex: 0, - explanation: 'Measured values, stated as measured. The number with its reference and the fact that step-track still holds at this level - that is data, and the report is built on data first.', + explanation: + 'Measured values, stated as measured, not estimated. The number with its reference and the fact that step-track still holds at this level - that is data, and the report is built on data first.', pointPenalty: 5, documentSection: 'Data (measured today)', documentLine: 'Beacon -4.0 dB vs 24-mo reference. Step-track holds lock at this level. Carrier C/N above demod threshold.', @@ -312,13 +308,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'The history: -1.1 dB at 12 months, -2.4 at 6 months, -4.0 today. How do you characterize the trend?', options: [ - 'Accelerating - the last 6 months lost ~1.6 dB versus ~1.1 dB in the prior 6; a steepening curve, not a straight line, so naive linear extrapolation understates the near-term decline', - 'Linear and steady at about 0.3 dB/month', - 'Flattening - the decline is slowing', - 'Random - no meaningful trend', + 'Accelerating - the last 6 months lost ~1.6 dB against ~1.3 dB in the 6 before; a steepening curve, not a straight line', + 'Linear - roughly 0.3 dB per month from the 12-month point to today; a straight line, so extrapolation is safe a year out', + 'Flattening - the first 12 months lost 1.1 dB and the last 6 only 1.6 dB more; the decline is slowing as the array settles', + 'Random - three points a year apart are noise, not a trend; the -4.0 dB today could be rain fade on a single measurement', ], correctIndex: 0, - explanation: 'Reading acceleration vs linearity is the whole analysis. A power subsystem shedding load tends to worsen non-linearly; calling it linear would sell the board a longer runway than the spacecraft has.', + explanation: + 'Reading acceleration vs linearity is the whole analysis. Per six months the losses run 0.55, 1.3, then 1.6 dB - the rate is rising, so naive linear extrapolation understates the near-term decline. A power subsystem shedding load tends to worsen non-linearly; calling it linear would sell the board a longer runway than the spacecraft has.', pointPenalty: 5, documentSection: 'Trend', documentLine: 'Beacon decline ACCELERATING: -1.1 (12mo) -> -2.4 (6mo) -> -4.0 (now). Non-linear - linear extrapolation understates near-term.', @@ -346,16 +343,18 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'What is the binding constraint - the thing that ends usable service first?', options: [ - 'The beacon getting too weak to step-track. AURORA-7 is inclined, so without a trackable beacon every pass becomes manual figure-8 chasing and service quality collapses - the cliff is tracking, not transponder death', - 'The transponder ceasing to relay - the payload fails first', - 'The satellite de-orbiting', - 'Fuel exhaustion for station-keeping', + 'The beacon getting too weak to step-track - AURORA-7 is inclined, so without it every pass becomes manual figure-8 chasing', + 'The transponder ceasing to relay - the payload is the oldest hardware aboard, so the carrier drops before the beacon does', + 'The satellite de-orbiting - AURORA-7 is inclined because station-keeping ended, so the orbit decays before the power does', + 'Fuel exhaustion for station-keeping - the inclination is already growing, so the figure-8 outgrows the dish before the beacon fades', ], correctIndex: 0, - explanation: 'The S18/S23 lesson pays off: an inclined bird is only usable while step-track can hold its beacon. The recommendation hinges on when the declining beacon crosses the trackability floor, not on the transponder.', + explanation: + 'The S18/S23 lesson pays off: an inclined bird is only usable while step-track can hold its beacon, and service quality collapses once it cannot. The cliff is tracking, not transponder death. The recommendation hinges on when the declining beacon crosses the trackability floor, not on the transponder.', pointPenalty: 5, documentSection: 'Risk', - documentLine: 'Binding constraint = beacon trackability (inclined bird). When beacon too weak to step-track, every pass goes manual and quality collapses. NOT transponder death.', + documentLine: + 'Binding constraint = beacon trackability (inclined bird). When beacon too weak to step-track, every pass goes manual and quality collapses. NOT transponder death.', }, mustMaintain: false, }, @@ -367,7 +366,7 @@ export const scenario22Data: ScenarioData = { id: 'marcus-corroboration-quiz', nice: ['T1020', 'K1032'], title: 'Report: Vehicle Corroboration', - description: 'Fold in Halifax\'s spacecraft-side view of the power trend.', + description: "Fold in Halifax's spacecraft-side view of the power trend.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['binding-constraint-quiz'], timeLimitSeconds: 2 * 60, @@ -380,13 +379,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'Marcus confirms the vehicle bus is shedding RF load as the array ages. How does that affect confidence in the trend?', options: [ - 'It raises confidence and explains the mechanism: ground-measured beacon decline and spacecraft-reported power loss are the same story from two independent vantage points - the trend is real, not an artifact of our station', - 'It contradicts the ground data and lowers confidence', - 'It is irrelevant - only ground measurements matter', - 'It means the satellite is fine and the dish is faulty', + 'It raises confidence: ground-measured beacon decline and spacecraft-reported power loss agree from independent vantage points', + 'It lowers confidence: the spacecraft reports a bus problem, not a beacon problem, so the ground data and the telemetry disagree', + "It leaves confidence unchanged: only the ground measurement matters to the board, and Halifax's telemetry is not our data", + 'It shifts the blame: the satellite is fine and shedding load by design, so the beacon decline is our dish or LNB degrading', ], correctIndex: 0, - explanation: 'Two independent measurements agreeing on cause AND trend is the strongest evidence a report can carry. It also rules out "our antenna is degrading" - the spacecraft sees its own power dropping.', + explanation: + 'Two independent measurements agreeing on cause AND trend is the strongest evidence a report can carry - it explains the mechanism, and the trend is real, not an artifact of our station. It also rules out "our antenna is degrading" - the spacecraft sees its own power dropping.', pointPenalty: 5, documentSection: 'Trend', documentLine: 'Corroborated by Halifax: vehicle bus shedding RF load as array ages. Ground + spacecraft agree on cause and trend (independent vantage points).', @@ -418,17 +418,19 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'What is the responsible recommendation?', options: [ - 'Begin customer migration now; target sunset in roughly one to two quarters with a hard review at each monthly data point, and a firm decision trigger when the beacon crosses the step-track floor. A window with tripwires, not a date', - 'Retire immediately - the risk is unacceptable', - 'No action for a year - the link still works today', - 'Set a precise retirement date 8 months out', + 'Begin migration now; sunset in one to two quarters, reviewed monthly, with a firm trigger at the step-track floor', + 'Retire immediately; the risk is unacceptable at -4.0 dB, migration forced this month, with no window for the customers to plan', + 'Take no action for a year; the link still works today, reviewed at the next budget cycle, with a decision then if the beacon fades', + 'Set a retirement date 8 months out; the board can budget against a date, migration scheduled backward from it, with no review points', ], correctIndex: 0, - explanation: 'A window plus tripwires gives customers runway while protecting against the accelerating curve. "Begin migration now, decide at the trackability floor" is defensible because it is tied to a measurable event, not a guess.', + explanation: + 'A window plus tripwires, not a date: it gives customers runway while protecting against the accelerating curve. "Begin migration now, decide at the trackability floor" is defensible because it is tied to a measurable event, not a guess.', pointPenalty: 10, preserveOptionOrder: true, documentSection: 'Recommendation', - documentLine: 'Begin customer migration NOW. Target sunset ~1-2 quarters, monthly data review, firm trigger = beacon crosses step-track floor. Window with tripwires, not a fixed date.', + documentLine: + 'Begin customer migration NOW. Target sunset ~1-2 quarters, monthly data review, firm trigger = beacon crosses step-track floor. Window with tripwires, not a fixed date.', }, mustMaintain: false, }, @@ -453,13 +455,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'Which assumptions must the report state explicitly?', options: [ - 'The decline continues or steepens (no recovery), no single-event failure intervenes, and tracking - not the payload - is the binding constraint. If any breaks, the window changes', - 'None - the recommendation is certain', - 'Only that the customer will cooperate with migration', - 'That AURORA-7 was built to a 15-year design life', + 'The decline continues or steepens (no recovery), no single-event failure intervenes, and tracking is the binding constraint', + 'The customer cooperates with migration (no holdouts), procurement funds the replacement, and Francis accepts the window', + 'AURORA-7 was built to a 15-year design life (now exceeded), the vendor bulletin is accurate, and the array cannot recover', + 'None need stating (the data speaks for itself), the recommendation is certain, and a caveat would only weaken it at the board', ], correctIndex: 0, - explanation: 'A recommendation that names its assumptions can be defended and updated; one that hides them collapses the first time reality diverges. Stating them is what separates analysis from a guess in a suit.', + explanation: + 'A recommendation that names its assumptions can be defended and updated; one that hides them collapses the first time reality diverges. If any of the three breaks, the window changes. Stating them is what separates analysis from a guess in a suit.', pointPenalty: 5, documentSection: 'Assumptions', documentLine: 'Assumes: decline continues/steepens (no recovery), no single-event failure, tracking is the binding constraint. Window revises if any breaks.', @@ -487,13 +490,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'How should the report distinguish measured facts from projections?', options: [ - 'Explicitly: today\'s -4.0 dB and the historical points are measured; the sunset window is a projection from those points under stated assumptions. Label each so the board knows which is which', - 'Present everything with equal confidence so the recommendation sounds strong', - 'Only report measurements; omit the projection entirely', - 'Round the projection to a single date to look decisive', + "Explicitly: today's -4.0 dB and the historical points are measured; the sunset window is a projection under stated assumptions", + "Uniformly: today's -4.0 dB and the sunset window carry equal weight; a recommendation reads stronger with one confidence level", + "Conservatively: only today's -4.0 dB and the historical points go in; the projection is omitted until more data exists", + 'Decisively: the historical points are the basis; the projection is rounded to a single date so the board has something to act on', ], correctIndex: 0, - explanation: 'Measured and projected carry different weight; conflating them is how a report loses credibility in a quarter. The board can act on a clearly-labeled projection - it cannot trust a number it later learns was a guess dressed as a fact.', + explanation: + 'Measured and projected carry different weight; conflating them is how a report loses credibility in a quarter. Label each so the board knows which is which: it can act on a clearly-labeled projection - it cannot trust a number it later learns was a guess dressed as a fact.', pointPenalty: 5, }, mustMaintain: false, @@ -523,13 +527,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'What makes this assessment something Francis can forward without editing?', options: [ - 'It has data, a trend, the binding-constraint risk, a windowed recommendation with tripwires, and labeled assumptions - a board member can read it, understand the basis, and defend the decision to others', - 'It is short', - 'It concludes with a single confident date', - 'It avoids any mention of uncertainty', + 'It has data, a trend, the binding-constraint risk, a windowed recommendation with tripwires, and labeled assumptions', + 'It is short, fits on one page, leads with the recommendation, and leaves the supporting data in an appendix Francis can drop', + 'It ends on a single confident date, gives the board a number to budget against, and keeps the caveats out of the summary', + 'It avoids any mention of uncertainty, reads as settled, and cannot be picked apart by a board member looking for doubt', ], correctIndex: 0, - explanation: 'Forward-without-editing is the bar: complete enough to stand alone, honest enough to survive scrutiny, structured so the reasoning is visible. That is the difference between operations input and an opinion.', + explanation: + 'Forward-without-editing is the bar: complete enough to stand alone, honest enough to survive scrutiny, structured so the reasoning is visible - a board member can read it, understand the basis, and defend the decision to others. That is the difference between operations input and an opinion.', pointPenalty: 5, }, mustMaintain: false, @@ -555,13 +560,14 @@ export const scenario22Data: ScenarioData = { character: Character.SYSTEM, question: 'Which log entry closes the tasking?', options: [ - 'AURORA-7 EOL assessment delivered to board (Martin). Final data run complete (beacon -4.0 dB, step-track held). Trend accelerating, binding constraint = beacon trackability. Recommendation: begin migration now, sunset ~1-2 quarters, trigger at step-track floor. Assumptions stated. Measured vs projected labeled.', - 'EOL report sent to Francis.', - 'AURORA-7 retirement recommended.', - 'Data run complete, analysis pending.', + 'AURORA-7 EOL assessment to board: beacon -4.0 dB, trend accelerating; migrate now, sunset ~1-2 quarters, trigger at tracking floor', + 'AURORA-7 EOL report to Francis: beacon -4.0 dB, trend linear; no action this year, review at next budget cycle, retire when it fails', + 'AURORA-7 retirement recommended: beacon -4.0 dB, trend accelerating; retire immediately, migration forced, date set 8 months out', + 'AURORA-7 data run complete: beacon -4.0 dB, step-track held; analysis pending, trend to be characterized, board note to follow', ], correctIndex: 0, - explanation: 'The log mirrors the report\'s spine: data, trend, constraint, recommendation, assumptions. Anyone reading it later knows exactly what was delivered and on what basis.', + explanation: + "The log mirrors the report's spine: data (beacon -4.0 dB, step-track held), trend, binding constraint (beacon trackability), recommendation, assumptions stated, measured vs projected labeled - delivered via Martin. Anyone reading it later knows exactly what was delivered and on what basis.", pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario23.ts b/src/campaigns/nats/scenario23.ts index 0c66d5b0..0337718d 100644 --- a/src/campaigns/nats/scenario23.ts +++ b/src/campaigns/nats/scenario23.ts @@ -65,13 +65,7 @@ export const scenario23Data: ScenarioData = { difficulty: 'advanced', missionType: 'Contingency Operations', description: `The ACU automation processor faulted at 1358. Program-track, move-to-target, and step-track are all offline - the dish is sitting exactly where automation left it, which for now is on TIDEMARK-1. The servos still work; it's the brains that died, not the muscles.

IT owns the controller and has no ETA. You own the link. TIDEMARK-1 is GEO and well-behaved, so the pointing solution doesn't change - but with the automation's lock logic dead, the ACU panel can no longer tell you whether you're on the satellite. The beacon on the spectrum is your only proof.

Get into manual cleanly, prove you're on the bird, hold it there, and do not experiment with the broken automation while IT is mid-diagnosis.`, - equipment: [ - '9-meter C-band Antenna (manual servo control)', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'TIDEMARK-1 prediction sheet', - ], + equipment: ['9-meter C-band Antenna (manual servo control)', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'TIDEMARK-1 prediction sheet'], timeLimitSeconds: 30 * 60, settings: { isSync: true, @@ -133,13 +127,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'The ACU automation processor crashed. What is and is not working?', options: [ - 'Broken: program-track, step-track, move-to-target, lock logic (the automation brain). Working: servos, manual mode, and the RF chain - the dish can still move, you just have to fly it', - 'Everything is down - the antenna is frozen until IT reboots', - 'Only step-track is affected; program-track still works', - 'The RF chain is down; tracking is unaffected', + 'Broken: program-track, step-track, move-to-target, lock logic. Working: servos, manual mode, the RF chain - you have to fly it', + 'Broken: servos, manual mode, program-track, step-track. Working: the RF chain and the lock logic - the dish is frozen until IT reboots', + 'Broken: step-track only. Working: program-track, move-to-target, servos, the RF chain - the ephemeris still flies the dish', + 'Broken: the RF chain, receiver lock, beacon detect. Working: program-track, step-track, servos - tracking is unaffected', ], correctIndex: 0, - explanation: 'Separating the failed subsystem from the healthy ones IS the bypass plan. The muscles work; the brain that usually aims them does not. You become the brain.', + explanation: + 'Separating the failed subsystem from the healthy ones IS the bypass plan. The automation brain is dead; the muscles work, so the dish can still move. You become the brain.', pointPenalty: 5, }, mustMaintain: false, @@ -196,13 +191,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'The ACU automation fault alarm is up. Before touching anything, what do you record?', options: [ - 'Current position (Az 161.8 / El 34.2 on TM-1) and that beacon + carrier are still locked - the dish is ON the bird; whatever you do next must not lose that', - 'Nothing - immediately switch to manual', - 'The ACU serial number for the warranty claim', - 'A full spectrum sweep of every transponder', + 'Current position (Az 161.8 / El 34.2 on TM-1) and that beacon + carrier are still locked - the dish is ON the bird', + 'Nothing yet (the alarm is time-stamped by IT) and switch to manual at once - every second in faulted program-track risks the bird', + 'The ACU serial and firmware (for ticket NOC-2026-2231) and the alarm text - IT needs the warranty details before they touch it', + 'A full spectrum sweep (every TM-1 transponder) and the noise floor - the fault could be RF, and the sweep proves it is not', ], correctIndex: 0, - explanation: 'The single most valuable asset right now is that the dish is already pointed correctly. Record it before you move, so if a manual input goes wrong you know exactly where home was.', + explanation: + 'The single most valuable asset right now is that the dish is already pointed correctly, and whatever you do next must not lose that. Record it before you move, so if a manual input goes wrong you know exactly where home was.', pointPenalty: 5, }, mustMaintain: false, @@ -228,13 +224,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'Which functions does the operator now have to perform by hand?', options: [ - 'Compute the pointing solution (from the prediction sheet), drive the axes manually, and judge lock from the spectrum - the ACU lock indicator is part of the dead automation and cannot be trusted', - 'Only the slewing - the lock indicator still works', - 'Nothing extra - manual mode automates pointing differently', - 'Re-derive the orbit from first principles', + 'Compute pointing (from the prediction sheet), drive the axes by hand, and judge lock from the spectrum - not the ACU indicator', + 'Slew the axes (from the ACU panel), watch the polar plot, and wait for the lock indicator - the lock logic is separate from the crash', + 'Select manual (from the mode buttons), enter the target, and let manual mode point it - manual automates the pointing differently', + 'Re-derive the orbit (from first principles), compute the ephemeris, and drive the axes - the prediction sheet came from the dead ACU', ], correctIndex: 0, - explanation: 'The dangerous assumption is trusting the ACU lock light - it is driven by the failed processor. The spectrum analyzer (beacon at the right IF) is the only pointing truth source that does not depend on the broken thing.', + explanation: + 'The dangerous assumption is trusting the ACU lock light - it is driven by the failed processor. The spectrum analyzer (beacon at the right IF) is the only pointing truth source that does not depend on the broken thing.', pointPenalty: 5, }, mustMaintain: false, @@ -260,13 +257,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'Why is "go manual and hold" the right move instead of power-cycling the ACU yourself?', options: [ - 'A reboot can return with stale/defaulted axis calibration and destroys the crash state IT needs for root cause - bypass keeps the link up AND preserves the evidence; IT cycles it on their schedule', - 'Rebooting takes too long', - 'The operator is not authorized to use the power switch', - 'A reboot would drop the customer, which manual avoids - and nothing else matters', + 'A reboot risks defaulted axis calibration and wipes the crash state IT needs - bypass keeps the link up and keeps the evidence', + "A reboot takes too long - the ACU cold-start and self-test run past the customer's tolerance, and manual is faster to reach", + "A reboot needs authorization the operator does not hold - the power switch is IT's, and touching it puts the ticket on you", + 'A reboot would drop the customer, which manual avoids - that is the whole reason, and nothing about the box itself matters', ], correctIndex: 0, - explanation: 'Same discipline as the S8/S16 LNB lesson scaled up: you cycle equipment you own; you coordinate cycles on equipment someone else is debugging. The calibration risk is the technical reason; preserving crash state is the investigative one.', + explanation: + 'Same discipline as the S8/S16 LNB lesson scaled up: you cycle equipment you own; you coordinate cycles on equipment someone else is debugging - IT cycles it on their schedule. The calibration risk is the technical reason; preserving crash state for root cause is the investigative one.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -324,13 +322,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'Why deliberately select MANUAL rather than leave it in program-track with dead automation?', options: [ - 'In program-track the panel implies an automation is flying the dish when none is - manual makes the truth explicit: YOU are the controller, the displays mean what they say, and there is no phantom loop to fight', - 'Manual mode is more accurate than program-track', - 'Program-track will resume on its own and undo your inputs', - 'There is no difference - the label is cosmetic', + 'In program-track the panel implies a loop is flying the dish when none is - manual makes YOU the controller, explicitly', + 'In program-track the dead loop will resume on its own and undo your inputs - manual locks it out until IT clears the fault', + 'In program-track the servo resolution is coarser than the hand controls - manual is the more accurate mode for a fine hold', + 'In program-track nothing is different once the automation is dead - manual is a label change, and the panel behaves the same', ], correctIndex: 0, - explanation: 'A half-failed automatic state is the worst of both worlds: it looks like something is in control and nothing is. Honest manual removes the ambiguity - every control does exactly what it says.', + explanation: + 'A half-failed automatic state is the worst of both worlds: it looks like something is in control and nothing is. Honest manual removes the ambiguity - the displays mean what they say, every control does exactly what it says, and there is no phantom loop to fight.', pointPenalty: 5, }, mustMaintain: false, @@ -425,13 +424,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'After any manual pointing input during this fault, what confirms you are still on the satellite?', options: [ - 'The beacon at 1074.5 MHz on the spectrum and receiver lock - the RF truth, independent of the dead ACU automation. Never the ACU lock indicator', - 'The ACU lock indicator turning green', - 'The polar plot showing the dish on target', - 'That the dish stopped moving', + 'The beacon at 1074.5 MHz on the spectrum and receiver lock - the RF truth, independent of the dead ACU automation', + 'The ACU lock indicator turning green and the polar plot on target - the lock logic reads the RF chain, which is healthy', + 'The polar plot on target and Az/El matching the prediction sheet - the position readouts come from the servos, not the processor', + 'The dish stopped moving and the axes holding still - a GEO bird does not move, so a still dish is a pointed dish', ], correctIndex: 0, - explanation: 'The ACU lock light is computed by the failed processor - it is exactly the instrument you cannot trust. The beacon and receiver lock come from the RF chain, which is healthy. Fly the instruments that still work.', + explanation: + 'The ACU lock light is computed by the failed processor - it is exactly the instrument you cannot trust, so never use it. The beacon and receiver lock come from the RF chain, which is healthy. Fly the instruments that still work.', pointPenalty: 5, }, mustMaintain: false, @@ -488,13 +488,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'Manual holding works for this shift. What makes it feasible, and when would it NOT be?', options: [ - 'TIDEMARK-1 is a well-behaved GEO bird - it barely moves over hours, so a fixed manual point holds. It would NOT work for an inclined bird like AURORA-7, whose figure-8 needs the step-track this fault disabled', - 'Manual always works regardless of the satellite', - 'It works because the LNB compensates for drift', - 'It only works at night', + "TIDEMARK-1 is a well-behaved GEO bird that barely moves, so a fixed point holds - NOT on AURORA-7's inclined figure-8", + 'Manual always works, since the servos are the muscles and they are healthy - NOT dependent on which satellite is the target', + 'The LNB compensates for drift, so the receiver keeps lock as the bird wanders - NOT possible on a receiver without AGC', + 'It only works at night, when the sun is off the array and the bird stops drifting - NOT during daytime thermal cycling', ], correctIndex: 0, - explanation: 'The same fault on AURORA-7 would be a genuine crisis - no step-track means chasing a figure-8 by hand. TIDEMARK-1\'s stability is what turns "automation failure" into "annoying but holdable."', + explanation: + 'The same fault on AURORA-7 would be a genuine crisis - no step-track means chasing a figure-8 by hand. TIDEMARK-1\'s stability is what turns "automation failure" into "annoying but holdable."', pointPenalty: 5, }, mustMaintain: false, @@ -520,13 +521,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'IT wants to attempt the ACU recovery now. What does the ground operator coordinate?', options: [ - 'Confirm the link is stable on manual first, agree a window, expect to re-establish program-track and re-verify pointing AFTER recovery - and be ready to fall back to manual if the restart misbehaves', - 'Tell IT to wait until end of shift', - 'Let IT proceed without any ground coordination - it is their system', - 'Switch back to program-track now to help IT test', + 'Confirm the link is stable on manual, agree a window, then re-verify pointing on RF after recovery - ready to fall back to manual', + 'Tell IT to wait, hold manual until end of shift, then hand the recovery to the next operator - never restart a box mid-shift', + 'Let IT proceed, keep flying manual, then trust program-track when the panel comes back - it is their system and their call', + 'Switch back to program-track now, let IT watch the loop, then confirm on the lock indicator - a live test beats a cold restart', ], correctIndex: 0, - explanation: 'You drive the link; IT drives the box. The link must be on a stable footing (manual, verified) before the box gets touched, and re-verified on the RF after - never assume a recovered automation is pointed correctly just because it says so.', + explanation: + 'You drive the link; IT drives the box. The link must be on a stable footing (manual, verified) before the box gets touched, and program-track re-established and re-verified on the RF after - never assume a recovered automation is pointed correctly just because it says so.', pointPenalty: 5, }, mustMaintain: false, @@ -552,13 +554,14 @@ export const scenario23Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry records the bypass correctly?', options: [ - 'ACU automation fault 1358 (IT NOC-2026-2231). Bypassed to manual 1404 - TM-1 held on prediction-sheet pointing, beacon + carrier verified on spectrum (ACU lock indicator NOT trusted, driven by failed processor). Link nominal under manual. Controller NOT rebooted - crash state preserved for IT. Recovery to be re-verified on RF post-restart.', - 'ACU broke, switched to manual, link fine.', - 'Automation fault - rebooted ACU, back to program-track.', - 'IT issue, no operator action required.', + 'ACU fault 1358 (NOC-2026-2231); manual 1404, TM-1 held on sheet pointing, beacon + carrier verified on RF; not rebooted', + 'ACU fault 1358 (NOC-2026-2231); rebooted 1404, program-track restored, lock indicator green; link fine, ticket closed', + 'ACU fault 1358 (NOC-2026-2231); manual 1404, link fine, no further action; IT issue, nothing for operations to record', + 'ACU fault 1358 (NOC-2026-2231); manual 1404, TM-1 held on ACU lock indicator, polar plot on target; rebooted for IT', ], correctIndex: 0, - explanation: 'The load-bearing details for the next shift and for IT: pointing source, that the lock light was distrusted, that the controller was deliberately NOT rebooted, and that recovery needs RF re-verification.', + explanation: + 'The details the next shift and IT need: the pointing source (prediction sheet), that the lock light was distrusted (driven by the failed processor), that the link is nominal under manual, that the controller was deliberately NOT rebooted (crash state preserved), and that recovery needs RF re-verification post-restart.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario24.ts b/src/campaigns/nats/scenario24.ts index 25f666eb..93f28013 100644 --- a/src/campaigns/nats/scenario24.ts +++ b/src/campaigns/nats/scenario24.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Character, Emotion } from '@app/modal/character-enum'; @@ -6,7 +7,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz, IfFrequency, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { aurora7Satellite, ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -69,13 +69,7 @@ export const scenario24Data: ScenarioData = { difficulty: 'advanced', missionType: 'Incident Command', description: `Everything at once. A storm front tracks onto Vermont inside the hour. Maine's BUC is running hot - the same unit, the same signature as the thermal trend you caught months ago, and the swap never cleared procurement. AURORA-7 has a SeaLink sync pass booked at 0800 on a beacon four dB weaker than the day you qualified. James is already calling. Francis wants a board note by end of morning.

Five tracks. Three are clocks you don't control - the storm, the pass window, the board deadline. Two are trends you can bend - the BUC heat, the customer's confidence.

You've done every piece of this before. Today you do all of it, in the right order, while everyone watches. Incident command is yours. Dana is your resource, not your safety net. This is the exam - fly it like a Tuesday.`, - equipment: [ - '9-meter C-band Antennas (VT-01 + ME-02)', - 'RF Front Ends (both)', - 'Spectrum Analyzers', - 'RX/TX Modems', - 'Incident-command log (Working Document)', - ], + equipment: ['9-meter C-band Antennas (VT-01 + ME-02)', 'RF Front Ends (both)', 'Spectrum Analyzers', 'RX/TX Modems', 'Incident-command log (Working Document)'], timeLimitSeconds: 50 * 60, settings: { isSync: true, @@ -233,13 +227,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'Five concurrent tracks, you hold incident command. What is the first thing command requires?', options: [ - 'Order the board before touching anything: separate the clocks you do not control (storm ETA, pass window, board deadline) from the trends you can bend (BUC heat, customer confidence), and sequence your attention accordingly', - 'Start fixing the loudest alarm immediately', - 'Escalate everything to Dana since it exceeds one operator', - 'Hand all traffic to whichever station is healthiest and wait out the morning', + 'Order the board first: fixed clocks (storm ETA, pass window, board note) versus bendable trends (BUC heat, customer confidence)', + 'Work the loudest alarm first: the ME-02 BUC (pre-alarm, climbing) beats anything still forecast (storm ETA, pass window)', + 'Escalate to Dana first: five tracks (storm, BUC, pass, customer, board) exceed one operator (single console, one pair of hands)', + 'Consolidate first: hand all traffic to the healthiest station (ME-02 or VT-01) and wait out the morning (storm, pass, board note)', ], correctIndex: 0, - explanation: 'Command is sequencing, not speed. The S16/S20 triage discipline scaled to five tracks: know which clocks are fixed and which trends you own before you spend a single action.', + explanation: + 'Command is sequencing, not speed. The S16/S20 triage discipline scaled to five tracks: know which clocks are fixed and which trends you own before you spend a single action, then sequence your attention accordingly.', pointPenalty: 10, documentSection: 'Command', documentLine: 'IC assumed. Board ordered: fixed clocks = storm ETA / AURORA pass / board note; bendable trends = ME-02 BUC heat / customer confidence.', @@ -267,13 +262,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'Storm hits VT-01 in ~4 minutes; ME-02 BUC is hot but pre-alarm; AURORA pass is at 0800. What order?', options: [ - 'Protect against the fixed clock first: enable VT-01 heater NOW (one switch, beats the front), then de-rate the ME-02 BUC trend, then set up the AURORA pass before its window - acting on the soonest fixed deadline first, cheap protections before expensive ones', - 'AURORA pass first because customers are watching', - 'ME-02 BUC first because thermal could damage hardware', - 'Whichever customer is loudest on the phone', + 'VT-01 heater NOW (one switch, beats the front), then de-rate the ME-02 BUC, then set up the AURORA pass before its 0800 window', + 'AURORA pass first (customers are watching), then the ME-02 BUC, then the VT-01 heater once the front is actually overhead', + 'ME-02 BUC first (thermal can damage hardware), then the VT-01 heater, then the AURORA pass if the window is still open', + 'Whoever is loudest first (James is on the phone), then the VT-01 heater, then the ME-02 BUC, then the AURORA pass at 0800', ], correctIndex: 0, - explanation: 'The storm has the nearest fixed deadline and the cheapest protection (one heater switch). Start it, then work the bendable BUC trend, then stage the pass before its window. Sequence by deadline and cost, not by volume.', + explanation: + 'The storm has the nearest fixed deadline and the cheapest protection (one heater switch). Start it, then work the bendable BUC trend, then stage the pass before its window. Sequence by deadline and cost, not by volume.', pointPenalty: 10, documentSection: 'Command', documentLine: 'Sequence: (1) VT-01 heater before front, (2) ME-02 BUC de-rate, (3) AURORA pass setup before 0800.', @@ -342,7 +338,7 @@ export const scenario24Data: ScenarioData = { id: 'select-maine', nice: ['S0421'], title: 'Open ME-02', - description: 'Heater\'s running unattended at Vermont. Now the BUC trend at Maine.', + description: "Heater's running unattended at Vermont. Now the BUC trend at Maine.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['enable-heater-proactive'], timeLimitSeconds: 1 * 60, @@ -382,13 +378,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'ME-02 BUC: gain 10 dB over operating, temperature climbing, current elevated, pre-alarm. What is this and what is the fix?', options: [ - 'The same over-gain thermal trend from before - the swap that never cleared procurement. Fix is the de-rate: gain back to the 23 dB operating value, which cuts the dissipation at the source', - 'A new BUC hardware failure requiring immediate swap and customer outage', - 'HPA overdrive bleeding heat into the BUC', - 'Ambient temperature in the Maine equipment room', + 'The same over-gain thermal trend as before (swap still pending) - fix is the de-rate, gain back to the 23 dB operating value', + 'A new BUC hardware failure (not the earlier trend) - fix is an immediate swap, with a TM-2 customer outage while it is done', + 'HPA overdrive bleeding heat into the BUC (shared chassis) - fix is the HPA back-off, up to 10 dB, with the BUC gain left alone', + 'Ambient temperature in the Maine equipment room (HVAC) - fix is facilities, with the BUC gain left at 33 dB until the room cools', ], correctIndex: 0, - explanation: 'Pattern recognition is the payoff of the campaign: you diagnosed this exact signature once. The de-rate addresses the cause without taking the customer down - and you log that the swap is still pending.', + explanation: + 'Pattern recognition is the payoff of the campaign: you diagnosed this exact signature once, and the swap never cleared procurement. The de-rate cuts the dissipation at the source without taking the customer down - and you log that the swap is still pending.', pointPenalty: 5, documentSection: 'ME-02 / TM-2', documentLine: 'BUC over-gain thermal trend (recurrence - swap still pending procurement). Action: de-rate to 23 dB operating.', @@ -556,13 +553,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'Step-track is holding the weak beacon and C/N is marginal-but-adequate. What is the call to Marcus?', options: [ - 'GO with a caveat: step-track holding, margin thin on an end-of-life beacon; proceed with the pass but flag that AURORA is on its sunset trajectory - and deliver that BEFORE 0800, not mid-pass', - 'NO-GO - the beacon is too weak, scrub it', - 'GO silently - do not worry the customer with margin details', - 'Defer the decision until the pass is already underway', + 'GO with a caveat: step-track holding, margin thin on an end-of-life beacon; flag the sunset trajectory to Marcus BEFORE 0800', + 'NO-GO with a scrub: step-track holding but margin thin on an end-of-life beacon; tell Marcus BEFORE 0800 that the pass is off', + "GO with no caveat: step-track holding, margin adequate for the pass; keep the margin and sunset detail off Marcus's plate", + 'GO with a deferred call: step-track holding, margin to be judged in the pass; brief Marcus mid-pass once the numbers settle', ], correctIndex: 0, - explanation: 'Honest go: the link supports the pass now, the operator says so, and flags the EOL reality (tying back to the S22 recommendation). Communication timing matters - the caveat before the window, not an excuse during it.', + explanation: + 'Honest go: the link supports the pass now, the operator says so, and flags the EOL reality (tying back to the S22 recommendation). Communication timing matters - the caveat before the window, not an excuse during it, and never mid-pass.', pointPenalty: 5, documentSection: 'VT-01 / AURORA-7', documentLine: 'Pass GO (caveat: thin margin, EOL beacon per sunset rec). Step-track holding. Marcus briefed pre-window.', @@ -594,13 +592,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'James wants status on TM-1 (storm) and TM-2 (BUC). What do you give him, and how often?', options: [ - 'Per-trunk cause/action/next-update, in thirty seconds each: TM-1 protected ahead of the storm (heater on, holding), TM-2 stabilized (BUC de-rated, no impact) - with a committed next-update time so he stops calling and you keep working', - 'A full technical briefing on both faults now', - 'Tell him to wait until everything is resolved', - 'Route him to Dana - customer comms is above incident command', + 'Per-trunk cause/action/next-update, thirty seconds each: TM-1 protected (heater on), TM-2 stabilized (BUC de-rated, no impact)', + 'A full technical briefing on both faults now: TM-1 storm dynamics (radar, ice rate), TM-2 BUC thermals (gain, current, dissipation)', + 'Nothing until both are resolved: TM-1 still under the front (heater on), TM-2 still cooling (BUC de-rated) - he waits for the all-clear', + 'A hand-off to Dana for both trunks: TM-1 storm status (her call), TM-2 BUC status (her call) - customer comms sit above command', ], correctIndex: 0, - explanation: 'Command keeps the customer channel short and scheduled: cause, action, next update. A committed update time converts an escalating caller into a manageable one - protecting your attention for the tracks.', + explanation: + 'Command keeps the customer channel short and scheduled: cause, action, next update. A committed next-update time converts an escalating caller into a manageable one - he stops calling, you keep working, and your attention stays on the tracks.', pointPenalty: 5, documentSection: 'Customer (SeaLink/James)', documentLine: 'James briefed per-trunk: TM-1 storm-protected (heater on), TM-2 BUC de-rated (no impact). Next update committed.', @@ -628,13 +627,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'The front is over Vermont now. Because you enabled the heater early, what is the situation?', options: [ - 'The feed stays clear as fast as ice tries to form - no accumulation, link holding; the proactive heater turned a potential outage into a non-event you only have to monitor', - 'Vermont is icing and needs an emergency handover', - 'The heater should be cycled off to save power now that the storm is here', - 'The storm requires a repoint to a clearer-sky satellite', + 'The feed stays clear as fast as ice tries to form - no accumulation, link holding; the outage never happens, monitor only', + 'The feed is icing faster than the heater can clear it - accumulation building, link sagging; an emergency handover to ME-02 is due', + 'The feed is clear and the front is overhead - heater no longer needed, power to save; cycle it off until the ice actually starts', + 'The feed is clear but the sky is not - rain fade on TM-1, margin dropping; a repoint to a clearer-sky satellite is the next move', ], correctIndex: 0, - explanation: 'The whole point of beating the fixed clock: protection in place before the threat means the threat never becomes an incident. This is the S14/S20 lesson paying its dividend under maximum load.', + explanation: + 'The whole point of beating the fixed clock: protection in place before the threat means the threat never becomes an incident - the proactive heater turned a potential outage into a non-event you only have to monitor. This is the S14/S20 lesson paying its dividend under maximum load.', pointPenalty: 5, documentSection: 'VT-01 / TM-1', documentLine: 'Storm over VT-01: no ice accumulation (heater pre-enabled). TM-1 holding, monitor-only.', @@ -662,16 +662,18 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'What goes in the one-paragraph board note for Francis?', options: [ - 'Posture, exposure, action: both stations stable through a concurrent storm + thermal-trend morning, no customer outage; AURORA pass delivered on a sunsetting beacon (reinforces the migration recommendation); residual risk = the pending ME-02 BUC swap. One paragraph, board-level, no jargon', - 'A detailed technical timeline of every action taken', - 'Just "all systems nominal"', - 'A request for more staff', + 'Posture and exposure: both stations stable, no outage; AURORA pass flown on a sunsetting beacon; residual risk = ME-02 BUC swap', + 'Timeline and procedure: every action time-stamped, heater then de-rate then pass; each switch and setting listed; jargon as logged', + 'Status only: all systems nominal; no detail on the storm, the BUC, or the pass; nothing for the board to worry about or act on', + 'Staffing and cost: five tracks exceeded one operator; a second console seat requested; the BUC swap deferred to next budget', ], correctIndex: 0, - explanation: 'Board-level means posture and exposure, not procedure. The note also does institutional work: the AURORA pass on a dying beacon reinforces the S22 sunset case, and the pending BUC swap is named as the residual risk the board controls (procurement).', + explanation: + 'Board-level means posture and exposure, not procedure: one paragraph, no jargon. The note also does institutional work: the AURORA pass on a dying beacon reinforces the S22 sunset case, and the pending BUC swap is named as the residual risk the board controls (procurement).', pointPenalty: 10, documentSection: 'Board Note (Martin)', - documentLine: 'Both stations stable through concurrent storm + BUC thermal event, zero customer outage. AURORA pass delivered on EOL beacon (reinforces sunset rec). Residual risk: pending ME-02 BUC swap (procurement).', + documentLine: + 'Both stations stable through concurrent storm + BUC thermal event, zero customer outage. AURORA pass delivered on EOL beacon (reinforces sunset rec). Residual risk: pending ME-02 BUC swap (procurement).', }, mustMaintain: false, }, @@ -700,13 +702,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'What makes this command log a complete after-action record?', options: [ - 'It shows the ordered board, the sequence and why, each track\'s action and outcome, the customer and board comms, and the residual risk - someone could pick up your shift cold and know exactly what happened and what is still open', - 'It lists every button pressed in order', - 'It concludes that everything is fine', - 'It is brief enough to read in ten seconds', + "It shows the ordered board, the sequence and why, each track's action and outcome, the comms, and the residual risk", + 'It lists every button pressed in order, each with a time-stamp, every setting before and after, and the alarm text verbatim', + 'It concludes that everything is fine, states the outage count as zero, closes every track, and leaves nothing open for the next shift', + 'It is brief enough to read in ten seconds, leads with the outcome, skips the reasoning, and points to the tickets for detail', ], correctIndex: 0, - explanation: 'The campaign\'s final discipline, scaled to its largest case: the log IS the work made legible. Ordered board, reasoned sequence, per-track outcomes, comms, open risk. The same shape as a good shift log - just holding five tracks at once.', + explanation: + "The campaign's final discipline, scaled to its largest case: the log IS the work made legible. Ordered board, reasoned sequence, per-track outcomes, customer and board comms, open risk - someone could pick up your shift cold and know exactly what happened and what is still open. The same form as a good shift log, just holding five tracks at once.", pointPenalty: 5, }, mustMaintain: false, @@ -732,13 +735,14 @@ export const scenario24Data: ScenarioData = { character: Character.SYSTEM, question: 'Which entry closes the incident?', options: [ - 'Constellation crisis worked under single-operator incident command: VT-01 storm protected (proactive heater, no ice), ME-02 BUC thermal trend de-rated (no outage), AURORA SeaLink pass delivered on EOL beacon, customers briefed per-trunk, board note filed. Zero customer outage across five concurrent tracks. Residual: ME-02 BUC swap pending. IC closed.', - 'Busy morning, everything handled.', - 'Multiple faults, all fixed, see individual tickets.', - 'Crisis averted.', + 'IC closed: VT-01 storm protected, ME-02 BUC de-rated, AURORA pass delivered; zero outage, five tracks; residual = ME-02 BUC swap', + 'IC closed: VT-01 storm handled, ME-02 BUC handled, AURORA pass handled; busy morning, all fine; residual = none, see individual tickets', + 'IC closed: VT-01 storm outage averted, ME-02 BUC swapped, AURORA pass scrubbed; one outage, five tracks; residual = none', + 'IC closed: VT-01 heater on, ME-02 gain 23 dB, AURORA step-track on; crisis averted, five tracks; residual = details on request', ], correctIndex: 0, - explanation: 'The finale entry: five tracks, one operator, zero customer outage, one open risk named. That sentence is the whole campaign - every skill, held together at once, made legible for whoever comes next.', + explanation: + 'The finale entry: five tracks, one operator, zero customer outage, one open risk named - proactive heater and no ice at VT-01, thermal trend de-rated with no outage at ME-02, the SeaLink pass on an EOL beacon, customers briefed per-trunk, board note filed. That sentence is the whole campaign - every skill, held together at once, made legible for whoever comes next.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario3.ts b/src/campaigns/nats/scenario3.ts index 691b4d87..c71ddeac 100644 --- a/src/campaigns/nats/scenario3.ts +++ b/src/campaigns/nats/scenario3.ts @@ -1,5 +1,6 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; -import { ANTENNA_CONFIG_KEYS } from "@app/equipment/antenna/antenna-config-keys"; +import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Receiver } from '@app/equipment/receiver/receiver'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; @@ -8,7 +9,6 @@ import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBi, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType, RfFrequency } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite } from './satellites'; @@ -70,13 +70,7 @@ export const scenario3Data: ScenarioData = { difficulty: 'beginner', missionType: 'Emergency Operations', description: `Heavy snow is already falling on Vermont - the link margin to TIDEMARK-1 is degrading fast. You've got maybe 15 minutes before the signal drops below operational threshold.

Your job: bring the Maine backup station online, configure it to match Vermont's parameters exactly, verify signal acquisition, and execute a clean traffic handover before Vermont goes dark. Catherine from Maine is on her way in and will take over once the handover is complete.

This is your first time managing multiple ground stations simultaneously. Weather handovers happen several times each winter in the Northeast - routine procedure, but time-critical. Don't rush, but don't dawdle either.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Available', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Available'], timeLimitSeconds: 30 * 60, // 30 minutes settings: { isSync: true, @@ -134,45 +128,46 @@ export const scenario3Data: ScenarioData = { { isVisible: false, isUpdating: false, mode: 'clearwrite' }, ], selectedTrace: 1, - } + }, + ], + transmitters: [ + { + activeModem: 1, + modems: [ + { + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: false, + isTransmittingSwitchUp: false, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: 'TIDEMARK-1-Teleport', + serverId: 1, + noradId: 61525, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: 1094e6 as IfFrequency, + power: -7 as dBm, + bandwidth: 36e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '1/2' as FECType, + }, + }, + ], + }, ], - transmitters: [{ - activeModem: 1, - modems: [{ - isPowered: true, - antenna_id: 1, - modem_number: 1, - isFaulted: false, - isTransmitting: false, - isTransmittingSwitchUp: false, - isFaultSwitchUp: false, - id: 1, - isLoopback: false, - ifSignal: { - signalId: 'TIDEMARK-1-Teleport', - serverId: 1, - noradId: 61525, - polarization: 'V', - feed: '', - isDegraded: false, - origin: SignalOrigin.TRANSMITTER, - noiseFloor: null, - gainInPath: 0 as dBi, - frequency: 1094e6 as IfFrequency, - power: -7 as dBm, - bandwidth: 36e6 as Hertz, - modulation: 'QPSK' as ModulationType, - fec: '1/2' as FECType, - }, - }], - }], receivers: [Receiver.getDefaultState()], }, ], - satellites: [ - tidemark1Satellite, - ses10Satellite, - ], + satellites: [tidemark1Satellite, ses10Satellite], weatherEvents: [ { id: 'vermont-blizzard', @@ -182,13 +177,13 @@ export const scenario3Data: ScenarioData = { startTime: 5, // 5 seconds into scenario - urgency! duration: 7200, // 2 hours linkMarginDegradation: 8, // dB - exceeds acceptable threshold - } + }, ], trafficOwnership: [ { satelliteNoradId: 61525, // TIDEMARK-1 initialOwnerId: 'VT-01', // Vermont initially owns traffic - } + }, ], missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-3?content-only=true&dark=true', isExtraSatellitesVisible: true, @@ -219,9 +214,7 @@ export const scenario3Data: ScenarioData = { description: 'Ready to Proceed', params: { question: 'Have you reviewed the mission brief and weather handover procedures?', - options: [ - 'Yes, I have read the mission brief and I am ready to proceed.', - ], + options: ['Yes, I have read the mission brief and I am ready to proceed.'], correctIndex: 0, explanation: 'The mission timer has started. Snow is already falling - move quickly but carefully.', pointPenalty: 0, @@ -339,7 +332,8 @@ export const scenario3Data: ScenarioData = { 'Equipment Protection → Safety → Customer Impact → Efficiency', ], correctIndex: 0, - explanation: "Safety always comes first - protecting personnel from RF hazards or other dangers. Next is customer impact - maintaining service. Then equipment protection - preventing damage. Finally, efficiency - doing things the optimal way. This framework guides what to address first when multiple concerns compete for attention - it's about prioritization, not about sacrificing lower priorities.", + explanation: + "Safety always comes first - protecting personnel from RF hazards or other dangers. Next is customer impact - maintaining service. Then equipment protection - preventing damage. Finally, efficiency - doing things the optimal way. This framework guides what to address first when multiple concerns compete for attention - it's about prioritization, not about sacrificing lower priorities.", pointPenalty: 10, }, mustMaintain: false, @@ -368,13 +362,14 @@ export const scenario3Data: ScenarioData = { params: { question: 'What would happen if the feed heater was turned off during this snow event?', options: [ - 'Ice would accumulate on the feed horn and waveguide, causing signal attenuation and potential physical damage', - 'The LNB would cool down and produce more noise', - 'The antenna motors would freeze and stop tracking', - 'Snow would build up on the dish reflector', + 'Ice would build up on the feed horn and waveguide and attenuate the signal', + 'The LNB would cool below spec and its noise temperature would rise', + 'The antenna drive motors would freeze and the antenna would stop tracking', + 'Snow would build up on the dish reflector and block the signal path', ], correctIndex: 0, - explanation: 'Without the heater, ice would accumulate on the feed horn and waveguide. This causes signal attenuation (making the weather degradation even worse) and can physically damage the feed assembly. The heater prevents ice from forming on these critical RF components.', + explanation: + 'Without the heater, ice would accumulate on the feed horn and waveguide. This causes signal attenuation (making the weather degradation even worse) and can physically damage the feed assembly. The heater prevents ice from forming on these critical RF components.', pointPenalty: 10, }, mustMaintain: false, @@ -441,13 +436,14 @@ export const scenario3Data: ScenarioData = { params: { question: 'What would happen if the AGC was disabled during this weather event?', options: [ - 'The output signal level would drop as weather attenuated the input, eventually causing loss of lock', - 'The receiver would overheat from trying to process a weak signal', - 'The LNB would automatically increase its own gain to compensate', - 'Nothing - AGC only matters for clear weather conditions', + 'The output level would fall with the attenuated input until the receiver lost lock', + 'The receiver would overheat while trying to process the weakened input signal', + 'The LNB would raise its own gain automatically to make up for the attenuation', + 'Nothing would change because AGC only matters in clear weather conditions', ], correctIndex: 0, - explanation: 'Without AGC, the output level would drop proportionally as the snow attenuates the input signal. Once the signal falls below the demodulation threshold, the receiver loses lock and data is lost. AGC compensates by automatically increasing gain to maintain a stable output level - but it has limits.', + explanation: + 'Without AGC, the output level would drop proportionally as the snow attenuates the input signal. Once the signal falls below the demodulation threshold, the receiver loses lock and data is lost. AGC compensates by automatically increasing gain to maintain a stable output level - but it has limits.', pointPenalty: 10, }, mustMaintain: false, @@ -475,13 +471,14 @@ export const scenario3Data: ScenarioData = { params: { question: 'I mentioned you have about six minutes before the link fails. What makes weather handovers so time-critical?', options: [ - 'Weather degradation is progressive - once AGC runs out of compensation range, the link fails rapidly', - 'The antenna motors slow down in cold weather and take longer to move', - 'Customer data must be backed up before switching sites', - 'Maine operators need time to physically travel to the station', + 'Degradation is progressive - once the AGC runs out of range the link fails fast', + 'Antenna motors slow in the cold - Maine takes longer to slew onto the satellite', + 'Customer data must be backed up - the transfer has to finish before the switch', + 'Maine operators are remote - they need time to physically reach the station', ], correctIndex: 0, - explanation: 'Weather degradation is continuous and progressive. The AGC compensates up to a point, but once it maxes out, any further signal loss causes rapid link failure. There\'s no graceful degradation - you either have enough margin or you don\'t. This is why we start the handover process well before the predicted failure point.', + explanation: + "Weather degradation is continuous and progressive. The AGC compensates up to a point, but once it maxes out, any further signal loss causes rapid link failure. There's no graceful degradation - you either have enough margin or you don't. This is why we start the handover process well before the predicted failure point.", pointPenalty: 10, }, mustMaintain: false, @@ -511,12 +508,13 @@ export const scenario3Data: ScenarioData = { question: 'The AGC is compensating for the weather degradation. Why do we still need to hand over to Maine?', options: [ 'AGC has a maximum gain limit - once reached, further signal loss cannot be compensated', - 'AGC uses too much power during heavy compensation', - 'AGC introduces phase errors that corrupt the data', - 'Maine has a bigger antenna with more gain', + 'AGC uses too much power - during heavy compensation, the receiver may overheat', + 'AGC introduces phase errors - as gain rises, the demodulator output gets corrupted', + 'Maine has a bigger antenna - with more gain, its link has margin Vermont lacks', ], correctIndex: 0, - explanation: 'AGC can only compensate within its gain range. The forecast predicts 8+ dB of degradation - once the AGC hits its maximum gain, any further signal loss will cause C/N to drop below the demodulation threshold and we lose lock. Maine is 150 miles away with clear weather, so their link is unaffected.', + explanation: + 'AGC can only compensate within its gain range. The forecast predicts 8+ dB of degradation - once the AGC hits its maximum gain, any further signal loss will cause C/N to drop below the demodulation threshold and we lose lock. Maine is 150 miles away with clear weather, so their link is unaffected.', pointPenalty: 10, }, mustMaintain: false, @@ -571,12 +569,13 @@ export const scenario3Data: ScenarioData = { question: 'You switched to Maine in the asset tree. What is happening to Vermont right now?', options: [ 'Vermont continues operating normally - customers are still being served from VT-01', - 'Vermont has been placed in standby mode until we switch back', - 'Vermont is now being controlled by Maine operators', - 'Vermont traffic has been automatically paused', + 'Vermont has dropped to standby - customers are held until we switch back to VT-01', + 'Vermont is now under Maine control - customers are being served from ME-02', + 'Vermont traffic is automatically paused - customers wait until the handover', ], correctIndex: 0, - explanation: 'Switching your view to Maine does not affect Vermont operations. VT-01 continues serving customer traffic normally. You are simply changing which station\'s equipment panels you see and can control. Both stations operate independently.', + explanation: + "Switching your view to Maine does not affect Vermont operations. VT-01 continues serving customer traffic normally. You are simply changing which station's equipment panels you see and can control. Both stations operate independently.", pointPenalty: 10, }, mustMaintain: false, @@ -655,7 +654,8 @@ export const scenario3Data: ScenarioData = { 'Warming up - need to wait for stabilization', ], correctIndex: 0, - explanation: 'The GPSDO shows locked status, meaning it has GPS satellite lock and is providing a stable 10 MHz reference. All RF equipment in the rack depends on this reference for frequency accuracy.', + explanation: + 'The GPSDO shows locked status, meaning it has GPS satellite lock and is providing a stable 10 MHz reference. All RF equipment in the rack depends on this reference for frequency accuracy.', pointPenalty: 10, }, mustMaintain: false, @@ -681,15 +681,16 @@ export const scenario3Data: ScenarioData = { type: 'status-check', description: 'Understand GPSDO Weather Independence', params: { - question: 'The snow is degrading Vermont\'s TIDEMARK-1 link. Why isn\'t the snow affecting Vermont\'s GPSDO?', + question: "The snow is degrading Vermont's TIDEMARK-1 link. Why isn't the snow affecting Vermont's GPSDO?", options: [ 'GPS uses L-band frequencies (~1.5 GHz) which are less affected by precipitation than C-band', - 'The GPSDO antenna is indoors, protected from weather', - 'GPS satellites are in a different part of the sky than TIDEMARK-1', - 'The GPSDO has a backup battery that maintains lock during weather', + 'The GPSDO antenna is indoors (in the equipment rack) and sheltered from precipitation', + 'GPS satellites sit in a different part of the sky (nearly overhead) than TIDEMARK-1', + 'The GPSDO has a backup battery (holdover) that keeps it locked through the weather', ], correctIndex: 0, - explanation: 'GPS operates at L-band (~1.5 GHz), which experiences much less rain/snow attenuation than C-band (~4-6 GHz). The TIDEMARK-1 link uses C-band, which is more susceptible to precipitation fade. This is why the GPSDO remains locked even as the satellite link degrades.', + explanation: + 'GPS operates at L-band (~1.5 GHz), which experiences much less rain/snow attenuation than C-band (~4-6 GHz). The TIDEMARK-1 link uses C-band, which is more susceptible to precipitation fade. This is why the GPSDO remains locked even as the satellite link degrades.', pointPenalty: 10, }, mustMaintain: false, @@ -738,7 +739,7 @@ export const scenario3Data: ScenarioData = { // understanding program-track mode for GEO satellites nice: ['S0421', 'K1032'], title: 'Point Antenna at TIDEMARK-1', - description: 'Set tracking mode to PROGRAM TRACK to acquire TIDEMARK-1. The system will calculate the correct look angles for Maine\'s location.', + description: "Set tracking mode to PROGRAM TRACK to acquire TIDEMARK-1. The system will calculate the correct look angles for Maine's location.", groundStation: 'ME-02', prerequisiteObjectiveIds: ['navigate-acu-maine'], timeLimitSeconds: 3 * 60, @@ -746,7 +747,7 @@ export const scenario3Data: ScenarioData = { timePenalty: { elapsedTimeThreshold: 8 * 60, // 8 minutes pointsDeducted: 20, - message: 'Vermont\'s link margin is getting critical. Speed up.', + message: "Vermont's link margin is getting critical. Speed up.", }, conditions: [ { @@ -782,7 +783,7 @@ export const scenario3Data: ScenarioData = { // K0689: Knowledge of network infrastructure principles and practices - // understanding geographic diversity in ground station networks nice: ['K1032', 'K0689'], - title: 'Catherine\'s Sanity Check', + title: "Catherine's Sanity Check", description: 'Catherine has arrived at the Maine station and is checking your work.', groundStation: 'ME-02', prerequisiteObjectiveIds: ['configure-maine-antenna'], @@ -791,17 +792,18 @@ export const scenario3Data: ScenarioData = { conditions: [ { type: 'status-check', - description: 'Acknowledge Catherine\'s Check', + description: "Acknowledge Catherine's Check", params: { - question: "I see you used program-track mode. Good choice. Do you know why I was checking the antenna pointing?", + question: 'I see you used program-track mode. Good choice. Do you know why I was checking the antenna pointing?', options: [ - 'Because look angles to a satellite depend on the ground station\'s geographic location', - 'Because the antenna might have been damaged during storage', - 'Because program-track mode sometimes points at the wrong satellite', - 'Because Maine uses a different antenna model than Vermont', + "Because look angles to a satellite depend on the ground station's geographic location", + 'Because the antenna might have been damaged while it was sitting in storage', + 'Because program-track mode sometimes locks onto the wrong satellite by mistake', + 'Because Maine uses a different antenna model than Vermont with its own offsets', ], correctIndex: 0, - explanation: 'Each ground station has unique look angles to any given satellite based on its latitude and longitude. Maine is about 150 miles from Vermont, so the azimuth and elevation are slightly different. Program-track mode calculates this automatically, but a common mistake for new operators is manually entering Vermont\'s angles at Maine.', + explanation: + "Each ground station has unique look angles to any given satellite based on its latitude and longitude. Maine is about 150 miles from Vermont, so the azimuth and elevation are slightly different. Program-track mode calculates this automatically, but a common mistake for new operators is manually entering Vermont's angles at Maine.", pointPenalty: 10, character: Character.CATHERINE_VEGA, }, @@ -915,15 +917,16 @@ export const scenario3Data: ScenarioData = { type: 'status-check', description: 'Understand LNB Matching', params: { - question: 'Why must Maine\'s LNB LO frequency match Vermont\'s exactly?', + question: "Why must Maine's LNB LO frequency match Vermont's exactly?", options: [ - 'Same LO frequency produces the same IF frequency, so downstream equipment configuration is identical', - 'Different LO frequencies would cause interference between the two sites', - 'The satellite requires all ground stations to use the same LO frequency', - 'It\'s just company policy for consistency', + 'Same LO gives the same IF, so downstream equipment configuration is identical', + 'Different LOs would produce different IFs, so the two sites would interfere', + 'The satellite expects one LO across all ground stations, so it enforces a match', + 'It is company policy for consistency, so every site gets the same LO', ], correctIndex: 0, - explanation: 'With the same LO frequency (5,250 MHz), the TIDEMARK-1 beacon at 4,175.5 MHz RF produces the same 1,074.5 MHz IF at both sites. This means the spectrum analyzer, receiver modem, and all downstream equipment use identical frequency settings, simplifying handover and reducing configuration errors.', + explanation: + 'With the same LO frequency (5,250 MHz), the TIDEMARK-1 beacon at 4,175.5 MHz RF produces the same 1,074.5 MHz IF at both sites. This means the spectrum analyzer, receiver modem, and all downstream equipment use identical frequency settings, simplifying handover and reducing configuration errors.', pointPenalty: 10, }, mustMaintain: false, @@ -1051,13 +1054,14 @@ export const scenario3Data: ScenarioData = { params: { question: 'The antenna is in program-track mode, which calculates pointing from ephemeris data. Why did we still verify the beacon on the spectrum analyzer?', options: [ - 'Beacon confirms the entire receive chain is working - antenna, feed, LNB, cables, and spectrum analyzer', - 'Program-track mode only works after the beacon is acquired', - 'The beacon is needed to calibrate the spectrum analyzer', - 'Company policy requires visual beacon confirmation', + 'Beacon proves the whole receive chain works - antenna, feed, LNB, cables, analyzer', + 'Program-track needs the beacon to engage - the ACU, once locked, holds the pointing', + 'The beacon calibrates the analyzer - reference level, span, and center frequency', + 'Company policy requires a visual check - checklist item, sign-off, nothing technical', ], correctIndex: 0, - explanation: 'Seeing the beacon confirms more than just antenna pointing - it proves the entire receive path is functional: antenna feed is clear, LNB is downconverting correctly, cables are connected, and the spectrum analyzer is configured properly. Program-track could have the antenna pointed perfectly, but if the LNB was misconfigured or a cable was disconnected, you\'d never see the signal.', + explanation: + "Seeing the beacon confirms more than just antenna pointing - it proves the entire receive path is functional: antenna feed is clear, LNB is downconverting correctly, cables are connected, and the spectrum analyzer is configured properly. Program-track could have the antenna pointed perfectly, but if the LNB was misconfigured or a cable was disconnected, you'd never see the signal.", pointPenalty: 10, }, mustMaintain: false, @@ -1145,13 +1149,14 @@ export const scenario3Data: ScenarioData = { params: { question: 'Why must the receiver modem parameters (frequency, bandwidth, modulation, FEC) match Vermont exactly?', options: [ - 'Both sites are receiving the same satellite carrier - mismatched parameters would fail to demodulate', - 'The satellite checks that all ground stations use identical parameters', - 'Different parameters would cause interference between the two ground stations', - 'It\'s easier to copy settings than calculate new ones', + 'Both sites receive the same carrier - mismatched parameters would fail to demodulate', + 'The satellite checks every ground station - mismatched parameters would be rejected', + 'The two sites share one transponder - mismatched parameters would interfere', + 'Copying settings is faster - calculating new parameters would delay the handover', ], correctIndex: 0, - explanation: 'TIDEMARK-1 is transmitting a single carrier with specific characteristics. Any ground station receiving that carrier must configure their modem to match those characteristics exactly - wrong frequency misses the signal, wrong bandwidth captures noise, wrong modulation/FEC produces garbage data. This isn\'t about coordination between ground stations; it\'s about matching what the satellite is actually transmitting.', + explanation: + "TIDEMARK-1 is transmitting a single carrier with specific characteristics. Any ground station receiving that carrier must configure their modem to match those characteristics exactly - wrong frequency misses the signal, wrong bandwidth captures noise, wrong modulation/FEC produces garbage data. This isn't about coordination between ground stations; it's about matching what the satellite is actually transmitting.", pointPenalty: 10, }, mustMaintain: false, @@ -1223,12 +1228,13 @@ export const scenario3Data: ScenarioData = { question: 'The modem shows "Locked" status. Why do we also verify the C/N ratio is above 10 dB?', options: [ 'Lock can occur at low C/N but with high error rates - we need margin for reliable data', - 'The lock indicator doesn\'t work below 10 dB C/N', - '10 dB is the minimum for the modem to power on', - 'C/N below 10 dB would damage the modem', + 'The lock indicator stops working below 10 dB C/N - we need it to read the status', + '10 dB is the minimum for the modem to power on - we need it to keep the modem running', + 'C/N below 10 dB can damage the modem front end - we need margin to protect hardware', ], correctIndex: 0, - explanation: 'A modem can achieve lock at C/N ratios as low as 3-4 dB for QPSK, but error rates would be high and the link fragile. We want at least 10 dB of margin - that means even if weather degrades the Maine link somewhat, we still have headroom before errors become a problem. Lock without margin is asking for trouble.', + explanation: + 'A modem can achieve lock at C/N ratios as low as 3-4 dB for QPSK, but error rates would be high and the link fragile. We want at least 10 dB of margin - that means even if weather degrades the Maine link somewhat, we still have headroom before errors become a problem. Lock without margin is asking for trouble.', pointPenalty: 10, }, mustMaintain: false, @@ -1284,7 +1290,7 @@ export const scenario3Data: ScenarioData = { timePenalty: { elapsedTimeThreshold: 15 * 60, // 15 minutes pointsDeducted: 30, - message: 'Vermont\'s link has degraded significantly. The handover should have been complete by now.', + message: "Vermont's link has degraded significantly. The handover should have been complete by now.", }, conditions: [ { @@ -1398,13 +1404,14 @@ export const scenario3Data: ScenarioData = { params: { question: 'What happens when you execute the traffic handover from Vermont to Maine?', options: [ - 'Maine\'s transmitter activates fully while Vermont\'s is disabled - avoiding dual uplinks to the satellite', - 'Both stations transmit simultaneously and the satellite selects the stronger signal', - 'Customer connections are dropped and re-established through Maine', - 'Vermont\'s antenna is automatically pointed away from the satellite', + "Maine's transmitter comes up while Vermont's is disabled - avoiding a dual uplink", + 'Both stations transmit at once - the satellite selects the stronger of the two uplinks', + 'Customer connections are dropped - each one is re-established through Maine', + "Vermont's antenna is slewed away from the satellite - pointing prevents a dual uplink", ], correctIndex: 0, - explanation: 'The handover process coordinates the transition: Maine\'s uplink chain (BUC, HPA) is fully enabled while Vermont\'s is disabled in a controlled sequence. This prevents dual uplinks (two ground stations transmitting on the same frequency to the same satellite), which would cause interference. The satellite transponder doesn\'t care which ground station is transmitting - it just relays what it receives.', + explanation: + "The handover process coordinates the transition: Maine's uplink chain (BUC, HPA) is fully enabled while Vermont's is disabled in a controlled sequence. This prevents dual uplinks (two ground stations transmitting on the same frequency to the same satellite), which would cause interference. The satellite transponder doesn't care which ground station is transmitting - it just relays what it receives.", pointPenalty: 10, }, mustMaintain: false, @@ -1468,12 +1475,13 @@ export const scenario3Data: ScenarioData = { question: 'How do you confirm the handover was successful?', options: [ 'Traffic indicator shows ME-02 as active, VT-01 TX disabled, no alarms, continuous data flow', - 'Vermont\'s antenna has automatically stowed', - 'The satellite has acknowledged the handover', - 'Maine\'s C/N ratio has increased', + 'Traffic indicator shows ME-02 as active, VT-01 antenna stowed, no alarms, data paused', + 'Traffic indicator shows both active, satellite acknowledged, no alarms, data flowing', + 'Traffic indicator shows VT-01 as active, ME-02 C/N increased, no alarms, data flowing', ], correctIndex: 0, - explanation: 'A successful handover shows Maine as the active traffic owner, Vermont\'s transmitter disabled (no dual uplink), no error alarms, and continuous data flow with no packet loss. The customers should experience no interruption - from their perspective, nothing changed.', + explanation: + "A successful handover shows Maine as the active traffic owner, Vermont's transmitter disabled (no dual uplink), no error alarms, and continuous data flow with no packet loss. The customers should experience no interruption - from their perspective, nothing changed.", pointPenalty: 10, }, mustMaintain: false, @@ -1594,12 +1602,13 @@ export const scenario3Data: ScenarioData = { question: 'Why does pointing the antenna straight up (90° elevation) protect it during a blizzard?', options: [ 'Minimizes wind loading on the dish and prevents snow from accumulating in the reflector', - 'Gets the antenna above the snow level', - 'Prevents the feed horn from getting wet', - 'Reduces electrical interference from the storm', + 'Raises the dish above the snow level and keeps the reflector edge clear of drifts', + 'Keeps the feed horn dry and prevents meltwater from running into the waveguide', + 'Reduces electrical interference from the storm and shields the LNB from lightning', ], correctIndex: 0, - explanation: 'At 90° elevation (straight up), the dish presents minimal surface area to horizontal winds, dramatically reducing wind loading on the structure. Additionally, snow cannot accumulate in the reflector when it\'s vertical - it simply falls off. A dish pointed at typical satellite elevation (30-40°) would catch snow like a bowl and the wind would push against the full dish area.', + explanation: + "At 90° elevation (straight up), the dish presents minimal surface area to horizontal winds, dramatically reducing wind loading on the structure. Additionally, snow cannot accumulate in the reflector when it's vertical - it simply falls off. A dish pointed at typical satellite elevation (30-40°) would catch snow like a bowl and the wind would push against the full dish area.", pointPenalty: 10, }, mustMaintain: false, @@ -1625,14 +1634,10 @@ export const scenario3Data: ScenarioData = { description: 'Documentation Requirements', params: { question: 'What information should be logged for this weather handover event?', - options: [ - 'Time of degradation onset and handover completion', - 'Affected satellite and services', - 'Primary and backup station identifiers', - 'All of the above', - ], + options: ['Time of degradation onset and handover completion', 'Affected satellite and services', 'Primary and backup station identifiers', 'All of the above'], correctIndex: 3, - explanation: 'Complete event documentation includes: timestamps for degradation and handover, affected assets, stations involved, weather conditions, and any anomalies observed. This information is critical for post-incident review and pattern analysis.', + explanation: + 'Complete event documentation includes: timestamps for degradation and handover, affected assets, stations involved, weather conditions, and any anomalies observed. This information is critical for post-incident review and pattern analysis.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -1645,12 +1650,13 @@ export const scenario3Data: ScenarioData = { question: 'Why is documenting routine handover events important?', options: [ 'Enables pattern analysis and improves future response procedures', - 'Required only for customer billing purposes', - 'Only necessary if something went wrong', - 'Documentation is optional for weather events', + 'Required only for customer billing and service-credit calculations', + 'Only necessary when something went wrong during the handover itself', + 'Optional for weather events since they are outside operator control', ], correctIndex: 0, - explanation: 'Even routine events should be documented. Over time, this data reveals patterns - which sites are most affected by weather, average handover times, seasonal trends. This drives infrastructure improvements and procedure refinements.', + explanation: + 'Even routine events should be documented. Over time, this data reveals patterns - which sites are most affected by weather, average handover times, seasonal trends. This drives infrastructure improvements and procedure refinements.', pointPenalty: 5, }, mustMaintain: false, @@ -2187,4 +2193,4 @@ export const scenario3Data: ScenarioData = { }, }, }, -}; \ No newline at end of file +}; diff --git a/src/campaigns/nats/scenario4.ts b/src/campaigns/nats/scenario4.ts index c4e5d391..b2c808ae 100644 --- a/src/campaigns/nats/scenario4.ts +++ b/src/campaigns/nats/scenario4.ts @@ -1,10 +1,10 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; import type { ScenarioData } from '@app/ScenarioData'; import type { dBm, Hertz, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { maineGroundStation, vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite } from './satellites'; @@ -59,13 +59,7 @@ export const scenario4Data: ScenarioData = { difficulty: 'beginner', missionType: 'Operations Phase', description: `ME-02 is maintaining primary communications with TIDEMARK-1. The spacecraft operations team in Halifax has just confirmed that TIDEMARK-2 has completed station-keeping at 45°W and the communications payload is ready for ground operations.

Your task at VT-01 is to switch from monitoring TIDEMARK-1 to establishing full uplink and downlink with TIDEMARK-2. You'll need to repoint the antenna, acquire the new beacon, reconfigure the modems for the new frequencies, and bring up the transmit path.

Marcus Chen from Halifax spacecraft ops will confirm payload status. Take your time - ME-02 has primary coverage while you complete the switchover.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Available', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Available'], settings: { isSync: true, groundStations: [ @@ -83,10 +77,10 @@ export const scenario4Data: ScenarioData = { modems: [ { ...vermontGroundStation.receivers[0].modems[0], - fec: "1/2", + fec: '1/2', }, ], - } + }, ], transmitters: [ { @@ -97,7 +91,7 @@ export const scenario4Data: ScenarioData = { isTransmitting: false, }, ], - } + }, ], }, { @@ -106,15 +100,11 @@ export const scenario4Data: ScenarioData = { name: maineGroundStation.name, location: maineGroundStation.location, isOperational: true, - } + }, ], missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-4?content-only=true&dark=true', isExtraSatellitesVisible: true, - satellites: [ - tidemark1Satellite, - ses10Satellite, - tidemark2Satellite - ], + satellites: [tidemark1Satellite, ses10Satellite, tidemark2Satellite], }, objectives: [ // ============================================================ @@ -144,9 +134,7 @@ export const scenario4Data: ScenarioData = { description: 'Ready to Proceed', params: { question: 'Have you reviewed the mission brief and are you ready to begin?', - options: [ - 'Yes, I have read the mission brief and I am ready to proceed.', - ], + options: ['Yes, I have read the mission brief and I am ready to proceed.'], correctIndex: 0, explanation: 'The mission timer has started. Good luck!', pointPenalty: 0, @@ -232,14 +220,10 @@ export const scenario4Data: ScenarioData = { description: 'Current Satellite Identified', params: { question: 'What satellite is VT-01 currently tracking?', - options: [ - 'TIDEMARK-1', - 'TIDEMARK-2', - 'SES-10', - 'None - antenna is stowed', - ], + options: ['TIDEMARK-1', 'TIDEMARK-2', 'SES-10', 'None - antenna is stowed'], correctIndex: 0, - explanation: 'VT-01 is currently tracking TIDEMARK-1. The antenna is pointed at Az: 161.9°, El: 34.2° with beacon lock confirmed. We need to switch to TIDEMARK-2 at Az: 219.7°, El: 26.3°.', + explanation: + 'VT-01 is currently tracking TIDEMARK-1. The antenna is pointed at Az: 161.9°, El: 34.2° with beacon lock confirmed. We need to switch to TIDEMARK-2 at Az: 219.7°, El: 26.3°.', pointPenalty: 5, }, mustMaintain: false, @@ -280,7 +264,8 @@ export const scenario4Data: ScenarioData = { 'Stow - the antenna is parked and needs to be unstowed first', ], correctIndex: 0, - explanation: 'The antenna is in program-track mode, following TIDEMARK-1\'s predicted position from ephemeris data. To switch to TIDEMARK-2, we need to command new coordinates. The ACU will calculate the slew path and move the antenna smoothly to the new position.', + explanation: + "The antenna is in program-track mode, following TIDEMARK-1's predicted position from ephemeris data. To switch to TIDEMARK-2, we need to command new coordinates. The ACU will calculate the slew path and move the antenna smoothly to the new position.", pointPenalty: 5, }, mustMaintain: false, @@ -349,13 +334,14 @@ export const scenario4Data: ScenarioData = { params: { question: 'The antenna slewed about 58° in azimuth and dropped 8° in elevation. Why such a large change?', options: [ - 'TIDEMARK-2 is at a different orbital slot (45°W vs 53°W), requiring different look angles from Vermont', - 'The antenna was incorrectly pointed at TIDEMARK-1 before', - 'TIDEMARK-2 has a lower orbit than TIDEMARK-1', - 'Wind pushed the antenna off-target during the slew', + 'TIDEMARK-2 sits at a different orbital slot (45°W vs 53°W), so the look angles differ', + 'The antenna was mispointed at TIDEMARK-1 before (off-peak), so the slew corrected it', + 'TIDEMARK-2 is in a lower orbit than TIDEMARK-1 (sub-GEO), so it sits lower in the sky', + 'Wind pushed the antenna off target during the slew (gusts), so it overshot the slot', ], correctIndex: 0, - explanation: 'TIDEMARK-1 sits at 53°W and TIDEMARK-2 is at 45°W - that\'s 8 degrees of orbital separation. From Vermont\'s perspective, this translates to about 58° of azimuth change and 8° of elevation change. Different satellites require different pointing angles even when both are GEO.', + explanation: + "TIDEMARK-1 sits at 53°W and TIDEMARK-2 is at 45°W - that's 8 degrees of orbital separation. From Vermont's perspective, this translates to about 58° of azimuth change and 8° of elevation change. Different satellites require different pointing angles even when both are GEO.", pointPenalty: 5, }, mustMaintain: false, @@ -418,15 +404,16 @@ export const scenario4Data: ScenarioData = { type: 'status-check', description: 'Calculate IF Frequency', params: { - question: 'TIDEMARK-2\'s beacon transmits at 4,180 MHz RF. With the LNB LO at 5,250 MHz, what IF frequency should you see the beacon at?', + question: "TIDEMARK-2's beacon transmits at 4,180 MHz RF. With the LNB LO at 5,250 MHz, what IF frequency should you see the beacon at?", options: [ '1,070 MHz (LO minus RF = 5,250 - 4,180)', '9,430 MHz (LO plus RF = 5,250 + 4,180)', - '4,180 MHz (same as RF)', - '1,074.5 MHz (same as TIDEMARK-1 beacon)', + '1,074.5 MHz (LO minus RF = 5,250 - 4,175.5)', + '2,090 MHz (RF divided by two = 4,180 / 2)', ], correctIndex: 0, - explanation: 'The LNB downconverts by mixing with the local oscillator. IF = LO - RF = 5,250 - 4,180 = 1,070 MHz. Note this is slightly different from TIDEMARK-1\'s beacon at 1,074.5 MHz IF - each satellite has its own beacon frequency.', + explanation: + "The LNB downconverts by mixing with the local oscillator. IF = LO - RF = 5,250 - 4,180 = 1,070 MHz. Note this is slightly different from TIDEMARK-1's beacon at 1,074.5 MHz IF - each satellite has its own beacon frequency.", pointPenalty: 10, }, mustMaintain: false, @@ -552,7 +539,8 @@ export const scenario4Data: ScenarioData = { 'Neither - beacon is independent of ground equipment', ], correctIndex: 2, - explanation: 'A stable beacon confirms both: (1) the antenna is pointed at the correct satellite, and (2) the LNB LO frequency is set correctly to downconvert the beacon RF to the expected IF. If either were wrong, you would not see the beacon at the expected frequency.', + explanation: + 'A stable beacon confirms both: (1) the antenna is pointed at the correct satellite, and (2) the LNB LO frequency is set correctly to downconvert the beacon RF to the expected IF. If either were wrong, you would not see the beacon at the expected frequency.', pointPenalty: 5, }, mustMaintain: false, @@ -580,13 +568,14 @@ export const scenario4Data: ScenarioData = { params: { question: 'We verified the beacon on the spectrum analyzer. Why is this important before configuring the receiver modem?', options: [ - 'It proves the entire RF path is working - antenna feed, LNB, cables, and signal routing - so we know modem issues would be modem configuration, not upstream problems', - 'The modem cannot lock without first seeing the beacon', - 'The beacon automatically configures the modem frequency', - 'It\'s just a procedural requirement with no technical purpose', + 'It proves the RF path works - feed, LNB, cables - so a modem fault would be configuration', + 'The modem needs the beacon to lock - it acquires carrier, then symbol timing from it', + 'The beacon configures the modem - center frequency, bandwidth are read from the analyzer', + 'It is a procedural requirement - a checklist step, sign-off - with no technical purpose', ], correctIndex: 0, - explanation: 'Seeing the beacon on the spectrum analyzer validates the entire upstream chain. If the modem fails to lock after this, you know the problem is modem configuration - not antenna pointing, not LNB settings, not cables. This systematic approach eliminates troubleshooting guesswork.', + explanation: + 'Seeing the beacon on the spectrum analyzer validates the entire upstream chain. If the modem fails to lock after this, you know the problem is modem configuration - not antenna pointing, not LNB settings, not cables. This systematic approach eliminates troubleshooting guesswork.', pointPenalty: 5, }, mustMaintain: false, @@ -729,13 +718,14 @@ export const scenario4Data: ScenarioData = { params: { question: 'The modem shows "Locked" with C/N above 10 dB. Why do we check the C/N value and not just the lock indicator?', options: [ - 'Lock can occur at C/N as low as 3-4 dB, but error rates would be high - we need margin for reliable operation', - 'The lock indicator is unreliable and often shows false positives', - '10 dB is required for the modem hardware to function', - 'The C/N value determines the data rate we can achieve', + 'Lock can occur at C/N as low as 3-4 dB, but error rates would be high - we need margin', + 'The lock indicator is unreliable, often showing false positives - we need a second check', + '10 dB is the hardware minimum, below it the modem will not run - we need it to function', + 'The C/N value sets the data rate, higher is faster - we need to know the throughput', ], correctIndex: 0, - explanation: 'QPSK with FEC 3/4 can achieve lock at about 4-5 dB C/N, but bit error rates would be significant. With 10+ dB, we have comfortable margin - the link stays solid even if weather degrades it slightly. Lock without margin is asking for trouble during the first rain fade.', + explanation: + 'QPSK with FEC 3/4 can achieve lock at about 4-5 dB C/N, but bit error rates would be significant. With 10+ dB, we have comfortable margin - the link stays solid even if weather degrades it slightly. Lock without margin is asking for trouble during the first rain fade.', pointPenalty: 5, }, mustMaintain: false, @@ -800,15 +790,16 @@ export const scenario4Data: ScenarioData = { type: 'status-check', description: 'Verify TX Chain State', params: { - question: 'What is the current state of VT-01\'s transmit chain?', + question: "What is the current state of VT-01's transmit chain?", options: [ 'BUC is muted and HPA is disabled - no RF output (safe state for switchover)', - 'BUC and HPA are active but transmitting to TIDEMARK-1', - 'TX chain is completely powered off', - 'TX chain is faulted and needs reset', + 'BUC and HPA are both active - RF output still going to TIDEMARK-1 (not yet safed)', + 'BUC and HPA are powered off - no RF output (cold chain, full restart needed)', + 'BUC and HPA are faulted - no RF output (alarm latched, reset required)', ], correctIndex: 0, - explanation: 'The transmit chain was placed in safe state for the switchover - BUC muted and HPA disabled. This is standard procedure when changing satellites. ME-02 is handling TIDEMARK-1 traffic, so VT-01 doesn\'t need to transmit until we\'re ready for TIDEMARK-2.', + explanation: + "The transmit chain was placed in safe state for the switchover - BUC muted and HPA disabled. This is standard procedure when changing satellites. ME-02 is handling TIDEMARK-1 traffic, so VT-01 doesn't need to transmit until we're ready for TIDEMARK-2.", pointPenalty: 5, }, mustMaintain: false, @@ -876,13 +867,13 @@ export const scenario4Data: ScenarioData = { maintainUntilObjectiveComplete: true, }, { - type: "tx-modem-transmitting", - description: "TX Modem Set to Transmitting", + type: 'tx-modem-transmitting', + description: 'TX Modem Set to Transmitting', params: { transmitting: true, }, maintainUntilObjectiveComplete: true, - } + }, ], conditionLogic: 'AND', points: 15, @@ -907,13 +898,14 @@ export const scenario4Data: ScenarioData = { params: { question: 'You need to enable the transmit path. What is the correct sequence and why?', options: [ - 'Unmute BUC first, then enable HPA - drive the amplifier chain from input to output to avoid undriven amplifiers', - 'Enable HPA first, then unmute BUC - warm up the high-power stage before applying signal', - 'Both can be enabled simultaneously - order doesn\'t matter', - 'The modem automatically sequences them when you press transmit', + 'Unmute BUC first, then enable HPA - drive the chain from input to output', + 'Enable HPA first, then unmute BUC - warm up the high-power stage before drive', + 'Enable both at once, order is irrelevant - the chain settles either way', + 'Press transmit on the modem, nothing else - the modem sequences them itself', ], correctIndex: 0, - explanation: 'Always enable the signal chain from input to output: BUC first, then HPA. An enabled HPA with no input signal can oscillate or amplify noise. By unmuting the BUC first, we ensure the HPA sees a proper signal as soon as it\'s enabled. Same principle as any amplifier chain.', + explanation: + "Always enable the signal chain from input to output: BUC first, then HPA. An enabled HPA with no input signal can oscillate or amplify noise. By unmuting the BUC first, we ensure the HPA sees a proper signal as soon as it's enabled. Same principle as any amplifier chain.", pointPenalty: 10, }, mustMaintain: false, @@ -976,13 +968,14 @@ export const scenario4Data: ScenarioData = { params: { question: 'VT-01 now has both receive and transmit paths active to TIDEMARK-2. What confirms full duplex operation?', options: [ - 'Receiver locked with good C/N, HPA enabled with proper backoff, no alarms - bidirectional link established', - 'The satellite has acknowledged our uplink signal', - 'Both the TX and RX indicators are green', - 'The modem shows "Full Duplex" mode', + 'Receiver locked with good C/N, HPA enabled with backoff, no alarms - both directions up', + 'Receiver locked with good C/N, satellite ack received, no alarms - uplink confirmed', + 'TX indicator green, RX indicator green, no alarms - both chains showing active', + 'Modem showing "Full Duplex", HPA enabled, no alarms - mode confirms the link', ], correctIndex: 0, - explanation: 'Full duplex means simultaneous transmit and receive. We confirm this by: (1) receiver locked with margin - downlink working, (2) HPA enabled with proper backoff - uplink active, (3) no alarms - everything in tolerance. The satellite doesn\'t "acknowledge" uplinks - it\'s a bent-pipe transponder that simply relays what it receives.', + explanation: + 'Full duplex means simultaneous transmit and receive. We confirm this by: (1) receiver locked with margin - downlink working, (2) HPA enabled with proper backoff - uplink active, (3) no alarms - everything in tolerance. The satellite doesn\'t "acknowledge" uplinks - it\'s a bent-pipe transponder that simply relays what it receives.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario5.ts b/src/campaigns/nats/scenario5.ts index 44b0e27f..09b36ca1 100644 --- a/src/campaigns/nats/scenario5.ts +++ b/src/campaigns/nats/scenario5.ts @@ -2,7 +2,7 @@ import { Satellite, TransponderConfig } from '@app/equipment/satellite/satellite import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; import type { ScenarioData } from '@app/ScenarioData'; -import { SignalOrigin } from "@app/signal-origin"; +import { SignalOrigin } from '@app/signal-origin'; import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import { Degrees } from 'ootk'; @@ -20,7 +20,7 @@ import { ses10Satellite, tidemark2Satellite } from './satellites'; * Premise: Customer reports degraded service on TIDEMARK-1. C/N ratio has dropped * because of a 3 MHz interference spike WITHIN our 36 MHz signal bandwidth. This is * caused by a third-party operator's polarization mismatch - their cross-pol leakage - * is landing in our transponder. The AGC is reducing gain based on the spike, which + * is falling in our transponder. The AGC is reducing gain based on the spike, which * degrades overall C/N. * * Solution: Apply a 3 MHz notch filter at the interference frequency to block the @@ -55,13 +55,7 @@ export const scenario5Data: ScenarioData = { difficulty: 'beginner', missionType: 'Troubleshooting', description: `Customer reports degraded service on TIDEMARK-1. The C/N ratio has dropped significantly, causing packet errors.

The spectrum analyzer is currently configured for beacon tracking - you'll need to reconfigure it to investigate the main signal. Something's causing interference, and you'll need to find it, understand what's happening, and apply the right mitigation.

Charlie will guide you through the troubleshooting process and provide hints along the way.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'IF Filter Bank', - 'ME-02: Available', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'IF Filter Bank', 'ME-02: Available'], settings: { isSync: true, groundStations: [ @@ -158,7 +152,7 @@ export const scenario5Data: ScenarioData = { } ), ses10Satellite, - tidemark2Satellite + tidemark2Satellite, ], }, timeLimitSeconds: 1500, // 25 minutes (expanded from 20) @@ -185,9 +179,7 @@ export const scenario5Data: ScenarioData = { description: 'Ready to Proceed', params: { question: 'Have you reviewed the mission brief and are you ready to begin?', - options: [ - 'Yes, I have read the mission brief and I am ready to proceed.', - ], + options: ['Yes, I have read the mission brief and I am ready to proceed.'], correctIndex: 0, explanation: 'The mission timer has started. Good luck!', pointPenalty: 0, @@ -246,7 +238,7 @@ export const scenario5Data: ScenarioData = { id: 'phase-1-observe-degradation', nice: ['K0740', 'T0153'], // K0740: Knowledge of network performance parameters, T0153: Monitor network capacity and performance title: 'Confirm Signal Degradation', - description: 'Check the receiver modem to confirm the customer\'s report of degraded service.', + description: "Check the receiver modem to confirm the customer's report of degraded service.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['navigate-rx-analysis'], timeLimitSeconds: 2 * 60, @@ -259,9 +251,9 @@ export const scenario5Data: ScenarioData = { question: 'Looking at the receiver modem, what is the current C/N ratio status?', options: [ 'C/N is degraded - well below normal operating threshold', - 'C/N is healthy - operating normally', - 'C/N is marginal - at threshold', - 'No signal lock - receiver is offline', + 'C/N is healthy - well above the normal operating threshold', + 'C/N is marginal - sitting right at the operating threshold', + 'No signal lock - receiver is offline with no C/N reading', ], correctIndex: 0, explanation: 'The C/N ratio is well below the normal operating level. This confirms the customer complaint - something is degrading our signal quality.', @@ -288,12 +280,13 @@ export const scenario5Data: ScenarioData = { question: 'Besides low C/N ratio, what other symptoms would you expect to see with this type of degradation?', options: [ 'Elevated BER (Bit Error Rate) and increased packet retransmissions', - 'Higher than normal transmit power from the modem', - 'Increased antenna tracking errors', - 'LNB temperature warnings', + 'Elevated TX power (uplink EIRP) and increased modem drive level', + 'Elevated tracking error (az/el) and increased ACU step corrections', + 'Elevated LNB temperature (thermal alarm) and increased noise figure', ], correctIndex: 0, - explanation: 'When C/N degrades, the demodulator makes more bit errors. This increases BER and causes more packet retransmissions, which is exactly what the customer is reporting - packet errors and degraded throughput.', + explanation: + 'When C/N degrades, the demodulator makes more bit errors. This increases BER and causes more packet retransmissions, which is exactly what the customer is reporting - packet errors and degraded throughput.', pointPenalty: 5, preserveOptionOrder: true, }, @@ -327,13 +320,14 @@ export const scenario5Data: ScenarioData = { params: { question: 'The spectrum analyzer is currently configured for beacon observation. Why is this configuration inadequate for troubleshooting the customer issue?', options: [ - 'The narrow span only shows the beacon, not our 36 MHz wideband signal where the problem likely exists', - 'The center frequency is wrong for this satellite', - 'The resolution bandwidth is too high to see small signals', - 'The reference level is clipping the signal', + 'The narrow span shows only the beacon, not the 36 MHz wideband carrier with the problem', + 'The center frequency is wrong for this satellite, not the downlink we are receiving', + 'The resolution bandwidth is too high, so small signals are lost in the noise floor', + 'The reference level is clipping the signal, so the peak of the carrier is cut off', ], correctIndex: 0, - explanation: 'When tracking beacons, we use a narrow span (typically 10-20 MHz) focused on the beacon frequency. But our customer traffic is on a 36 MHz wideband carrier at a different frequency. We need to widen the span and recenter to see what\'s happening to the actual customer signal.', + explanation: + "When tracking beacons, we use a narrow span (typically 10-20 MHz) focused on the beacon frequency. But our customer traffic is on a 36 MHz wideband carrier at a different frequency. We need to widen the span and recenter to see what's happening to the actual customer signal.", pointPenalty: 5, preserveOptionOrder: true, }, @@ -350,7 +344,8 @@ export const scenario5Data: ScenarioData = { id: 'phase-2-configure-and-locate', nice: ['K0737', 'S0421', 'T0153', 'K1032'], // K0737: Knowledge of RF spectrum characteristics, S0421: Skill in using test equipment, T0153: Monitor network capacity and performance, K1032: Knowledge of RF propagation title: 'Configure Spectrum View', - description: 'The spectrum analyzer is currently configured for beacon observation. Reconfigure it to see the full wideband signal - widen the span and center on the downlink IF frequency.', + description: + 'The spectrum analyzer is currently configured for beacon observation. Reconfigure it to see the full wideband signal - widen the span and center on the downlink IF frequency.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['verify-speca-initial-state'], timeLimitSeconds: 3 * 60, @@ -408,7 +403,7 @@ export const scenario5Data: ScenarioData = { id: 'phase-4-identify-interference', nice: ['T0081', 'K0773'], // T0081: Analyze anomalies in network traffic, K0773: Knowledge of signal analysis title: 'Identify Interference', - description: 'Look at the spectrum analyzer display. Our wideband signal should be visible - is there anything else that shouldn\'t be there?', + description: "Look at the spectrum analyzer display. Our wideband signal should be visible - is there anything else that shouldn't be there?", groundStation: 'VT-01', prerequisiteObjectiveIds: ['phase-2-configure-and-locate'], timeLimitSeconds: 2 * 60, @@ -426,7 +421,8 @@ export const scenario5Data: ScenarioData = { 'Multiple spikes scattered across the spectrum', ], correctIndex: 0, - explanation: 'There\'s a narrowband spike sitting within our wideband signal. This is in-band interference - it\'s not adjacent to our signal, it\'s inside it. That\'s why it\'s so problematic.', + explanation: + "There's a narrowband spike sitting within our wideband signal. This is in-band interference - it's not adjacent to our signal, it's inside it. That's why it's so problematic.", pointPenalty: 5, }, mustMaintain: false, @@ -457,7 +453,8 @@ export const scenario5Data: ScenarioData = { 'Variable - the interference bandwidth keeps changing', ], correctIndex: 0, - explanation: 'The interference is a narrowband spike - only a few MHz wide compared to our wideband signal. This is important because it means we can surgically remove it with a notch filter without affecting most of our signal.', + explanation: + 'The interference is a narrowband spike - only a few MHz wide compared to our wideband signal. This is important because it means we can surgically remove it with a notch filter without affecting most of our signal.', pointPenalty: 5, }, mustMaintain: false, @@ -470,7 +467,8 @@ export const scenario5Data: ScenarioData = { id: 'measure-interference-frequency', nice: ['S0421', 'T0153'], // S0421: Skill in using test equipment, T0153: Monitor network capacity and performance title: 'Record Interference Frequency', - description: 'Observe the spectrum analyzer display and identify the approximate center frequency of the interference spike. This will be critical for notch filter configuration.', + description: + 'Observe the spectrum analyzer display and identify the approximate center frequency of the interference spike. This will be critical for notch filter configuration.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['phase-5-characterize-interference'], timeLimitSeconds: 2 * 60, @@ -481,14 +479,9 @@ export const scenario5Data: ScenarioData = { description: 'Record Frequency', params: { question: 'What is the approximate IF frequency of the interference spike?', - options: [ - '1515 MHz', - '1532 MHz', - '1520 MHz', - '1500 MHz', - ], + options: ['1515 MHz', '1532 MHz', '1520 MHz', '1500 MHz'], correctIndex: 0, - explanation: 'The interference is centered at approximately 1515 MHz IF. You\'ll use this frequency to configure the notch filter in the next steps.', + explanation: "The interference is centered at approximately 1515 MHz IF. You'll use this frequency to configure the notch filter in the next steps.", pointPenalty: 5, }, mustMaintain: false, @@ -514,10 +507,7 @@ export const scenario5Data: ScenarioData = { description: 'Frequency Domain for Notch Filter', params: { question: 'Should the notch filter be configured using IF or RF frequency?', - options: [ - 'IF frequency', - 'RF frequency', - ], + options: ['IF frequency', 'RF frequency'], correctIndex: 0, explanation: 'The notch filter is configured using the IF frequency because it operates on the intermediate frequency signal after downconversion.', pointPenalty: 5, @@ -536,7 +526,7 @@ export const scenario5Data: ScenarioData = { id: 'phase-6-understand-cause', nice: ['T0081', 'K0773'], // T0081: Analyze anomalies in network traffic, K0773: Knowledge of signal analysis title: 'Understand the Interference Source', - description: 'Determine what\'s causing this in-band interference.', + description: "Determine what's causing this in-band interference.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['understand-notch-frequency-domain'], timeLimitSeconds: 2 * 60, @@ -548,13 +538,14 @@ export const scenario5Data: ScenarioData = { params: { question: 'What is the most likely cause of this narrowband spike within our signal bandwidth?', options: [ - 'Cross-polarization leakage from another operator\'s uplink', + "Cross-polarization leakage from another operator's uplink", 'A faulty component in our own transmit chain', 'Terrestrial interference from nearby radio towers', 'Solar radio emissions during a flare event', ], correctIndex: 0, - explanation: 'This is cross-polarization interference. Satellites use orthogonal polarizations (H and V) to allow frequency reuse - different operators can use the same frequency on opposite polarizations. But polarization isolation isn\'t perfect. Another operator\'s signal on the V polarization is leaking into our H polarization due to imperfect antenna alignment or atmospheric effects.', + explanation: + "This is cross-polarization interference. Satellites use orthogonal polarizations (H and V) to allow frequency reuse - different operators can use the same frequency on opposite polarizations. But polarization isolation isn't perfect. Another operator's signal on the V polarization is leaking into our H polarization due to imperfect antenna alignment or atmospheric effects.", pointPenalty: 5, }, mustMaintain: false, @@ -580,12 +571,13 @@ export const scenario5Data: ScenarioData = { question: 'Why is this narrowband spike causing the C/N to drop across our entire wideband signal?', options: [ 'The AGC sees the spike as part of the total signal and reduces gain accordingly', - 'The spike is overloading the LNB causing compression', - 'The interference is jamming our tracking beacon', - 'The spike is exactly on our carrier center frequency', + 'The spike is overloading the LNB and pushing the front end into compression', + 'The interference is jamming the tracking beacon and pulling the antenna off peak', + 'The spike sits exactly on the carrier center frequency and corrupts the symbols', ], correctIndex: 0, - explanation: 'The receiver\'s AGC (Automatic Gain Control) measures total power in the passband. It sees the strong spike and reduces gain to prevent overload. But this gain reduction affects our entire signal, degrading the C/N ratio for the wanted carrier. This is why even a narrowband interferer can impact a wideband signal.', + explanation: + "The receiver's AGC (Automatic Gain Control) measures total power in the passband. It sees the strong spike and reduces gain to prevent overload. But this gain reduction affects our entire signal, degrading the C/N ratio for the wanted carrier. This is why even a narrowband interferer can impact a wideband signal.", pointPenalty: 5, }, mustMaintain: false, @@ -618,7 +610,8 @@ export const scenario5Data: ScenarioData = { 'Contact the interfering operator and wait for them to fix it', ], correctIndex: 0, - explanation: 'A notch filter is the surgical solution. It removes only the narrow interference spike while passing our full 36 MHz signal. A narrower bandpass would sacrifice our own bandwidth. Increasing power wouldn\'t help the C/N ratio and would violate coordination agreements. Contacting the operator is the long-term solution, but we need an immediate fix for the customer.', + explanation: + "A notch filter is the surgical solution. It removes only the narrow interference spike while passing our full 36 MHz signal. A narrower bandpass would sacrifice our own bandwidth. Increasing power wouldn't help the C/N ratio and would violate coordination agreements. Contacting the operator is the long-term solution, but we need an immediate fix for the customer.", pointPenalty: 5, preserveOptionOrder: true, }, @@ -665,7 +658,7 @@ export const scenario5Data: ScenarioData = { timePenalty: { elapsedTimeThreshold: 18 * 60, // 18 minutes pointsDeducted: 30, - message: "We just violated the SLA! This is going to cost us a lot of money.", + message: 'We just violated the SLA! This is going to cost us a lot of money.', }, conditionLogic: 'AND', points: 25, @@ -695,12 +688,13 @@ export const scenario5Data: ScenarioData = { question: 'Looking at the spectrum analyzer, what happened to the interference spike?', options: [ 'The spike is gone - the notch filter removed it from the passband', - 'The spike is still visible at the same level', - 'The spike moved to a different frequency', - 'The entire signal disappeared', + 'The spike is still there - the notch filter had no effect on its level', + 'The spike has moved - the notch filter pushed it to a different frequency', + 'The whole signal is gone - the notch filter removed the entire passband', ], correctIndex: 0, - explanation: 'The notch filter is working. It\'s attenuating the interference spike by 40 dB, effectively removing it from the receiver\'s passband. Our wideband signal passes through unaffected because the notch is narrow enough to only target the interferer.', + explanation: + "The notch filter is working. It's attenuating the interference spike by 40 dB, effectively removing it from the receiver's passband. Our wideband signal passes through unaffected because the notch is narrow enough to only target the interferer.", pointPenalty: 5, }, mustMaintain: false, @@ -726,12 +720,13 @@ export const scenario5Data: ScenarioData = { question: 'After applying the notch filter, what happened to the signal?', options: [ 'C/N restored to normal levels - the spike is notched out and AGC normalized', - 'C/N unchanged - the filter had no effect', - 'Signal lock lost - the notch filter blocked our carrier', - 'C/N dropped further - wrong filter settings', + 'C/N unchanged from before - the notch filter had no effect on the AGC', + 'Signal lock lost entirely - the notch filter blocked our own carrier', + 'C/N dropped even further - the filter settings notched the wrong frequency', ], correctIndex: 0, - explanation: 'The notch filter blocks the interference spike while passing the rest of our wideband signal. With the spike removed, the AGC no longer sees the excess power and allows proper gain. C/N returns to normal.', + explanation: + 'The notch filter blocks the interference spike while passing the rest of our wideband signal. With the spike removed, the AGC no longer sees the excess power and allows proper gain. C/N returns to normal.', pointPenalty: 5, }, mustMaintain: false, @@ -759,12 +754,13 @@ export const scenario5Data: ScenarioData = { question: 'Which of the following is most important to document and report about this interference event?', options: [ 'Interference frequency, bandwidth, apparent source, time of occurrence, and mitigation applied', - 'Just the notch filter settings in case we need to apply them again', - 'Customer complaint details only - they don\'t need technical specifics', - 'Nothing - the problem is fixed so no documentation is needed', + 'Notch filter center, depth, bandwidth, and time applied, in case we need it again', + 'Customer name, complaint time, services affected, and resolution, nothing technical', + 'Ticket number, time closed, and operator on shift, since the problem is already fixed', ], correctIndex: 0, - explanation: 'Complete documentation is essential. The frequency and bandwidth help identify the source. The time helps correlate with other operators\' activities. Recording the mitigation allows quick response if it recurs. This data also supports the interference coordination process to resolve the root cause with the other operator.', + explanation: + "Complete documentation is essential. The frequency and bandwidth help identify the source. The time helps correlate with other operators' activities. Recording the mitigation allows quick response if it recurs. This data also supports the interference coordination process to resolve the root cause with the other operator.", pointPenalty: 5, preserveOptionOrder: true, }, diff --git a/src/campaigns/nats/scenario6.ts b/src/campaigns/nats/scenario6.ts index f1d0dbab..e787a483 100644 --- a/src/campaigns/nats/scenario6.ts +++ b/src/campaigns/nats/scenario6.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; @@ -5,7 +6,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz, IfFrequency, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { aurora7Satellite, ses10Satellite, tidemark1Satellite } from './satellites'; @@ -73,12 +73,7 @@ export const scenario6Data: ScenarioData = { difficulty: 'beginner', missionType: 'Training Exercise', description: `AURORA-7 is a legacy C-band satellite that's been in service for over 15 years. To conserve fuel, the operators stopped north-south station-keeping, so the orbit is now inclined. The satellite traces a figure-8 pattern in the sky - you can't just point and forget.

This is a training exercise to practice step-track mode. Unlike program-track which follows predicted orbital elements, step-track uses the beacon signal to continuously adjust antenna pointing. It's essential for tracking satellites with inclined orbits.

Charlie has pre-configured the beacon frequency, encryption, and payload settings. Your job is to acquire the satellite using step-track, establish receive lock, calculate the TX IF frequency, and bring up the transmit path.

Take your time - this is practice.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems'], settings: { isSync: true, groundStations: [ @@ -211,7 +206,7 @@ export const scenario6Data: ScenarioData = { // K1032: Knowledge of satellite-based communication systems nice: ['K1032'], title: 'Understand Inclined Orbits', - description: 'Before we start tracking, let\'s make sure you understand why AURORA-7 requires special handling.', + description: "Before we start tracking, let's make sure you understand why AURORA-7 requires special handling.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['review-mission-brief'], timeLimitSeconds: 3 * 60, @@ -224,12 +219,13 @@ export const scenario6Data: ScenarioData = { question: 'Why does AURORA-7 require step-track mode instead of program-track?', options: [ 'Inclined orbit causes the satellite to drift in az/el; step-track follows the beacon', - 'The satellite has a faulty antenna requiring manual adjustment', - 'Step-track uses less power than program-track', - 'Program-track only works with newer satellites', + 'The satellite has a faulty antenna; step-track compensates with manual-style nudges', + 'Step-track uses less drive power than program-track; the ACU runs cooler on long passes', + 'Program-track only works with newer satellites; AURORA-7 predates the TLE format', ], correctIndex: 0, - explanation: 'Legacy satellites with inclined orbits drift in a figure-8 pattern as seen from the ground. Step-track uses the beacon signal to continuously adjust antenna pointing, while program-track relies on TLE predictions that assume a fixed geostationary position.', + explanation: + 'Legacy satellites with inclined orbits drift in a figure-8 pattern as seen from the ground. Step-track uses the beacon signal to continuously adjust antenna pointing, while program-track relies on TLE predictions that assume a fixed geostationary position.', pointPenalty: 5, }, mustMaintain: false, @@ -303,7 +299,7 @@ export const scenario6Data: ScenarioData = { // K1032: Knowledge of satellite-based communication systems nice: ['K1032'], title: 'Understand Program-Track Limitations', - description: 'Program-track has acquired AURORA-7, but there\'s a problem with inclined-orbit satellites.', + description: "Program-track has acquired AURORA-7, but there's a problem with inclined-orbit satellites.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['program-track-aurora7'], timeLimitSeconds: 2 * 60, @@ -313,15 +309,16 @@ export const scenario6Data: ScenarioData = { type: 'status-check', description: 'Program-Track Limitation', params: { - question: 'Why can\'t we stay in program-track mode for AURORA-7?', + question: "Why can't we stay in program-track mode for AURORA-7?", options: [ - 'AURORA-7\'s inclined orbit causes drift - ephemeris predictions aren\'t accurate enough', - 'Program-track consumes more power than step-track', - 'The antenna hardware doesn\'t support program-track for C-band', - 'Program-track only works for LEO satellites', + "AURORA-7's inclined orbit causes drift - ephemeris predictions aren't accurate enough", + "Program-track consumes more drive power than step-track - the ACU can't sustain it", + "The antenna hardware doesn't support program-track at C-band - the ACU firmware blocks it", + "Program-track only works for LEO satellites - AURORA-7's GEO slot isn't supported", ], correctIndex: 0, - explanation: 'Program-track follows TLE predictions that assume a fixed geostationary position. AURORA-7\'s inclined orbit means it drifts in a figure-8 pattern, so predictions are only accurate for rough pointing. We need step-track to actively follow the beacon.', + explanation: + "Program-track follows TLE predictions that assume a fixed geostationary position. AURORA-7's inclined orbit means it drifts in a figure-8 pattern, so predictions are only accurate for rough pointing. We need step-track to actively follow the beacon.", pointPenalty: 5, }, mustMaintain: false, @@ -353,11 +350,12 @@ export const scenario6Data: ScenarioData = { options: [ 'LNB LO (5250 MHz) minus beacon RF (4165 MHz) = 1085 MHz', 'Beacon RF (4165 MHz) minus a standard offset (3080 MHz)', - 'It\'s the satellite\'s default beacon IF setting', + "It's the satellite's default beacon IF setting", 'BUC LO (7500 MHz) minus beacon RF (4165 MHz) = 3355 MHz', ], correctIndex: 0, - explanation: 'The beacon IF is calculated using high-side LO injection: IF = LO - RF = 5250 - 4165 = 1085 MHz. The LNB converts the RF signal down to IF for processing.', + explanation: + 'The beacon IF is calculated using high-side LO injection: IF = LO - RF = 5250 - 4165 = 1085 MHz. The LNB converts the RF signal down to IF for processing.', pointPenalty: 5, }, mustMaintain: false, @@ -575,7 +573,7 @@ export const scenario6Data: ScenarioData = { // S0077: Skill in securing network communications nice: ['S0077'], title: 'Verify Encryption Understanding', - description: 'Before we enable transmission, let\'s verify you understand the encryption configuration. Look at the Encryption card on the TX Chain tab.', + description: "Before we enable transmission, let's verify you understand the encryption configuration. Look at the Encryption card on the TX Chain tab.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['verify-rx-lock'], timeLimitSeconds: 3 * 60, @@ -588,12 +586,13 @@ export const scenario6Data: ScenarioData = { question: 'Looking at the Encryption card, what security configuration is active?', options: [ 'AES-256-GCM with valid key - ready for secure transmission', - 'AES-128-CBC with expired key - needs renewal', - 'Encryption bypassed - transmitting in clear', - 'Triple-DES with pending key rotation', + 'AES-128-CBC with expired key - renewal needed before transmit', + 'Encryption bypassed with no key - transmitting in the clear', + 'Triple-DES with rotating key - waiting on the key rotation', ], correctIndex: 0, - explanation: 'The Encryption card shows AES-256-GCM active with a valid key. AES-256 provides strong symmetric encryption, and GCM mode adds authenticated encryption to detect tampering. Always verify encryption status before transmitting.', + explanation: + 'The Encryption card shows AES-256-GCM active with a valid key. AES-256 provides strong symmetric encryption, and GCM mode adds authenticated encryption to detect tampering. Always verify encryption status before transmitting.', pointPenalty: 5, }, mustMaintain: false, @@ -610,7 +609,7 @@ export const scenario6Data: ScenarioData = { // K0773: Knowledge of telecommunications principles and practices nice: ['K0773'], title: 'Calculate TX IF Frequency', - description: 'AURORA-7\'s uplink is at 6053 MHz RF. Calculate the TX IF frequency using the BUC LO at 7500 MHz.', + description: "AURORA-7's uplink is at 6053 MHz RF. Calculate the TX IF frequency using the BUC LO at 7500 MHz.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['quiz-encryption'], timeLimitSeconds: 3 * 60, @@ -621,14 +620,10 @@ export const scenario6Data: ScenarioData = { description: 'TX IF Calculated', params: { question: 'What TX IF frequency should you configure to transmit at 6053 MHz RF?', - options: [ - '1447 MHz (7500 - 6053 = 1447)', - '13553 MHz (6053 + 7500 = 13553)', - '2303 MHz (6053 - 7500 / 2 = 2303)', - '755 MHz (7500 - 4170 = 755)', - ], + options: ['1447 MHz (7500 - 6053 = 1447)', '13553 MHz (6053 + 7500 = 13553)', '2303 MHz (6053 - 7500 / 2 = 2303)', '755 MHz (7500 - 4170 = 755)'], correctIndex: 0, - explanation: 'For uplink, the BUC upconverts the IF to RF: RF = IF + LO, so IF = LO - RF = 7500 - 6053 = 1447 MHz. This is similar to the downlink calculation because the BUC is also using low-side injection.', + explanation: + 'For uplink, the BUC upconverts the IF to RF: RF = IF + LO, so IF = LO - RF = 7500 - 6053 = 1447 MHz. This is similar to the downlink calculation because the BUC is also using low-side injection.', pointPenalty: 10, }, mustMaintain: false, @@ -643,7 +638,7 @@ export const scenario6Data: ScenarioData = { // T1567: Equipment configuration happens throughout nice: ['S0421', 'T1567'], title: 'Configure TX Modem', - description: 'Set the TX modem frequency to 1447 MHz to transmit on AURORA-7\'s uplink.', + description: "Set the TX modem frequency to 1447 MHz to transmit on AURORA-7's uplink.", groundStation: 'VT-01', prerequisiteObjectiveIds: ['calculate-tx-if'], timeLimitSeconds: 2 * 60, @@ -721,7 +716,8 @@ export const scenario6Data: ScenarioData = { 'Step-track on beacon, RX at 3605 MHz RF, TX at 5830 MHz RF, no encryption', ], correctIndex: 0, - explanation: 'Your link uses step-track mode to maintain pointing on the inclined-orbit AURORA-7 satellite. The receiver is configured for 1422 MHz IF (downlink), transmitter for 1447 MHz IF (uplink), and AES-256-GCM encryption is active.', + explanation: + 'Your link uses step-track mode to maintain pointing on the inclined-orbit AURORA-7 satellite. The receiver is configured for 1422 MHz IF (downlink), transmitter for 1447 MHz IF (uplink), and AES-256-GCM encryption is active.', pointPenalty: 5, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario7.ts b/src/campaigns/nats/scenario7.ts index d10a464b..1657287c 100644 --- a/src/campaigns/nats/scenario7.ts +++ b/src/campaigns/nats/scenario7.ts @@ -1,14 +1,13 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; import type { ScenarioData } from '@app/ScenarioData'; import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBi, dBm, Hertz, IfFrequency, MHz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { maineGroundStation, vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite } from './satellites'; - /** * NATS Level 7: "Uplink Validation" * @@ -72,13 +71,7 @@ export const scenario7Data: ScenarioData = { difficulty: 'beginner', missionType: 'Operations Phase', description: `The Vermont station completed overnight maintenance on the transmit chain - waveguide inspection and HPA tube replacement. Before resuming normal operations, you need to validate the entire uplink path.

Charlie is off-site today. Dana Torres, the shift supervisor, is handling paperwork but will check in periodically. You're expected to handle this independently.

Verify the receive chain, configure the transmitter, use BUC loopback to validate your signal, then bring the uplink online.

Key lesson: Always validate before you radiate.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Unavailable', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Unavailable'], timeLimitSeconds: 35 * 60, settings: { isSync: true, @@ -236,14 +229,10 @@ export const scenario7Data: ScenarioData = { description: 'Identify Active Alarms', params: { question: 'What alarm condition is currently displayed on the Dashboard?', - options: [ - 'BUC High Current Draw', - 'LNB Reference Unlocked', - 'HPA Output Fault', - 'No active alarms', - ], + options: ['BUC High Current Draw', 'LNB Reference Unlocked', 'HPA Output Fault', 'No active alarms'], correctIndex: 0, - explanation: 'The BUC is drawing too much current. The maintenance crew left it unmuted while in loopback mode, which means it\'s actively processing signal. We need to mute it and disable loopback before proceeding.', + explanation: + "The BUC is drawing too much current. The maintenance crew left it unmuted while in loopback mode, which means it's actively processing signal. We need to mute it and disable loopback before proceeding.", pointPenalty: 10, character: Character.DANA_TORRES, }, @@ -276,14 +265,10 @@ export const scenario7Data: ScenarioData = { description: 'Identify Cause', params: { question: 'Looking at the BUC panel, what is the likely cause of the high current draw?', - options: [ - 'BUC has been on for a few hours and is overheating', - 'BUC gain is set too high', - 'External reference is unlocked', - 'BUC temperature is too low', - ], + options: ['BUC has been on for a few hours and is overheating', 'BUC gain is set too high', 'External reference is unlocked', 'BUC temperature is too low'], correctIndex: 1, - explanation: 'The BUC gain is set to 50 dB and since it\'s in loopback mode and unmuted, it\'s actively processing signal, leading to high current draw. The maintenance crew likely forgot to mute it after testing.', + explanation: + "The BUC gain is set to 50 dB and since it's in loopback mode and unmuted, it's actively processing signal, leading to high current draw. The maintenance crew likely forgot to mute it after testing.", pointPenalty: 10, character: Character.DANA_TORRES, }, @@ -350,9 +335,9 @@ export const scenario7Data: ScenarioData = { question: 'What is the current BUC status on the Dashboard?', options: [ 'Normal - current draw within limits, no active alarms', - 'Warning - current still too high', - 'Fault - BUC offline', - 'Unknown - BUC not reporting', + 'Warning - current draw still above limit, alarm latched', + 'Fault - BUC offline, current draw at zero, alarm active', + 'Unknown - BUC not reporting, no current or alarm data', ], correctIndex: 0, explanation: 'The BUC has been muted and loopback disabled. The high current alarm has cleared. Always verify alarm resolution on the Dashboard before proceeding.', @@ -399,12 +384,7 @@ export const scenario7Data: ScenarioData = { description: 'Antenna Status Verified', params: { question: 'What is the current antenna tracking mode and target satellite?', - options: [ - 'Program Track - TIDEMARK-1', - 'Program Track - TIDEMARK-2', - 'Step Track - TIDEMARK-1', - 'Maintenance - Stowed', - ], + options: ['Program Track - TIDEMARK-1', 'Program Track - TIDEMARK-2', 'Step Track - TIDEMARK-1', 'Maintenance - Stowed'], correctIndex: 0, explanation: 'The antenna is in Program Track mode, locked on TIDEMARK-1.', pointPenalty: 5, @@ -455,13 +435,12 @@ export const scenario7Data: ScenarioData = { type: 'status-check', description: 'Noise Temperature Understanding', params: { - question: - 'What does the LNB noise temperature value indicate about receiver performance?', + question: 'What does the LNB noise temperature value indicate about receiver performance?', options: [ 'Lower noise temperature means better sensitivity and signal-to-noise ratio', - 'Higher noise temperature means better sensitivity', - 'Noise temperature only affects transmit power', - 'Noise temperature has no impact on signal quality', + 'Higher noise temperature means better sensitivity and signal-to-noise ratio', + 'Noise temperature sets the transmit power and has no effect on sensitivity', + 'Noise temperature has no effect on sensitivity or signal-to-noise ratio', ], correctIndex: 0, explanation: @@ -541,13 +520,12 @@ export const scenario7Data: ScenarioData = { type: 'status-check', description: 'Beacon Identification', params: { - question: - 'What distinguishes the beacon signal from other signals on the spectrum display?', + question: 'What distinguishes the beacon signal from other signals on the spectrum display?', options: [ 'Beacon is a narrow CW carrier spike, while data signals have wider bandwidth', - 'Beacon is wider than data signals', - 'Beacon has modulation visible in the spectrum shape', - 'Beacon power level is always exactly -50 dBm', + 'Beacon is a wide modulated carrier, while data signals show up as narrow CW spikes', + 'Beacon shows visible modulation, while data signals appear as flat CW lines', + 'Beacon sits at exactly -50 dBm, while data signals sit lower on the display', ], correctIndex: 0, explanation: @@ -561,8 +539,7 @@ export const scenario7Data: ScenarioData = { type: 'status-check', description: 'Beacon Purpose', params: { - question: - 'What does successful beacon acquisition confirm about the receive chain?', + question: 'What does successful beacon acquisition confirm about the receive chain?', options: [ 'Antenna is pointed at the satellite', 'LNB is functioning and converting RF to IF', @@ -570,8 +547,7 @@ export const scenario7Data: ScenarioData = { 'All of the above', ], correctIndex: 3, - explanation: - 'The beacon validates the entire receive chain: antenna pointing, LNB operation, and signal routing. If any component fails, the beacon disappears.', + explanation: 'The beacon validates the entire receive chain: antenna pointing, LNB operation, and signal routing. If any component fails, the beacon disappears.', pointPenalty: 5, preserveOptionOrder: true, character: Character.DANA_TORRES, @@ -598,12 +574,7 @@ export const scenario7Data: ScenarioData = { description: 'Beacon IF Calculation', params: { question: 'The TIDEMARK-1 beacon is at 4,175.5 MHz RF. With an LNB LO of 5,250 MHz, what IF frequency is the beacon at?', - options: [ - '1,074.5 MHz', - '1,174.5 MHz', - '9,425.5 MHz', - '925.5 MHz', - ], + options: ['1,074.5 MHz', '1,174.5 MHz', '9,425.5 MHz', '925.5 MHz'], correctIndex: 0, explanation: 'IF = LO - RF = 5,250 - 4,175.5 = 1,074.5 MHz. The LNB downconverts C-band RF to L-band IF.', pointPenalty: 10, @@ -635,12 +606,7 @@ export const scenario7Data: ScenarioData = { description: 'TX IF Frequency Calculated', params: { question: 'TIDEMARK-1 TP-1 uplink is 5,943 MHz RF. The BUC LO is 7,000 MHz. What TX IF frequency is required?', - options: [ - '1,057 MHz', - '12,943 MHz', - '957 MHz', - '1,157 MHz', - ], + options: ['1,057 MHz', '12,943 MHz', '957 MHz', '1,157 MHz'], correctIndex: 0, explanation: 'TX IF = BUC LO - RF = 7,000 - 5,943 = 1,057 MHz. The BUC upconverts IF to RF.', pointPenalty: 10, @@ -713,10 +679,10 @@ export const scenario7Data: ScenarioData = { type: 'tx-modem-transmitting', description: 'TX Modem Transmitting', params: { - isTransmitting: true + isTransmitting: true, }, maintainUntilObjectiveComplete: true, - } + }, ], conditionLogic: 'AND', points: 15, @@ -795,7 +761,8 @@ export const scenario7Data: ScenarioData = { 'Bypasses the HPA to reduce power consumption and improve performance', ], correctIndex: 0, - explanation: 'Loopback mode internally routes the BUC output signal back to the receive chain, allowing you to verify the TX modem and BUC are working without actually transmitting RF power through the antenna.', + explanation: + 'Loopback mode internally routes the BUC output signal back to the receive chain, allowing you to verify the TX modem and BUC are working without actually transmitting RF power through the antenna.', pointPenalty: 5, character: Character.DANA_TORRES, }, @@ -880,13 +847,12 @@ export const scenario7Data: ScenarioData = { type: 'status-check', description: 'Loopback Signal Verified', params: { - question: - 'With the LNB LO at 7000 MHz and spectrum analyzer centered at 1,057 MHz, what do you observe?', + question: 'With the LNB LO at 7000 MHz and spectrum analyzer centered at 1,057 MHz, what do you observe?', options: [ 'A 36 MHz wide signal centered at 1,057 MHz - the TX modem output via loopback', - 'No signal visible at 1,057 MHz', - 'Only the beacon signal at 1,074.5 MHz', - 'A narrow CW carrier spike', + 'No signal anywhere near 1,057 MHz - the loopback path is not visible on RX', + 'Only a narrow CW spike at 1,074.5 MHz - the TIDEMARK-1 beacon, no loopback', + 'A narrow CW carrier spike at 1,057 MHz - the BUC LO leaking through the loopback', ], correctIndex: 0, explanation: @@ -918,12 +884,13 @@ export const scenario7Data: ScenarioData = { question: 'What does a successful loopback test verify?', options: [ 'TX modem output and BUC signal path are functioning', - 'The satellite transponder is responding', - 'The HPA is at full power', - 'The antenna is pointed correctly', + 'Satellite transponder and downlink path are responding', + 'HPA output stage and waveguide path are at full power', + 'Antenna pointing and feed alignment are on the satellite', ], correctIndex: 0, - explanation: 'Loopback testing verifies the low-power transmit chain (modem and BUC) without engaging the HPA or transmitting. This catches configuration errors before they cause interference.', + explanation: + 'Loopback testing verifies the low-power transmit chain (modem and BUC) without engaging the HPA or transmitting. This catches configuration errors before they cause interference.', pointPenalty: 5, character: Character.DANA_TORRES, }, @@ -971,15 +938,9 @@ export const scenario7Data: ScenarioData = { description: 'Encryption Status Verified', params: { question: 'What is the encryption status for this uplink?', - options: [ - 'AES-256 Enabled', - 'AES-128 Enabled', - 'Encryption Disabled', - 'Key Expired - Renewal Required', - ], + options: ['AES-256 Enabled', 'AES-128 Enabled', 'Encryption Disabled', 'Key Expired - Renewal Required'], correctIndex: 0, - explanation: - 'Link encryption is AES-256 per the TIDEMARK-1 service agreement. Never transmit without verifying encryption status.', + explanation: 'Link encryption is AES-256 per the TIDEMARK-1 service agreement. Never transmit without verifying encryption status.', pointPenalty: 10, character: Character.DANA_TORRES, }, @@ -1058,8 +1019,7 @@ export const scenario7Data: ScenarioData = { // K0740: Knowledge of network performance management nice: ['T0153', 'K0740'], title: 'Increase HPA Output Power', - description: - 'Lower the HPA backoff to reach the minimum output power required for reliable uplink.', + description: 'Lower the HPA backoff to reach the minimum output power required for reliable uplink.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['enable-hpa-output'], timeLimitSeconds: 2 * 60, @@ -1211,4 +1171,4 @@ export const scenario7Data: ScenarioData = { }, }, }, -}; \ No newline at end of file +}; diff --git a/src/campaigns/nats/scenario8.ts b/src/campaigns/nats/scenario8.ts index e05b409d..1d123704 100644 --- a/src/campaigns/nats/scenario8.ts +++ b/src/campaigns/nats/scenario8.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { Character, Emotion } from '@app/modal/character-enum'; import type { Objective } from '@app/objectives/objective-types'; @@ -6,7 +7,6 @@ import { SignalOrigin } from '@app/signal-origin'; import type { dB, dBm, FECType, Hertz, IfFrequency, MHz, ModulationType } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { aurora7Satellite, tidemark1Satellite } from './satellites'; @@ -64,12 +64,7 @@ export const scenario8Data: ScenarioData = { duration: '30-40 min', missionType: 'Final Evaluation', description: `It's 2 AM on a Saturday night - your first solo night shift at the Vermont station. Charlie is visiting family out of state. Dana is on-call but sleeping; she's made it clear she only wants to be woken for genuine emergencies.

A customer reports intermittent connectivity issues on AURORA-7, an aging C-band satellite with an inclined orbit. You'll need to investigate independently, diagnose any equipment issues, verify the link, and handle whatever complications arise.

This is your graduation exam. Everything you've learned in Scenarios 1-7 comes together here. No one is going to walk you through each step. Make good decisions, work methodically, and prove you're ready for solo operations.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems'], timeLimitSeconds: 40 * 60, // 40 minutes settings: { isSync: true, @@ -149,7 +144,7 @@ export const scenario8Data: ScenarioData = { ...vermontGroundStation.receivers[0].modems[0], frequency: 1422 as MHz, // AURORA-7 downlink RF bandwidth: 24 as MHz, - } + }, ], }, ], @@ -213,9 +208,9 @@ export const scenario8Data: ScenarioData = { question: 'Customer reports intermittent connectivity on AURORA-7. You are alone at the station. How will you proceed?', options: [ 'Begin systematic troubleshooting - check timing, RX chain, antenna, then TX if needed', - 'Call Dana immediately to report the issue', - 'Wait until morning shift to investigate', - 'Reboot all equipment and hope it fixes itself', + 'Call Dana now - report the ticket, then hold until she directs the troubleshooting', + 'Wait for morning shift - log the ticket, keep monitoring, let the day crew investigate', + 'Power cycle everything - reboot LNB, BUC, HPA and modems, then see if the dropouts stop', ], correctIndex: 0, explanation: 'Systematic troubleshooting is the professional approach. You have the skills to investigate this independently.', @@ -273,14 +268,10 @@ export const scenario8Data: ScenarioData = { params: { character: Character.SYSTEM, question: 'What alarm is displayed on the Dashboard?', - options: [ - 'LNB Reference Unlocked', - 'BUC Over-Temperature', - 'HPA Output Fault', - 'No active alarms', - ], + options: ['LNB Reference Unlocked', 'BUC Over-Temperature', 'HPA Output Fault', 'No active alarms'], correctIndex: 0, - explanation: 'The LNB shows a reference unlock condition. This means the LNB is not locked to the GPSDO 10 MHz reference, which can cause frequency drift and degraded receive performance.', + explanation: + 'The LNB shows a reference unlock condition. This means the LNB is not locked to the GPSDO 10 MHz reference, which can cause frequency drift and degraded receive performance.', pointPenalty: 10, }, mustMaintain: false, @@ -319,12 +310,13 @@ export const scenario8Data: ScenarioData = { question: 'The GPSDO shows locked status. What does this tell you about the LNB reference unlock alarm?', options: [ 'The 10 MHz reference is available - the problem is likely the cable or LNB input', - 'The GPSDO is faulty and causing the LNB problem', - 'The LNB alarm is a false positive', - 'We need to restart the GPSDO', + 'The 10 MHz reference is faulty - the GPSDO lock indicator is masking a bad output', + 'The LNB alarm is a false positive - a locked GPSDO means the LNB must be locked too', + 'The GPSDO needs a restart - the LNB will re-lock once the reference is re-acquired', ], correctIndex: 0, - explanation: 'The GPSDO is generating a valid 10 MHz reference. Since the LNB shows unlocked, the issue is downstream - either the reference cable to the LNB or the LNB reference input itself.', + explanation: + 'The GPSDO is generating a valid 10 MHz reference. Since the LNB shows unlocked, the issue is downstream - either the reference cable to the LNB or the LNB reference input itself.', pointPenalty: 10, }, maintainUntilObjectiveComplete: true, @@ -367,12 +359,13 @@ export const scenario8Data: ScenarioData = { question: 'The LNB is powered but shows reference unlocked. What is the most likely corrective action?', options: [ 'Power cycle the LNB to re-acquire the external reference', - 'Replace the LNB immediately', - 'Increase LNB gain to compensate', - 'Switch to internal oscillator mode', + 'Replace the LNB since the reference input has failed', + 'Increase the LNB gain to compensate for the lost reference', + 'Switch the LNB to its internal oscillator to bypass the reference', ], correctIndex: 0, - explanation: 'Power cycling the LNB will force it to re-acquire the external 10 MHz reference. This is a common fix for reference lock issues, especially after thermal cycling or power glitches.', + explanation: + 'Power cycling the LNB will force it to re-acquire the external 10 MHz reference. This is a common fix for reference lock issues, especially after thermal cycling or power glitches.', pointPenalty: 10, }, mustMaintain: false, @@ -463,12 +456,7 @@ export const scenario8Data: ScenarioData = { params: { character: Character.SYSTEM, question: 'AURORA-7 beacon is at 4165 MHz RF. The LNB LO is 5250 MHz. What IF frequency should you tune the spectrum analyzer to?', - options: [ - '1085 MHz', - '9415 MHz', - '915 MHz', - '4165 MHz', - ], + options: ['1085 MHz', '9415 MHz', '915 MHz', '4165 MHz'], correctIndex: 0, explanation: 'IF = LO - RF = 5250 - 4165 = 1085 MHz. The LNB downconverts by subtracting the RF frequency from the LO frequency.', pointPenalty: 15, @@ -542,7 +530,7 @@ export const scenario8Data: ScenarioData = { rbw: null, }, maintainUntilObjectiveComplete: true, - } + }, ], conditionLogic: 'AND', points: 10, @@ -575,12 +563,13 @@ export const scenario8Data: ScenarioData = { question: 'AURORA-7 is a legacy satellite with an inclined orbit. The antenna is in program-track mode. Why might this cause tracking problems?', options: [ 'Inclined orbits require step-track to follow satellite drift - program-track cannot compensate', - 'Program-track mode is only for LEO satellites', - 'The antenna motors are too slow for program-track', - 'Program-track requires manual polarization adjustment', + 'Program-track is only valid for LEO satellites - a GEO bird needs step-track regardless of orbit', + 'The antenna motors are too slow for program-track - step-track moves the dish in smaller steps', + 'Program-track needs manual polarization updates - step-track adjusts polarization on its own', ], correctIndex: 0, - explanation: 'AURORA-7 has stopped north-south station-keeping, causing its orbit to become inclined. This makes the satellite trace a figure-8 pattern in the sky. Program-track follows predicted positions, but step-track actively hunts for peak signal, which is required for drifting satellites.', + explanation: + 'AURORA-7 has stopped north-south station-keeping, causing its orbit to become inclined. This makes the satellite trace a figure-8 pattern in the sky. Program-track follows predicted positions, but step-track actively hunts for peak signal, which is required for drifting satellites.', pointPenalty: 15, }, mustMaintain: false, @@ -671,12 +660,13 @@ export const scenario8Data: ScenarioData = { question: 'Weather service reports freezing rain expected in 2 hours. AURORA-7 link is now stable. What is the appropriate action?', options: [ 'Enable feed heater now as a precaution, continue monitoring the link', - 'Immediately stow the antenna to protect it', - 'Call Dana to report the weather forecast', - 'Ignore the weather alert - it is 2 hours away', + 'Stow the antenna now as a precaution, accept the link outage until it passes', + 'Call Dana now with the forecast, let her decide whether to stow or heat', + 'Ignore the alert for now since it is 2 hours out, revisit when the rain starts', ], correctIndex: 0, - explanation: 'Enabling the feed heater proactively prevents ice accumulation. The link is stable so there is no need to stow yet, but preparing for weather is good practice. This is not urgent enough to wake Dana at 2 AM.', + explanation: + 'Enabling the feed heater proactively prevents ice accumulation. The link is stable so there is no need to stow yet, but preparing for weather is good practice. This is not urgent enough to wake Dana at 2 AM.', pointPenalty: 10, }, mustMaintain: false, @@ -743,12 +733,13 @@ export const scenario8Data: ScenarioData = { question: 'The customer still reports intermittent errors. The ACU shows stable step-track with beacon lock. What does this tell you?', options: [ 'The antenna is tracking properly - the intermittent issue is not caused by tracking problems', - 'The beacon frequency is drifting and causing lock instability', - 'The polarization needs to be adjusted for the inclined orbit', - 'Step-track mode is inadequate for AURORA-7', + 'The beacon frequency is drifting - the intermittent issue is caused by lock instability', + 'The polarization is misaligned - the inclined orbit needs a manual polarization adjustment', + 'Step-track is inadequate for AURORA-7 - the intermittent issue is the antenna hunting the peak', ], correctIndex: 0, - explanation: 'With stable beacon lock in step-track mode, we can rule out antenna tracking as the cause of intermittent errors. The antenna is correctly following the satellite. We need to look elsewhere.', + explanation: + 'With stable beacon lock in step-track mode, we can rule out antenna tracking as the cause of intermittent errors. The antenna is correctly following the satellite. We need to look elsewhere.', pointPenalty: 10, }, mustMaintain: false, @@ -790,15 +781,16 @@ export const scenario8Data: ScenarioData = { description: 'RX Path Analysis', params: { character: Character.SYSTEM, - question: 'The receiver shows stable lock with good C/N. What does this indicate about the customer\'s intermittent errors?', + question: "The receiver shows stable lock with good C/N. What does this indicate about the customer's intermittent errors?", options: [ 'The RX path is healthy - the problem must be in the transmit direction', - 'The receiver is masking the real problem with AGC', - 'We need to check the LNB temperature before concluding', - 'The C/N margin is still too low for reliable service', + 'The AGC is masking a fault - the problem is still in the receive direction', + 'The LNB temperature is unknown - the problem cannot be isolated yet', + 'The C/N margin is still too low - the problem is receive-side noise', ], correctIndex: 0, - explanation: 'With stable receiver lock and good C/N, the downlink (RX) path is working correctly. Since the customer reports bidirectional issues, and RX is healthy, the problem must be in the uplink (TX) direction.', + explanation: + 'With stable receiver lock and good C/N, the downlink (RX) path is working correctly. Since the customer reports bidirectional issues, and RX is healthy, the problem must be in the uplink (TX) direction.', pointPenalty: 10, }, mustMaintain: false, @@ -831,12 +823,13 @@ export const scenario8Data: ScenarioData = { question: 'When investigating an intermittent TX fault, which indicator would show evidence of the problem?', options: [ 'The modem Output Power display - it shows DROPOUT during fault periods', - 'The HPA reflected power - it increases during modem faults', - 'The BUC temperature - it spikes during signal dropouts', - 'The GPSDO holdover counter - it increments during TX faults', + 'The HPA reflected power readout - it climbs during the modem fault periods', + 'The BUC temperature readout - it spikes during each signal dropout', + 'The GPSDO holdover counter - it increments during each TX fault period', ], correctIndex: 0, - explanation: 'The TX modem Output Power display directly indicates when an intermittent hardware fault causes a signal dropout. During fault periods, the display shows "DROPOUT" in red, making it easy to identify the source of the problem.', + explanation: + 'The TX modem Output Power display directly indicates when an intermittent hardware fault causes a signal dropout. During fault periods, the display shows "DROPOUT" in red, making it easy to identify the source of the problem.', pointPenalty: 10, }, mustMaintain: false, @@ -891,12 +884,7 @@ export const scenario8Data: ScenarioData = { params: { character: Character.SYSTEM, question: 'AURORA-7 uplink RF is 7100 MHz. The BUC LO is 6053 MHz. What TX IF frequency is required?', - options: [ - '1047 MHz', - '13153 MHz', - '1043 MHz', - '6000 MHz', - ], + options: ['1047 MHz', '13153 MHz', '1043 MHz', '6000 MHz'], correctIndex: 0, explanation: 'TX IF = RF - BUC LO = 7100 - 6053 = 1047 MHz. The BUC upconverts by adding the LO frequency to the IF.', pointPenalty: 15, @@ -1014,12 +1002,13 @@ export const scenario8Data: ScenarioData = { question: 'Why must BUC loopback be enabled BEFORE starting transmission on a new modem?', options: [ 'To prevent accidental RF transmission through the HPA until the new modem is verified', - 'To reduce power consumption during testing', - 'To improve signal quality measurements', - 'To synchronize the modem clock with the BUC', + 'To reduce power consumption in the BUC and HPA until the new modem is verified', + 'To give the spectrum analyzer a cleaner signal to measure while the new modem is verified', + 'To let the new modem clock synchronize to the BUC before the HPA is engaged', ], correctIndex: 0, - explanation: 'Enabling loopback before transmission ensures the signal is routed back to the receiver for testing, rather than going to the HPA and antenna. This prevents accidental RF transmission until the new modem configuration is verified.', + explanation: + 'Enabling loopback before transmission ensures the signal is routed back to the receiver for testing, rather than going to the HPA and antenna. This prevents accidental RF transmission until the new modem configuration is verified.', pointPenalty: 10, }, mustMaintain: false, @@ -1063,9 +1052,9 @@ export const scenario8Data: ScenarioData = { question: 'What does BUC loopback test that modem loopback does not?', options: [ 'The full signal path from modem through BUC, without engaging the HPA', - 'The HPA output power level', - 'The antenna pointing accuracy', - 'The satellite transponder response', + 'The HPA output stage and its power level, without radiating from the antenna', + 'The antenna pointing accuracy, without transmitting to the satellite', + 'The satellite transponder response, without the customer traffic loaded', ], correctIndex: 0, explanation: 'BUC loopback tests the complete modem-to-BUC signal path while keeping the HPA disengaged. This verifies the full low-power TX chain.', @@ -1081,7 +1070,8 @@ export const scenario8Data: ScenarioData = { id: 'configure-lnb-for-loopback', nice: ['T0153', 'K0740'], title: 'Configure LNB for Loopback Test', - description: 'Set the LNB LO to 7000 MHz to view the BUC loopback signal. The BUC LO is 7100 MHz, so the 1047 MHz TX IF becomes 6053 MHz RF, which downconverts to 947 MHz with a 7000 MHz LNB.', + description: + 'Set the LNB LO to 7000 MHz to view the BUC loopback signal. The BUC LO is 7100 MHz, so the 1047 MHz TX IF becomes 6053 MHz RF, which downconverts to 947 MHz with a 7000 MHz LNB.', groundStation: 'VT-01', prerequisiteObjectiveIds: ['test-modem2-loopback'], timeLimitSeconds: 2 * 60, @@ -1139,12 +1129,13 @@ export const scenario8Data: ScenarioData = { question: 'What should you observe on the spectrum analyzer at 947 MHz?', options: [ 'A 24 MHz wide modulated signal - confirming TX chain is working', - 'A narrow CW spike like the beacon', - 'No signal - loopback does not produce visible output', - 'The AURORA-7 beacon signal', + 'A narrow CW spike like the beacon - confirming the BUC LO is present', + 'No signal at all on the display - loopback does not produce visible output', + 'The AURORA-7 beacon at 947 MHz - confirming the LNB LO change took effect', ], correctIndex: 0, - explanation: 'The loopback signal appears as a 24 MHz wide modulated carrier at 947 MHz (TX IF 1047 MHz upconverted by BUC LO 7100 MHz to 6053 MHz RF, then downconverted by LNB LO 7000 MHz). This confirms Modem 2 and the BUC are working correctly.', + explanation: + 'The loopback signal appears as a 24 MHz wide modulated carrier at 947 MHz (TX IF 1047 MHz upconverted by BUC LO 7100 MHz to 6053 MHz RF, then downconverted by LNB LO 7000 MHz). This confirms Modem 2 and the BUC are working correctly.', pointPenalty: 10, }, mustMaintain: false, @@ -1268,14 +1259,10 @@ export const scenario8Data: ScenarioData = { params: { character: Character.SYSTEM, question: 'What indicators confirm the AURORA-7 link is now operational?', - options: [ - 'No active alarms on Dashboard', - 'Antenna in step-track with beacon lock', - 'HPA enabled and BUC unmuted', - 'All of the above', - ], + options: ['No active alarms on Dashboard', 'Antenna in step-track with beacon lock', 'HPA enabled and BUC unmuted', 'All of the above'], correctIndex: 3, - explanation: 'A fully operational link shows: no Dashboard alarms, antenna tracking with beacon lock (step-track for inclined orbit), and active TX chain (HPA enabled, BUC unmuted). All conditions must be met.', + explanation: + 'A fully operational link shows: no Dashboard alarms, antenna tracking with beacon lock (step-track for inclined orbit), and active TX chain (HPA enabled, BUC unmuted). All conditions must be met.', pointPenalty: 10, preserveOptionOrder: true, }, @@ -1302,13 +1289,14 @@ export const scenario8Data: ScenarioData = { character: Character.SYSTEM, question: 'Which summary correctly describes the root cause and resolution?', options: [ - 'LNB reference unlock caused RX degradation; program-track inadequate for inclined orbit; TX Modem 1 intermittent fault. Fixed by power cycling LNB, enabling step-track, and switching to Modem 2.', - 'HPA fault caused TX failure; fixed by replacing the HPA tube.', - 'Weather degradation caused link loss; handed over to backup station.', - 'Customer equipment issue; no action required at ground station.', + 'LNB reference unlock, program-track drift, Modem 1 fault; fixed by LNB power cycle, step-track, Modem 2', + 'HPA output fault, program-track drift, Modem 1 fault; fixed by HPA tube swap, step-track, Modem 2', + 'Freezing rain fade, LNB reference unlock, feed icing; fixed by feed heater, LNB power cycle, backup station', + 'Customer modem fault, program-track drift, GPSDO holdover; fixed by customer reset, step-track, GPSDO restart', ], correctIndex: 0, - explanation: 'The customer intermittent connectivity had three causes: (1) LNB reference unlock degraded receive quality, (2) program-track mode could not follow AURORA-7\'s inclined orbit drift, (3) TX Modem 1 had an intermittent hardware fault. All were resolved without waking Dana or escalating.', + explanation: + "The customer intermittent connectivity had three causes: (1) LNB reference unlock degraded receive quality, (2) program-track mode could not follow AURORA-7's inclined orbit drift, (3) TX Modem 1 had an intermittent hardware fault. All were resolved without waking Dana or escalating.", pointPenalty: 15, }, mustMaintain: false, diff --git a/src/campaigns/nats/scenario9.ts b/src/campaigns/nats/scenario9.ts index 7cad9528..a567c202 100644 --- a/src/campaigns/nats/scenario9.ts +++ b/src/campaigns/nats/scenario9.ts @@ -1,3 +1,4 @@ +import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import type { AntennaState } from '@app/equipment/antenna'; import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; import { Receiver } from '@app/equipment/receiver/receiver'; @@ -7,7 +8,6 @@ import type { ScenarioData } from '@app/ScenarioData'; import type { dB, dBm, Hertz } from '@app/types'; import { getAssetUrl } from '@app/utils/asset-url'; import type { Degrees } from 'ootk'; -import { createRfFrontEnd } from '@app/campaigns/rf-front-end-factory'; import { vermontGroundStation } from './ground-stations'; import { ses10Satellite, tidemark1Satellite, tidemark2Satellite, tidemark3Satellite } from './satellites'; @@ -62,13 +62,7 @@ export const scenario9Data: ScenarioData = { difficulty: 'intermediate', missionType: 'Routine Operations', description: `First qualified shift. Three birds to check this morning: TIDEMARK-1 from Vermont, TIDEMARK-2 from Maine, and a spot-check on the newly commissioned TIDEMARK-3.

Standard morning rounds - verify each link is healthy, log any anomalies, move on. The overnight commissioning crew brought TIDEMARK-3 online a few hours ago; the only thing on your list is a quick beacon verification before our customers start lighting it up.

No drama today. Just routine.`, - equipment: [ - '9-meter C-band Antenna', - 'RF Front End', - 'Spectrum Analyzer', - 'RX/TX Modems', - 'ME-02: Operational', - ], + equipment: ['9-meter C-band Antenna', 'RF Front End', 'Spectrum Analyzer', 'RX/TX Modems', 'ME-02: Operational'], timeLimitSeconds: 25 * 60, // 25 minutes settings: { isSync: true, @@ -108,7 +102,7 @@ export const scenario9Data: ScenarioData = { ], rfFrontEnds: [ createRfFrontEnd(vermontGroundStation.rfFrontEnds[0], { - // Inherits VT-01 LNB LO 5250 MHz so the TM-2 beacon lands at 1070 MHz IF + // Inherits VT-01 LNB LO 5250 MHz so the TM-2 beacon comes out at 1070 MHz IF // Maine TX is muted - we're not transmitting from ME-02 in this scenario buc: { isMuted: true }, hpa: { isHpaEnabled: false, isHpaSwitchEnabled: false }, @@ -138,12 +132,7 @@ export const scenario9Data: ScenarioData = { receivers: [Receiver.getDefaultState()], }, ], - satellites: [ - tidemark1Satellite, - tidemark2Satellite, - tidemark3Satellite, - ses10Satellite, - ], + satellites: [tidemark1Satellite, tidemark2Satellite, tidemark3Satellite, ses10Satellite], missionBriefUrl: 'https://docs.signalrange.space/campaign-1/scenario-9?content-only=true&dark=true', isExtraSatellitesVisible: true, }, @@ -232,9 +221,9 @@ export const scenario9Data: ScenarioData = { question: 'What is the active alarm state on VT-01?', options: [ 'No active alarms - all systems nominal', - 'BUC high current draw', - 'LNB reference unlocked', - 'HPA output fault', + 'BUC high current draw - TX chain alarm', + 'LNB reference unlocked - RX chain alarm', + 'HPA output fault - transmit path alarm', ], correctIndex: 0, explanation: 'Clean board on the primary station. Moving on to timing.', @@ -277,9 +266,9 @@ export const scenario9Data: ScenarioData = { question: 'What does the GPSDO lock indicator confirm?', options: [ 'Stable 10 MHz reference available to all downstream RF equipment', - 'GPS receiver is searching for satellites', - 'External reference is in holdover', - 'GPSDO is powered off', + 'GPS receiver has enough satellites to start disciplining the oscillator', + 'External reference in holdover, oscillator free-running on last correction', + '1 PPS output present but the 10 MHz output is still warming up', ], correctIndex: 0, explanation: 'Reference is stable. RX/TX chains can trust their frequencies.', @@ -366,9 +355,9 @@ export const scenario9Data: ScenarioData = { question: 'What does the 10 dB HPA backoff during routine ops indicate?', options: [ 'Standard operating margin - reduces stress on the amplifier', - 'HPA is faulted and limiting itself', - 'Customer requested low power', - 'Output stage saturated and clipping', + 'HPA fault protection - the amplifier is limiting its own output', + 'Customer requested setting - low power for a light traffic load', + 'Output stage saturation - the amplifier is clipping at its peak', ], correctIndex: 0, explanation: 'Headroom is the goal. No drama on TIDEMARK-1.', @@ -429,9 +418,9 @@ export const scenario9Data: ScenarioData = { question: 'What is the alarm state on ME-02?', options: [ 'No active alarms - station nominal', - 'Antenna tracking lost', - 'GPSDO holdover', - 'LNB over-temperature', + 'Antenna tracking lost - ACU alarm active', + 'GPSDO in holdover - timing alarm', + 'LNB over-temperature - RX chain alarm', ], correctIndex: 0, explanation: 'Maine is clean. Catherine has the station in good shape.', @@ -518,9 +507,9 @@ export const scenario9Data: ScenarioData = { question: 'Why is program-track the appropriate mode for TIDEMARK-2 right now?', options: [ 'GEO satellite holding station - ephemeris is accurate enough; no need to hunt the beacon', - 'Step-track is unavailable on ME-02 hardware', - 'Program-track uses less antenna motion which extends motor life', - 'TIDEMARK-2 is in an inclined orbit and requires program-track', + 'Step-track unavailable on ME-02 - ACU has no beacon receiver; program-track is the only mode', + 'Program-track moves the dish less - fewer motor cycles; saves the drive on a long shift', + 'TIDEMARK-2 is in an inclined orbit - figure-8 drift is predictable; program-track follows it', ], correctIndex: 0, explanation: 'For a healthy GEO bird with good TLE, program-track is the right default. Save step-track for inclined birds like AURORA-7.', @@ -550,9 +539,9 @@ export const scenario9Data: ScenarioData = { question: 'On a routine spot check, which combination of indicators confirms the downlink data path is healthy?', options: [ 'Frame sync locked + CRC valid + FEC engaged (Reed-Solomon active, no uncorrectables)', - 'High RX power alone is sufficient - if power is good, data is good', - 'Antenna beacon lock is sufficient confirmation of data integrity', - 'Modem temperature within range proves the data path is healthy', + 'RX power high + AGC mid-range + C/N above threshold (strong carrier, data must follow)', + 'Beacon lock + antenna on peak + program-track holding (RF path proven, data follows)', + 'Modem temperature in range + fans nominal + no hardware alarms (healthy unit, clean data)', ], correctIndex: 0, explanation: 'Beacon lock proves the RF path. Frame sync + CRC + FEC prove the data path. Different layers, different evidence.', @@ -680,9 +669,9 @@ export const scenario9Data: ScenarioData = { question: 'TIDEMARK-3 beacon RF is 4172 MHz. With VT-01 LNB LO at 5250 MHz, seeing the beacon at 1078 MHz IF confirms what?', options: [ 'Antenna pointing is correct AND LNB LO is set correctly (5250 - 4172 = 1078)', - 'Only the antenna pointing - LO has no effect on where the beacon appears', - 'Only the LO - antenna pointing is verified by separate means', - 'Nothing meaningful - any noise spike at 1078 would look like this', + 'Only the antenna pointing is correct (the beacon sits at 1078 whatever LO the LNB uses)', + 'Only the LNB LO is correct (5250 - 4172 = 1078 works whether or not the dish is on TM-3)', + 'Nothing about the chain (any noise spike near 1078 would look the same on a 5250 LO)', ], correctIndex: 0, explanation: 'Both have to be right. Wrong pointing = no signal. Wrong LO = signal at a different IF.', @@ -752,9 +741,9 @@ export const scenario9Data: ScenarioData = { question: 'Summarize the morning round result.', options: [ 'TIDEMARK-1 healthy, TIDEMARK-2 healthy, TIDEMARK-3 beacon verified - all three nominal', - 'TIDEMARK-1 degraded, TIDEMARK-2 nominal, TIDEMARK-3 offline', - 'TIDEMARK-1 nominal, TIDEMARK-2 not yet checked, TIDEMARK-3 verified', - 'All three birds in fault state', + 'TIDEMARK-1 degraded, TIDEMARK-2 healthy, TIDEMARK-3 beacon absent - one bird offline', + 'TIDEMARK-1 healthy, TIDEMARK-2 not yet checked, TIDEMARK-3 beacon verified - round open', + 'TIDEMARK-1 healthy, TIDEMARK-2 healthy, TIDEMARK-3 not yet verified - spot-check deferred', ], correctIndex: 0, explanation: 'Clean round. Log it and move on.', @@ -784,9 +773,9 @@ export const scenario9Data: ScenarioData = { question: 'Which line correctly records this shift in the operations log?', options: [ '0700 - Morning rounds complete. VT-01/TM-1, ME-02/TM-2, TM-3 beacon verified via VT-01 spot-check. No anomalies.', - '0700 - Morning rounds incomplete. TM-3 spot-check deferred.', - '0700 - Multiple alarms cleared. See trouble ticket.', - '0700 - Antenna swap performed VT-01 to ME-02.', + '0700 - Morning rounds incomplete. VT-01/TM-1, ME-02/TM-2 checked, TM-3 spot-check deferred to next shift. No anomalies.', + '0700 - Morning rounds complete. VT-01/TM-1, ME-02/TM-2 checked, multiple alarms cleared on VT-01. See trouble ticket.', + '0700 - Morning rounds complete. VT-01 repointed to TM-3 for spot-check, TM-1 traffic swapped to ME-02. No anomalies.', ], correctIndex: 0, explanation: 'Routine work logged routinely. Next operator picks up with full context.', diff --git a/src/campaigns/release-stage.ts b/src/campaigns/release-stage.ts new file mode 100644 index 00000000..8eb92829 --- /dev/null +++ b/src/campaigns/release-stage.ts @@ -0,0 +1,131 @@ +import { html } from '@app/engine/utils/development/formatter'; +import type { CampaignData, ReleaseStage } from './campaign-types'; + +/** + * Release-stage disclaimers for campaigns that are not finished yet. + * + * Campaigns ship on very different footings - Campaign 1 is done, Campaign 2 is + * feature-complete but still under test, and Campaigns 3-5 are being actively + * built and can break saved progress from one build to the next. All five are + * registered and playable, so the only thing telling a player which is which is + * the copy in here. Every string a player reads about beta/alpha status is + * authored once in this module and rendered by both the campaign grid and the + * per-campaign scenario list. + */ + +/** Feedback form on the marketing site (SignalRange domain, KeepTrack mailroom). */ +export const FEEDBACK_CONTACT_URL = 'https://signalrange.space/contact-us'; + +/** Community Discord - same invite the app header links to. */ +export const FEEDBACK_DISCORD_URL = 'https://discord.gg/hr6jUHEgPB'; + +/** Campaigns we point players at when they want something finished. */ +const POLISHED_CAMPAIGN_TEXT = 'North Atlantic Teleport Services (Campaign 1)'; + +/** + * A campaign with no explicit stage is treated as finished, so Campaign 1 needs + * no annotation and any future campaign has to opt in to a warning. + */ +export function getReleaseStage(campaign: CampaignData): ReleaseStage { + return campaign.releaseStage ?? 'stable'; +} + +/** + * Short badge shown beside difficulty/duration on a campaign card. + * Returns an empty string for stable campaigns so the badge row is unchanged. + */ +export function renderReleaseBadge(stage: ReleaseStage): string { + switch (stage) { + case 'beta': + return html`Public Beta`; + case 'alpha': + return html`Alpha`; + default: + return ''; + } +} + +/** + * One-line warning strip inside a campaign card. + * + * Deliberately link-free: the whole card is a click target that navigates to + * the campaign, so an anchor here would fire both the link and the navigation. + * The reporting links live in the callout on the scenario list instead. + */ +export function renderReleaseCardNotice(stage: ReleaseStage): string { + switch (stage) { + case 'beta': + return html` +
+ ⚠ + + Public beta. Playable start to finish, but still under test - you may + hit errors. Bug reports welcome. + +
+ `; + case 'alpha': + return html` +
+ ⚠ + + Alpha build, in active development. Expect missing content and errors. + Future changes are likely to invalidate saved progress. + +
+ `; + default: + return ''; + } +} + +/** + * Full disclaimer shown under the campaign title on its scenario list - the last + * screen before a player commits to a scenario, and the place the reporting + * links can safely live. + */ +export function renderReleaseCallout(stage: ReleaseStage): string { + if (stage === 'stable') { + return ''; + } + + const links = html` +
+ `; + + if (stage === 'beta') { + return html` +
+
+ ⚠ Public Beta +
+

+ This campaign is complete and playable from start to finish, but it is still under public + test and some things are still wrong. If a scenario stalls, an objective refuses to + complete, or a reading looks impossible, that is a bug and we want to hear about it. + Saved progress is expected to carry forward. +

+ ${links} +
+ `; + } + + return html` +
+
+ ⚠ Alpha Build - Active Development +
+

+ This campaign is still being built. It is incomplete, it contains known errors, and it is + likely to change - including changes that invalidate saved progress, so treat anything you + complete here as temporary. You are very welcome to explore what is coming and tell us what + you find. If you want a more polished experience, we recommend ${POLISHED_CAMPAIGN_TEXT} or + North Atlantic Teleport Services EU (Campaign 2) for now. +

+ ${links} +
+ `; +} diff --git a/src/campaigns/rf-front-end-factory.ts b/src/campaigns/rf-front-end-factory.ts index 982c222c..1dd4cb37 100644 --- a/src/campaigns/rf-front-end-factory.ts +++ b/src/campaigns/rf-front-end-factory.ts @@ -4,9 +4,11 @@ import type { RFFrontEndState } from '@app/equipment/rf-front-end/rf-front-end-c * Recursively makes all properties optional. * Allows deep partial overrides for nested state objects. */ -export type DeepPartial = T extends object ? { - [P in keyof T]?: DeepPartial; -} : T; +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; /** * RF Front End state without runtime-assigned properties. @@ -22,10 +24,7 @@ export type RfFrontEndConfig = Omit, - source: Record -): Record { +function deepMerge(target: Record, source: Record): Record { const result = { ...target }; for (const key of Object.keys(source)) { @@ -42,10 +41,7 @@ function deepMerge( !Array.isArray(targetValue) ) { // Recursively merge nested objects - result[key] = deepMerge( - targetValue as Record, - sourceValue as Record - ); + result[key] = deepMerge(targetValue as Record, sourceValue as Record); } else { // Replace primitives, arrays, and null values result[key] = sourceValue; @@ -76,9 +72,6 @@ function deepMerge( * filter: { bandwidthIndex: 0 } * }) */ -export function createRfFrontEnd( - base: Partial, - overrides: DeepPartial = {} -): Partial { +export function createRfFrontEnd(base: Partial, overrides: DeepPartial = {}): Partial { return deepMerge(base as RfFrontEndConfig, overrides); } diff --git a/src/campaigns/satellite-config-factory.ts b/src/campaigns/satellite-config-factory.ts index 296b62e7..6da811a2 100644 --- a/src/campaigns/satellite-config-factory.ts +++ b/src/campaigns/satellite-config-factory.ts @@ -101,13 +101,10 @@ const DEFAULTS = { * @throws Error if transponder not found */ function getTransponder(satellite: Satellite, transponderId: string): Transponder { - const transponder = satellite.transponders.find(tp => tp.id === transponderId); + const transponder = satellite.transponders.find((tp) => tp.id === transponderId); if (!transponder) { - const availableIds = satellite.transponders.map(tp => tp.id).join(', '); - throw new Error( - `Transponder '${transponderId}' not found on satellite ${satellite.name}. ` + - `Available transponders: ${availableIds}` - ); + const availableIds = satellite.transponders.map((tp) => tp.id).join(', '); + throw new Error(`Transponder '${transponderId}' not found on satellite ${satellite.name}. ` + `Available transponders: ${availableIds}`); } return transponder; } @@ -161,10 +158,7 @@ function calculateIfFrequency(rfHz: number, loMhz: MHz): number { * buc: { loFrequency: config.bucLoFrequency }, * }); */ -export function configureGroundStationForSatellite( - satellite: Satellite, - options: SatelliteConfigOptions = {} -): SatelliteConfigResult { +export function configureGroundStationForSatellite(satellite: Satellite, options: SatelliteConfigOptions = {}): SatelliteConfigResult { const transponderId = options.transponderId ?? DEFAULTS.transponderId; const lnbLo = options.lnbLoFrequency ?? DEFAULTS.lnbLoFrequency; @@ -263,17 +257,8 @@ export interface ApplyConfigOptions { * satConfig * ); */ -export function applyConfigToGroundStation( - groundStation: GroundStationConfig, - satConfig: SatelliteConfigResult, - options: ApplyConfigOptions = {} -): GroundStationConfig { - const { - antennaIndex = 0, - rfFrontEndIndex = 0, - createTransmitter = true, - createReceiver = true, - } = options; +export function applyConfigToGroundStation(groundStation: GroundStationConfig, satConfig: SatelliteConfigResult, options: ApplyConfigOptions = {}): GroundStationConfig { + const { antennaIndex = 0, rfFrontEndIndex = 0, createTransmitter = true, createReceiver = true } = options; // Clone ground station to avoid mutation const result = { ...groundStation }; @@ -297,13 +282,10 @@ export function applyConfigToGroundStation( // Update RF front-end (BUC LO) if (groundStation.rfFrontEnds?.[rfFrontEndIndex]) { result.rfFrontEnds = [...groundStation.rfFrontEnds]; - result.rfFrontEnds[rfFrontEndIndex] = createRfFrontEnd( - groundStation.rfFrontEnds[rfFrontEndIndex], - { - buc: { loFrequency: satConfig.bucLoFrequency }, - lnb: { loFrequency: satConfig.lnbLoFrequency }, - } - ); + result.rfFrontEnds[rfFrontEndIndex] = createRfFrontEnd(groundStation.rfFrontEnds[rfFrontEndIndex], { + buc: { loFrequency: satConfig.bucLoFrequency }, + lnb: { loFrequency: satConfig.lnbLoFrequency }, + }); } // Update spectrum analyzer @@ -320,52 +302,60 @@ export function applyConfigToGroundStation( const existingTx = groundStation.transmitters?.[0]; const existingModem = existingTx?.modems?.[0]; - result.transmitters = [{ - activeModem: 1, - modems: [{ - isPowered: true, - antenna_id: 1, - modem_number: 1, - isFaulted: false, - isTransmitting: existingModem?.isTransmitting ?? false, - isTransmittingSwitchUp: existingModem?.isTransmittingSwitchUp ?? false, - isFaultSwitchUp: false, - id: 1, - isLoopback: false, - ifSignal: { - signalId: `${satConfig.txModem.noradId}-Teleport`, - serverId: 1, - noradId: satConfig.txModem.noradId, - polarization: 'V', - feed: '', - isDegraded: false, - origin: SignalOrigin.TRANSMITTER, - noiseFloor: null, - gainInPath: 0 as dBi, - frequency: satConfig.txModem.frequency, - power: satConfig.txModem.power, - bandwidth: satConfig.txModem.bandwidth, - modulation: satConfig.txModem.modulation, - fec: satConfig.txModem.fec, - }, - }], - }]; + result.transmitters = [ + { + activeModem: 1, + modems: [ + { + isPowered: true, + antenna_id: 1, + modem_number: 1, + isFaulted: false, + isTransmitting: existingModem?.isTransmitting ?? false, + isTransmittingSwitchUp: existingModem?.isTransmittingSwitchUp ?? false, + isFaultSwitchUp: false, + id: 1, + isLoopback: false, + ifSignal: { + signalId: `${satConfig.txModem.noradId}-Teleport`, + serverId: 1, + noradId: satConfig.txModem.noradId, + polarization: 'V', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + frequency: satConfig.txModem.frequency, + power: satConfig.txModem.power, + bandwidth: satConfig.txModem.bandwidth, + modulation: satConfig.txModem.modulation, + fec: satConfig.txModem.fec, + }, + }, + ], + }, + ]; } // Update or create receiver if (createReceiver) { - result.receivers = [{ - activeModem: 1, - modems: [{ - modemNumber: 1, - isPowered: true, - frequency: satConfig.rxModem.frequency, - bandwidth: satConfig.rxModem.bandwidth, - modulation: satConfig.rxModem.modulation, - fec: satConfig.rxModem.fec, - antenna_id: 1, - }], - }]; + result.receivers = [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: satConfig.rxModem.frequency, + bandwidth: satConfig.rxModem.bandwidth, + modulation: satConfig.rxModem.modulation, + fec: satConfig.rxModem.fec, + antenna_id: 1, + }, + ], + }, + ]; } return result; diff --git a/src/campaigns/signal-hunter/ground-stations.ts b/src/campaigns/signal-hunter/ground-stations.ts new file mode 100644 index 00000000..78994828 --- /dev/null +++ b/src/campaigns/signal-hunter/ground-stations.ts @@ -0,0 +1,204 @@ +import type { GroundStationConfig } from '@app/assets/ground-station/ground-station-state'; +import { FrequencyBand } from '@app/constants'; +import { type AntennaState } from '@app/equipment/antenna'; +import { ANTENNA_CONFIG_KEYS } from '@app/equipment/antenna/antenna-config-keys'; +import { type CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import type { dB, dBm, Hertz, MHz } from '@app/types'; +import type { Degrees } from 'ootk'; + +/** + * Campaign 5 (Signal Hunter) - Peterson Annex (PA-22) + * + * 22nd Electronic Warfare Squadron field site on the Colorado plains. A C-band + * 9m dish already program-tracking the victim SENTRY-7 and receiving its + * allied service carrier (3785 MHz -> 1365 MHz IF, LNB LO 5150). The spectrum + * analyzer opens on a wide span so the operator can see the intermittent + * hostile carrier a few MHz off the friendly one. + */ +export const petersonGroundStation = { + id: 'PA-22', + name: 'Peterson Annex', + location: { + latitude: 38.82, + longitude: -104.7, + elevation: 1900, + }, + antennas: [ANTENNA_CONFIG_KEYS.C_BAND_9M_VORTEK], + antennaConfigKey: ANTENNA_CONFIG_KEYS.C_BAND_9M_VORTEK, + antennasState: [ + { + // Program-tracking SENTRY-7 (az 172.5 / el 47.1 from PA-22 at T+0) + isPowered: true, + azimuth: 172.5 as Degrees, + elevation: 47.1 as Degrees, + polarization: 0 as Degrees, + trackingMode: 'program-track', + isBeaconLocked: true, + targetSatelliteId: 71001, + targetAzimuth: 172.5 as Degrees, + targetElevation: 47.1 as Degrees, + targetPolarization: 0 as Degrees, + slewing: false, + beaconCN: 12 as dB, + beaconFrequencyHz: 3785e6 as Hertz, + isLocked: true, + } as Partial, + ], + rfFrontEnds: [ + { + omt: { + isPowered: true, + txPolarization: 'V', + rxPolarization: 'H', + effectiveTxPol: 'V', + effectiveRxPol: 'H', + crossPolIsolation: 28.5 as dB, + isFaulted: false, + insertionLoss: 0.5 as dB, + }, + buc: { + isPowered: true, + isMuted: true, + isLoopback: false, + temperature: 25, + currentDraw: 0, + loFrequency: 7000 as MHz, + filterHighHz: FrequencyBand.c.upHigh, + filterLowHz: FrequencyBand.c.upLow, + filterRejectionDb: 40 as dB, + isExtRefLocked: true, + frequencyError: 0, + phaseLockRange: 10000, + gain: 23 as dB, + outputPower: -10 as dBm, + saturationPower: 15 as dBm, + gainFlatness: 0.5 as dB, + groupDelay: 3, + phaseNoise: -100, + spuriousOutputs: [], + noiseFloor: -140, + }, + hpa: { + isPowered: true, + backOff: 10, + outputPower: 50 as dBm, + isOverdriven: false, + imdLevel: -30, + temperature: 45, + isHpaEnabled: false, + isHpaSwitchEnabled: false, + noiseFloor: -140, + gain: 44 as dB, + }, + filter: { + isPowered: true, + bandwidthIndex: 13, + bandwidth: 40 as MHz, + insertionLoss: 2.0, + noiseFloor: -101, + }, + lnb: { + isPowered: true, + loFrequency: 5150 as MHz, + gain: 65 as dB, + lnaNoiseFigure: 0.6, + mixerNoiseFigure: 16.0, + noiseTemperature: 43, + noiseTemperatureStabilizationTime: 0, + isExtRefLocked: true, + noiseFloor: -140, + frequencyError: 0, + temperature: 28, + thermalStabilizationTime: 0, + }, + agc: { + isPowered: true, + isBypassed: false, + targetLevel: -30 as dBm, + currentGain: 10 as dB, + inputPower: -80 as dBm, + outputPower: -30 as dBm, + attackTime: 10, + releaseTime: 100, + maxGain: 10 as dB, + minGain: -100 as dB, + }, + coupler: { + isEngineeringMode: false, + tapPointA: TapPoint.TX_IF, + tapPointB: TapPoint.RX_IF, + availableTapPointsA: [TapPoint.TX_IF, TapPoint.RX_IF], + availableTapPointsB: [TapPoint.TX_IF, TapPoint.RX_IF], + couplingFactorA: -40, + couplingFactorB: -39, + isEnabledA: false, + isEnabledB: true, + isActiveA: false, + isActiveB: true, + } as CouplerState, + gpsdo: { + isPowered: true, + isLocked: true, + warmupTimeRemaining: 0, + temperature: 70, + gnssSignalPresent: true, + isGnssSwitchUp: true, + isGnssAcquiringLock: false, + satelliteCount: 10, + utcAccuracy: 15, // ns - a tight timing reference is what makes TDOA viable + constellation: 'GPS', + lockDuration: 7200, + frequencyAccuracy: 2e-11, + allanDeviation: 1e-11, + phaseNoise: -110, + isInHoldover: false, + holdoverDuration: 0, + holdoverError: 0, + active10MHzOutputs: 3, + max10MHzOutputs: 5, + output10MHzLevel: 7, + ppsOutputsEnabled: true, + operatingHours: 8760, + selfTestPassed: true, + agingRate: 1e-10, + }, + }, + ], + spectrumAnalyzers: [ + { + referenceLevel: -60 as dBm, + centerFrequency: 1364e6 as Hertz, // Between the service carrier (1365) and jammer (1362) IFs + span: 30e6 as Hertz, + rbw: 100e3 as Hertz, + minAmplitude: -110 as dBm, + maxAmplitude: -50 as dBm, + scaleDbPerDiv: 10 as dB, + screenMode: 'both', + inputUnit: 'MHz', + inputValue: '', + traces: [ + { isVisible: true, isUpdating: true, mode: 'clearwrite' }, + { isVisible: true, isUpdating: true, mode: 'maxhold' }, // Catch the duty-cycled jammer + { isVisible: false, isUpdating: false, mode: 'clearwrite' }, + ], + selectedTrace: 1, + }, + ], + receivers: [ + { + activeModem: 1, + modems: [ + { + modemNumber: 1, + isPowered: true, + frequency: 1365 as MHz, // SENTRY-7 service carrier IF + bandwidth: 8 as MHz, + modulation: 'QPSK', + fec: '3/4', + antenna_id: 1, + }, + ], + }, + ], +} as GroundStationConfig; diff --git a/src/campaigns/signal-hunter/sandbox.ts b/src/campaigns/signal-hunter/sandbox.ts new file mode 100644 index 00000000..8fe05747 --- /dev/null +++ b/src/campaigns/signal-hunter/sandbox.ts @@ -0,0 +1,204 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { petersonGroundStation } from './ground-stations'; +import { sentry7Satellite, sentry9Satellite } from './satellites'; + +/** + * Campaign 5 (Signal Hunter) - Geolocation Sandbox + * + * Validation level for the interference-geolocation feature set: + * - terrestrial jammer with hidden emitter ground truth (West Texas), + * - two-satellite TDOA/FDOA correlation console + geographic map, + * - the geolocation-measurements-collected / geolocation-fix-accuracy + * objective conditions. + * + * The RF chain is pre-configured and tracking SENTRY-7; the operator detects + * the intermittent hostile carrier, characterizes its duty cycle, then works + * the geolocation console to cross-fix the emitter. + * + * Scenario clock starts 2027-09-01 06:00:00 UTC so the authored SENTRY TLEs + * place both birds ~45 deg elevation over Peterson Annex. + */ +export const signalHunterSandboxData: ScenarioData = { + id: 'signal-hunter-sandbox', + url: 'signal-hunter/scenarios/signal-hunter-sandbox', + imageUrl: 'nats/21/card.png', + number: 0, + isDisabled: false, + difficulty: 'advanced', + title: 'Signal Hunter Sandbox', + subtitle: 'Interference Geolocation Trainer', + duration: 'Unlimited', + missionType: 'Sandbox', + description: `An allied SENTRY satellite is being jammed by an intermittent uplink carrier. Your dish at Peterson Annex is already tracking the victim bird and its neighbor SENTRY-9. +

Detect the hostile carrier on the spectrum analyzer, characterize its duty cycle, then use the Geolocation console to cross-fix the emitter: tune the correlator to the interferer's uplink, capture TDOA/FDOA measurements while it is transmitting, and compute a position fix. Your job is to put an error ellipse over the jammer.`, + equipment: ['9-meter C-band Antenna (program-track)', 'C-band RF Front End (5150 MHz LNB LO)', 'Spectrum Analyzer', 'Two-Satellite Geolocation Console'], + settings: { + isSync: true, + groundStations: [petersonGroundStation], + satellites: [sentry7Satellite, sentry9Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-09-01', + scenarioStartWallTime: '06:00:00', + interferenceEvents: [ + { + id: 'pa22-hostile', + satelliteNoradId: 71001, + frequency: 6013e6, // Uplink, inside SENTRY-7 TP-1 passband (5990-6030), H-pol + bandwidth: 5e6, + power: 6, // dBm at transponder input; ~C/I 14 dB against the 20 dBm service carrier + polarization: 'H', + startTime: 15, + duration: 7200, // Persists across the session + periodSeconds: 105, // 60 s on / 45 s off - a deliberate cadence + onSeconds: 60, + // Hidden ground truth: a clandestine site in West Texas + emitter: { + latitude: 31.3, + longitude: -103.5, + altitudeKm: 0.8, + }, + }, + ], + geolocation: { + primaryNoradId: 71001, + adjacentNoradIds: [71002], + tdoaSigmaS: 1.5e-6, // 1.5 us timing noise + fdoaSigmaHz: 3, + areaOfInterest: { latMin: 28, latMax: 40, lonMin: -110, lonMax: -98 }, + captureWindowS: 10, + }, + }, + objectives: [ + { + id: 'detect-interference', + title: 'Detect the Hostile Carrier', + description: + 'Watch the RX Analysis spectrum. An intermittent carrier appears a few MHz from the SENTRY-7 service carrier - the max-hold trace will catch it between duty cycles.', + groundStation: 'PA-22', + nice: ['K0926'], + conditions: [ + { + type: 'signal-detected', + description: 'Hostile Carrier Observed', + params: { + signalId: 'INTERFERER-pa22-hostile', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'characterize-duty-cycle', + title: 'Characterize the Interferer', + description: 'Time the on/off windows. A regular duty cycle is the signature of deliberate interference, not an accidental spur.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['detect-interference'], + conditions: [ + { + type: 'status-check', + description: 'Duty Cycle Assessment', + params: { + question: 'The carrier transmits ~60 s then goes quiet ~45 s, repeating. What does this regular cadence most strongly indicate?', + options: [ + 'Deliberate, duty-cycled interference (jamming)', + 'A failing, cycling satellite power supply (brownouts)', + 'Rain fade cycling on the downlink (passing cells)', + 'Normal, bursty traffic on an allied carrier (data mode)', + ], + correctIndex: 0, + explanation: 'A stable on/off cadence is a hallmark of intentional interference - accidents are continuous or random, not periodic.', + character: Character.CHARLIE_BROOKS, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'open-geolocation-console', + title: 'Open the Geolocation Console', + description: 'Switch to the Geolocation tab to begin the two-satellite cross-fix.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['characterize-duty-cycle'], + conditions: [ + { + type: 'tab-active', + description: 'Geolocation Tab Open', + params: { tab: 'geolocation' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'collect-measurements', + title: 'Capture Correlation Measurements', + description: + 'Tune the correlator to the interferer uplink (6013 MHz, ~5 MHz bandwidth), select SENTRY-9 as the adjacent collector, and CAPTURE at least three times while the jammer is transmitting.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['open-geolocation-console'], + nice: ['K0926', 'K1032'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 3 TDOA/FDOA captures', + params: { minCount: 3, interferenceEventId: 'pa22-hostile' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'compute-fix', + title: 'Fix the Emitter', + description: + 'Run COMPUTE FIX and refine until the fix falls within 40 km of the true emitter. Spreading captures across several duty cycles gives the satellites time to move, which tightens the error ellipse and resolves the geometry.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['collect-measurements'], + nice: ['K1032'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'Fix within 40 km of truth', + params: { maxErrorKm: 40 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'report-location', + title: 'Report the Fix', + description: 'Confirm the emitter region for the incident report.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['compute-fix'], + conditions: [ + { + type: 'status-check', + description: 'Emitter Region', + params: { + question: 'Your fix places the jammer near 31°N, 103.5°W. Which region does that correspond to?', + options: ['West Texas', 'Central Colorado', 'Baja California', 'The Gulf of Mexico'], + correctIndex: 0, + explanation: 'The cross-fix localizes the emitter to West Texas - hand the coordinates and error ellipse to the incident response cell.', + character: Character.CHARLIE_BROOKS, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + ], +}; diff --git a/src/campaigns/signal-hunter/satellites.ts b/src/campaigns/signal-hunter/satellites.ts new file mode 100644 index 00000000..4e4fb91b --- /dev/null +++ b/src/campaigns/signal-hunter/satellites.ts @@ -0,0 +1,126 @@ +import { OrbitalObserver, OrbitalSatellite } from '@app/equipment/satellite/orbital-satellite'; +import { TransponderConfig } from '@app/equipment/satellite/satellite'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency } from '@app/types'; +import type { Degrees, Kilometers, TleLine1, TleLine2 } from 'ootk'; + +/** + * Campaign 5 (Signal Hunter) satellite roster. + * + * Two SGP4-propagated GEO birds (real az/el/range from TLEs, same physics as + * Campaign 2's LEO fleet - not the faked figure-8 GEO of Campaign 1). Both are + * inclined (3.0 / 4.5 deg) so their subpoints trace sizeable daily figure-8s. + * That N-S velocity is deliberate: it rotates the FDOA gradient off the TDOA + * gradient so the emitter's LATITUDE is observable (a near-equatorial pair + * leaves the N-S fix ambiguous), and it makes successive FDOA measurements + * rotate the line of position so the fix tightens over the collection. + * + * TLEs authored by scripts/author-tle-signal-hunter.mjs against the + * 2027-09-01 06:00:00 UTC scenario epoch (see that script for geometry + + * TDOA/FDOA observability validation): + * - SENTRY-7 (victim): slot 100.2W, el 47.1 deg / az 172.5 from the station + * - SENTRY-9 (adjacent collector): slot 98.1W, el 48.1 deg / az 168.7 + * + * Frequency plan (C-band): + * - Allied service carrier: downlink 3785 MHz, H-pol + * - Hostile uplink jammer (interferenceEvents): 6013 MHz H-pol, inside the + * SENTRY-7 transponder passband -> relayed to 3788 MHz downlink + * - Station LNB LO 5150 MHz: 3785 MHz -> 1365 MHz IF, jammer -> 1362 MHz IF + */ + +/** Peterson Annex ground station observer (Colorado) used for relative telemetry */ +export const petersonObserver: OrbitalObserver = { + name: 'Peterson Annex', + lat: 38.82 as Degrees, + lon: -104.7 as Degrees, + alt: 1.9 as Kilometers, +}; + +/** Victim satellite whose transponder is being jammed. */ +export const sentry7Satellite = new OrbitalSatellite( + 'SENTRY-7', + 71001, + [], // No interactive uplink traffic; the jammer is injected via interferenceEvents + [], // Downlink carrier defined as a transponder beacon below + { + tle1: '1 71001U 27200A 27244.25000000 .00000010 00000-0 00000-0 0 9997' as TleLine1, + tle2: '2 71001 3.0000 288.0000 0001000 90.0000 312.0000 1.00273791123453' as TleLine2, + observer: petersonObserver, + isDopplerEnabled: false, // GEO: negligible Doppler; keep the carrier stationary on the SA + }, + { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.02 as Degrees, + ephemerisErrorEl: 0.02 as Degrees, + transponderConfigs: [ + { + id: 'TP-1', + uplinkCenterFrequency: 6010e6 as RfFrequency, // passband 5990-6030 MHz + bandwidth: 40e6 as Hertz, + frequencyOffset: 2.225e9 as Hertz, // uplink 6013 -> downlink 3788 (jammer) + polarization: 'H', + // Allied service carrier (direct downlink, modeled as a transponder beacon) + beacon: { + frequency: 3785e6 as RfFrequency, + signalId: 'SENTRY-7-SERVICE', + serverId: 1, + noradId: 71001, + power: 20 as dBm, + bandwidth: 8e6 as Hertz, + modulation: 'QPSK' as ModulationType, + fec: '3/4' as FECType, + polarization: 'H', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + } +); + +/** Adjacent satellite used as the sidelobe collector for the correlation pair. */ +export const sentry9Satellite = new OrbitalSatellite( + 'SENTRY-9', + 71002, + [], + [], + { + tle1: '1 71002U 27200A 27244.25000000 .00000010 00000-0 00000-0 0 9998' as TleLine1, + tle2: '2 71002 4.5000 198.0000 0001000 90.0000 44.0000 1.00273791123452' as TleLine2, + observer: petersonObserver, + isDopplerEnabled: false, + }, + { + rotation: 0 as Degrees, + ephemerisErrorAz: 0.02 as Degrees, + ephemerisErrorEl: 0.02 as Degrees, + transponderConfigs: [ + { + id: 'TP-1', + uplinkCenterFrequency: 6250e6 as RfFrequency, + bandwidth: 36e6 as Hertz, + frequencyOffset: 2.225e9 as Hertz, + polarization: 'H', + beacon: { + frequency: 4005e6 as RfFrequency, + signalId: 'SENTRY-9-BEACON', + serverId: 1, + noradId: 71002, + power: 0 as dBm, + bandwidth: 1e3 as Hertz, + modulation: 'CW' as ModulationType, + fec: 'null' as FECType, + polarization: 'H', + feed: '', + isDegraded: false, + origin: SignalOrigin.TRANSMITTER, + noiseFloor: null, + gainInPath: 0 as dBi, + }, + } as TransponderConfig, + ], + } +); diff --git a/src/campaigns/signal-hunter/scenario1.ts b/src/campaigns/signal-hunter/scenario1.ts new file mode 100644 index 00000000..4e1da109 --- /dev/null +++ b/src/campaigns/signal-hunter/scenario1.ts @@ -0,0 +1,344 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { petersonGroundStation } from './ground-stations'; +import { sentry7Satellite, sentry9Satellite } from './satellites'; + +/** + * Campaign 5 (Signal Hunter) - Scenario 1: "First Fix" + * + * First scored mission of the arc. The sandbox walked the loop with the + * numbers handed over; here the operator earns them: + * - detect an intermittent carrier inside SENTRY-7 TP-1 and work its uplink + * frequency back from the IF on the analyzer (LNB LO 5150, TP-1 +2225), + * - time a sparse duty cycle (40 s on / 90 s off, ~30%) that leaves a 12 s + * correlator window little slack - captures must be started inside the + * on-window or the console reports NO CORRELATION, + * - collect at least six captures across the cycle, fix within 25 km, and + * file the incident characterization (duty cycle, bandwidth, polarization), + * - stretch: keep collecting until the fix closes to 15 km. + * + * Emitter ground truth: a ranch airstrip in Quay County, eastern New Mexico + * (35.17N 103.72W, 1.25 km MSL). Achievability of the 25 km / 15 km targets + * against the 1.5 us / 3 Hz measurement noise is held by + * test/campaigns/signal-hunter-scenario1-validation.test.ts, which solves the + * same geometry headlessly at the scenario epoch. + * + * Scenario clock 2027-09-01 06:00:00 UTC (0000 MDT) - the epoch the SENTRY + * TLEs were authored against (both birds ~47 deg elevation from PA-22). + * + * Frequency plan for this incident: + * - Hostile uplink 6021 MHz H-pol, 3 MHz wide, inside TP-1 (5990-6030) + * - Relayed downlink 3796 MHz -> 1354 MHz IF (service carrier sits at 1365) + * + * NICE Framework Alignment: + * Primary Codes: + * - K0926: Knowledge of signal jamming tools and techniques + * - K1032: Knowledge of satellite-based communication systems + * - S0648: Skill in detecting anomalies + * Supporting Codes: + * - K0645: Knowledge of standard operating procedures + * - S0421: Skill in operating communications equipment + * - S0593: Skill in handling incidents + * - S0842: Skill in recording operational events + */ +export const signalHunterScenario1Data: ScenarioData = { + id: 'signal-hunter-scenario1', + url: 'signal-hunter/scenarios/signal-hunter-scenario1', + imageUrl: 'nats/21/card.png', + number: 1, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['signal-hunter-sandbox'], + title: 'First Fix', + subtitle: 'Incident EW-27-0244 - SENTRY-7 TP-1', + duration: '30-40 min', + missionType: 'Interference Geolocation', + description: `Midnight at Peterson Annex. The allied logistics net riding SENTRY-7 TP-1 has been dropping packets in bursts since 2140Z - about forty seconds of loss every couple of minutes - and the NOC cannot see anything wrong on a clear-write trace. The incident cell has opened EW-27-0244 and handed it to you.

Find the carrier on the analyzer, work its uplink back from the IF, and time it. The correlator integrates for twelve seconds and will not correlate on an interferer that goes quiet halfway through, so your captures have to be started inside the on-window. Six good captures across the cycle should put a 25 km fix on the map. The cell wants it, with the characterization, before the 0100Z sync.`, + equipment: [ + '9-meter C-band Antenna (program-track on SENTRY-7)', + 'C-band RF Front End (5150 MHz LNB LO)', + 'Spectrum Analyzer (RX IF tap)', + 'Two-Satellite Geolocation Console (SENTRY-9 collector)', + ], + settings: { + isSync: true, + groundStations: [petersonGroundStation], + satellites: [sentry7Satellite, sentry9Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-09-01', + scenarioStartWallTime: '06:00:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-5/scenario-1?content-only=true&dark=true', + + workingDocument: { + title: 'Incident Report EW-27-0244', + description: 'SENTRY-7 TP-1 interference, PA-22, 2027-09-01. Sections: Characterization, Fix.', + }, + + interferenceEvents: [ + { + id: 'quay-hostile', + satelliteNoradId: 71001, + frequency: 6021e6, // Uplink, inside SENTRY-7 TP-1 (5990-6030) -> 3796 MHz downlink, 1354 MHz IF + bandwidth: 3e6, + power: 4, // dBm at transponder input; ~C/I 16 dB against the 20 dBm service carrier + polarization: 'H', + startTime: 20, + duration: 7200, // Persists across the session + periodSeconds: 130, // 40 s on / 90 s off - a ~30% duty cycle + onSeconds: 40, + // Hidden ground truth: a ranch airstrip in Quay County, eastern New Mexico + emitter: { + latitude: 35.17, + longitude: -103.72, + altitudeKm: 1.25, + }, + }, + ], + geolocation: { + primaryNoradId: 71001, + adjacentNoradIds: [71002], + tdoaSigmaS: 1.5e-6, // 1.5 us timing noise + fdoaSigmaHz: 3, + // Wider search box than the sandbox: the cell has no prior on the region + areaOfInterest: { latMin: 26, latMax: 42, lonMin: -112, lonMax: -96 }, + captureWindowS: 12, // Needs the interferer up for >= 8.4 s of every capture + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the Incident Package', + description: 'Open the shift package for EW-27-0244: the SENTRY-7/9 transponder plan, the LNB and translation figures, and what the incident cell needs in the report.', + groundStation: 'PA-22', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Incident Package Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'detect-interference', + nice: ['K0926', 'S0648'], + title: 'Find the Carrier', + description: + 'The service carrier sits at 1365 MHz IF. Something else is inside the TP-1 passband and it is not there all the time - the max-hold trace on the RX Analysis view will keep it once it has shown up.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'Unidentified Carrier Observed in TP-1', + params: { + signalId: 'INTERFERER-quay-hostile', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'characterize-duty-cycle', + nice: ['K0926', 'S0648'], + title: 'Time the Cadence', + description: + 'Watch the clear-write trace against the mission clock through at least two full cycles. The correlator window is 12 s; you need to know how much on-time you have to work with.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['detect-interference'], + conditions: [ + { + type: 'status-check', + description: 'Cadence Timed', + params: { + character: Character.SYSTEM, + question: 'You have timed the unidentified carrier through two full cycles. Which cadence did you observe?', + options: [ + 'About 40 s on, then about 90 s off - a 130 s cycle', + 'About 60 s on, then about 45 s off - a 105 s cycle', + 'Continuous, with 5-10 s dropouts every few minutes', + 'About 5 s on, then about 5 s off - a 10 s cycle', + ], + correctIndex: 0, + explanation: + 'Forty seconds up in every 130 - roughly a 30% duty cycle. With a 12 s integration window that is three, at most four, captures per on-window, and none at all if you start late.', + pointPenalty: 5, + documentLine: 'Cadence: ~40 s on / ~90 s off, 130 s period (~30% duty cycle), stable across cycles.', + documentSection: 'Characterization', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'open-geolocation-console', + nice: ['S0421'], + title: 'Bring Up the Correlator', + description: 'Open the Geolocation console. SENTRY-9 is the adjacent collector for this pair; the correlator has its own tuning and does not touch the receive chain.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['characterize-duty-cycle'], + conditions: [ + { + type: 'tab-active', + description: 'Geolocation Console Open', + params: { tab: 'geolocation' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'collect-measurements', + nice: ['K0926', 'K1032', 'S0421'], + title: 'Capture Inside the Window', + description: + 'Work the interferer uplink back from the IF you measured (RF = LO - IF; uplink = RF + TP-1 translation) and set the correlation bandwidth to match the carrier. Start captures only while the carrier is up - the correlator needs it present for 70% of the 12 s window or it reports NO CORRELATION. Six good captures across at least two cycles.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['open-geolocation-console'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 6 TDOA/FDOA captures on the interferer', + params: { minCount: 6, interferenceEventId: 'quay-hostile' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'compute-fix', + nice: ['K1032', 'K0926'], + title: 'First Fix', + description: + 'COMPUTE FIX. The cell needs the emitter inside 25 km. If the ellipse is still wide, keep capturing through further cycles - the inclined SENTRY pair moves between windows and every new epoch rotates the FDOA line - then compute again.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['collect-measurements'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'Fix within 25 km of the emitter', + params: { maxErrorKm: 25 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 20, + }, + { + id: 'file-incident-report', + nice: ['S0842', 'S0593', 'K0926'], + title: 'File the Characterization', + description: 'Complete the characterization block of EW-27-0244: duty cycle, occupied bandwidth, and polarization. The cell hands this to the site that goes out to look.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['compute-fix'], + conditions: [ + { + type: 'status-check', + description: 'Duty Cycle Reported', + params: { + character: Character.SYSTEM, + question: "EW-27-0244, field 4 - DUTY CYCLE. Report the interferer's duty cycle.", + options: [ + 'Approximately 30% (40 s on in each 130 s period)', + 'Approximately 57% (60 s on in each 105 s period)', + 'Approximately 100% (on throughout each 130 s period)', + 'Approximately 10% (5 s on in each 50 s period)', + ], + correctIndex: 0, + explanation: 'Logged. Forty of every 130 seconds is a 31% duty cycle - low enough to hide from a clear-write trace, high enough to hurt the customer.', + pointPenalty: 5, + documentLine: 'Duty cycle: ~30% (40 s on / 130 s period).', + documentSection: 'Characterization', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Occupied Bandwidth Reported', + params: { + character: Character.SYSTEM, + question: 'EW-27-0244, field 5 - OCCUPIED BANDWIDTH. From the analyzer, how wide is the interfering carrier?', + options: [ + 'About 3 MHz - narrower than the service carrier', + 'About 8 MHz - the same as the service carrier', + 'About 40 MHz - the full transponder passband', + 'Under 100 kHz - a narrowband CW spur on the edge', + ], + correctIndex: 0, + explanation: + 'Logged. A 3 MHz haystack, well inside the 40 MHz passband and clear of the 8 MHz service carrier - which is why the correlation bandwidth had to be set to match it, not to the transponder.', + pointPenalty: 5, + documentLine: 'Occupied bandwidth: ~3 MHz, centered 1354 MHz IF (3796 MHz downlink / 6021 MHz uplink).', + documentSection: 'Characterization', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Polarization Reported', + params: { + character: Character.SYSTEM, + question: "EW-27-0244, field 6 - POLARIZATION. The carrier is being relayed by SENTRY-7 TP-1. What is the interferer's uplink polarization, and how do you know?", + options: [ + 'Horizontal - TP-1 is an H-pol transponder; a cross-polarized uplink would be rejected and never show at this level', + 'Vertical - uplinks are the opposite polarization to the downlink; TP-1 comes down H-pol, so the uplink had to go up V-pol', + 'Right-hand circular - C-band military transponders are circular; a linear uplink would lose 3 dB and never reach this level', + 'Cannot be determined - polarization is lost through a transponder; only the field team at the emitter can measure the uplink', + ], + correctIndex: 0, + explanation: + "Logged. The transponder is the polarization filter: a cross-polarized uplink is rejected by ~28 dB, so only an H-pol uplink gets through TP-1 at the level you are seeing. That is a fact about the emitter's antenna the field team can use.", + pointPenalty: 5, + documentLine: 'Polarization: H (co-polar with TP-1; inferred from transponder routing).', + documentSection: 'Characterization', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'refine-fix', + nice: ['K1032'], + title: 'Close the Ellipse', + description: + 'Stretch: keep capturing through further cycles - twelve or more captures - and recompute until the fix closes to within 15 km. A tighter ellipse is fewer square kilometres for the field team to search.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['compute-fix'], + isOptional: true, + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 12 captures on the interferer', + params: { minCount: 12, interferenceEventId: 'quay-hostile' }, + mustMaintain: false, + }, + { + type: 'geolocation-fix-accuracy', + description: 'Fix within 15 km of the emitter', + params: { maxErrorKm: 15 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/signal-hunter/scenario2.ts b/src/campaigns/signal-hunter/scenario2.ts new file mode 100644 index 00000000..769d8585 --- /dev/null +++ b/src/campaigns/signal-hunter/scenario2.ts @@ -0,0 +1,435 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm, Hertz } from '@app/types'; +import { petersonGroundStation } from './ground-stations'; +import { sentry7Satellite, sentry9Satellite } from './satellites'; + +/** + * Campaign 5 (Signal Hunter) - Scenario 2: "Two Carriers" + * + * The uplink-versus-downlink call. Two unidentified carriers sit in the TP-1 + * downlink at PA-22 and the operator has to sort them before the correlator + * is any use: + * - 'dalhart-uplink' rides SENTRY-7 TP-1 (6001 MHz uplink -> 3776 MHz + * downlink -> 1374 MHz IF), 36 s on / 84 s off. Every station under the + * bird hears it; SENTRY-9 hears it through its sidelobes; the correlator + * can fix it. + * - 'fountain-terrestrial' never touches a satellite: a fixed-wireless site + * south-east of the annex whose 3752 MHz sector arrives at the 9 m dish + * through its own sidelobes (1398 MHz IF). Continuous, no Doppler, and the + * correlator reports NO CORRELATION however it is tuned - there is nothing + * on the bird to correlate. That is a receive-site problem for spectrum + * management, not a target for the field team. + * + * The decision is graded on the transponder-/terrestrial-interference-active + * facts, so the same option set would grade differently in a scenario with + * only one of the two paths in play. + * + * Emitter ground truth for the uplink: a grain elevator lot outside Dalhart, + * Dallam County, Texas (36.06N 102.52W, 1.22 km MSL). Achievability of the + * 25 km / 15 km targets is held by + * test/campaigns/signal-hunter-scenario2-validation.test.ts. + * + * Scenario clock 2027-09-04 05:30:00 UTC (2330 MDT on the 3rd) - three + * nights after First Fix, same SENTRY geometry. + * + * NICE Framework Alignment: + * Primary Codes: + * - K0926: Knowledge of signal jamming tools and techniques + * - S0648: Skill in detecting anomalies + * - K1032: Knowledge of satellite-based communication systems + * Supporting Codes: + * - K0645: Knowledge of standard operating procedures + * - S0421: Skill in operating communications equipment + * - S0593: Skill in handling incidents + * - S0842: Skill in recording operational events + */ +export const signalHunterScenario2Data: ScenarioData = { + id: 'signal-hunter-scenario2', + url: 'signal-hunter/scenarios/signal-hunter-scenario2', + imageUrl: 'nats/22/card.png', + number: 2, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['signal-hunter-scenario1'], + title: 'Two Carriers', + subtitle: 'Incident EW-27-0251 - SENTRY-7 TP-1', + duration: '30-40 min', + missionType: 'Interference Discrimination', + description: `Three nights after First Fix the logistics net is dropping packets again, and this time the NOC's max-hold shows two carriers in the TP-1 downlink that were not there last week. One cycles. One does not. The incident cell has opened EW-27-0251 and wants to know which of them the field team drives to.

Only one of those carriers is coming through the satellite. The other is arriving at the dish directly, from the ground, and no satellite pair will ever correlate on it. Sort them - by cadence, by what the correlator can and cannot see - then fix the one that is a target and refer the one that is not.`, + equipment: [ + '9-meter C-band Antenna (program-track on SENTRY-7)', + 'C-band RF Front End (5150 MHz LNB LO)', + 'Spectrum Analyzer (RX IF tap, 60 MHz span)', + 'Two-Satellite Geolocation Console (SENTRY-9 collector)', + ], + settings: { + isSync: true, + groundStations: [ + { + ...petersonGroundStation, + // Wider opening span than First Fix: both carriers have to fit on one trace + spectrumAnalyzers: petersonGroundStation.spectrumAnalyzers.map((analyzer, index) => + index === 0 ? { ...analyzer, centerFrequency: 1380e6 as Hertz, span: 60e6 as Hertz } : analyzer + ), + }, + ], + satellites: [sentry7Satellite, sentry9Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-09-04', + scenarioStartWallTime: '05:30:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-5/scenario-2?content-only=true&dark=true', + + workingDocument: { + title: 'Incident Report EW-27-0251', + description: 'SENTRY-7 TP-1 interference, PA-22, 2027-09-04. Sections: Characterization, Disposition, Fix.', + }, + + interferenceEvents: [ + { + id: 'dalhart-uplink', + satelliteNoradId: 71001, + frequency: 6001e6, // Uplink, inside SENTRY-7 TP-1 (5990-6030) -> 3776 MHz downlink, 1374 MHz IF + bandwidth: 2.5e6, + power: 4, // dBm at transponder input + polarization: 'H', + startTime: 20, + duration: 7200, + periodSeconds: 120, // 36 s on / 84 s off - three 12 s captures per window + onSeconds: 36, + // Hidden ground truth: a grain elevator lot outside Dalhart, Dallam County, Texas + emitter: { + latitude: 36.06, + longitude: -102.52, + altitudeKm: 1.22, + }, + }, + { + id: 'fountain-terrestrial', + frequency: 3752e6, // Received directly at 3752 MHz -> 1398 MHz IF; would read as a 5977 MHz uplink if it were relayed, which is below TP-1 + bandwidth: 5e6, + // EIRP in PA-22's direction, dBm: an off-axis slice of a fixed-wireless + // sector 27 km away. Through the dish's own far sidelobes it lands a + // few dB above the relayed service carrier at the feed. + power: 28, + polarization: 'H', + startTime: 0, + duration: 7200, + periodSeconds: 7200, // Continuous: a site that is simply on + onSeconds: 7200, + path: 'terrestrial', + // A fixed-wireless site south-east of Fountain, Colorado + emitter: { + latitude: 38.62, + longitude: -104.52, + altitudeKm: 1.72, + }, + }, + ], + geolocation: { + primaryNoradId: 71001, + adjacentNoradIds: [71002], + tdoaSigmaS: 1.5e-6, + fdoaSigmaHz: 3, + areaOfInterest: { latMin: 26, latMax: 42, lonMin: -112, lonMax: -96 }, + captureWindowS: 12, + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the Incident Package', + description: 'Open the package for EW-27-0251: the NOC max-hold with two carriers on it, the transponder plan, and what the cell needs before anyone drives anywhere.', + groundStation: 'PA-22', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Incident Package Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'find-both-carriers', + nice: ['K0926', 'S0648'], + title: 'Find Both Carriers', + description: + 'The analyzer opens on a 60 MHz span centered at 1380. The service carrier is at 1365. Find the two carriers that are not on the transponder plan - one a few MHz above the service carrier, one well above it - and watch what each of them does against the clock.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'Cycling Carrier Observed at 1374 MHz IF', + params: { + signalId: 'INTERFERER-dalhart-uplink', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'Continuous Carrier Observed at 1398 MHz IF', + params: { + signalId: 'INTERFERER-fountain-terrestrial', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'time-the-carriers', + nice: ['K0926', 'S0648'], + title: 'Time What Cycles', + description: 'Two full cycles on the clear-write trace. One of the two carriers has a cadence; the other has none. Log both.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['find-both-carriers'], + conditions: [ + { + type: 'status-check', + description: 'Cadence Logged for Both', + params: { + character: Character.SYSTEM, + question: 'You have watched both carriers through two full cycles of the mission clock. What did you see?', + options: [ + 'The 1374 carrier is up about 36 s in every 120 s; the 1398 carrier never drops', + 'Both carriers cycle together: about 36 s on, 84 s off', + 'The 1398 carrier cycles at about 36 s on / 84 s off; the 1374 carrier never drops', + 'Neither carrier cycles - both are continuous at a steady level', + ], + correctIndex: 0, + explanation: + 'A 30% duty cycle on one carrier and none on the other. Something keyed on a schedule is a decision somebody made; something that is simply on all night is usually a site that is simply on all night.', + pointPenalty: 5, + documentLine: 'Cadence: 1374 MHz IF ~36 s on / 84 s off (120 s period, ~30%); 1398 MHz IF continuous.', + documentSection: 'Characterization', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'call-the-path', + nice: ['K0926', 'K1032', 'S0648'], + title: 'Call the Path', + description: + 'Which carrier came through the satellite and which arrived at the dish directly? Try the correlator on each: it sees what the two satellites see, and nothing else. Then make the call - one of these is a target for the field team and one is a referral.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['time-the-carriers'], + conditions: [ + { + id: 'uplink-seen', + type: 'signal-detected', + description: 'Cycling carrier on the trace', + params: { + signalId: 'INTERFERER-dalhart-uplink', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + id: 'terrestrial-seen', + type: 'signal-detected', + description: 'Continuous carrier on the trace', + params: { + signalId: 'INTERFERER-fountain-terrestrial', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Which Carrier Is the Target', + params: { + character: Character.SYSTEM, + prompt: 'Quintero: "Two carriers, one field team. Which one do I send them after, and what do I do with the other one?" Make the call.', + evidence: ['uplink-seen', 'terrestrial-seen'], + decisionOptions: [ + { + label: + 'The 1374 carrier is on the transponder - it cycles, it maps to a 6001 MHz uplink inside TP-1, and the correlator locks it. The 1398 carrier is local: continuous, no uplink that fits the plan, NO CORRELATION however it is tuned. Fix 1374; refer 1398 to spectrum management as receive-site RFI', + correctWhen: { + all: [ + { fact: 'transponder-interference-active', is: true }, + { fact: 'terrestrial-interference-active', is: true }, + ], + }, + consequence: { log: 'EW-27-0251: 1374 MHz IF called as uplink interference through TP-1; 1398 MHz IF called as terrestrial RFI at PA-22 and referred' }, + }, + { + label: 'Both carriers are uplink interference through TP-1 - capture both, fix both, and give the team two ellipses', + correctWhen: { + all: [ + { fact: 'transponder-interference-active', is: true }, + { fact: 'terrestrial-interference-active', is: false }, + ], + }, + feedback: + 'Tune the correlator to the 1398 carrier and it says NO CORRELATION every time, because SENTRY-9 has never heard it. A carrier that only one dish on Earth can see did not come through a satellite. There is one ellipse in this incident.', + }, + { + label: 'Both carriers are local RFI at the annex - nothing here for the correlator. Refer both to spectrum management and close the ticket', + correctWhen: { + all: [ + { fact: 'transponder-interference-active', is: false }, + { fact: 'terrestrial-interference-active', is: true }, + ], + }, + feedback: + 'The 1374 carrier correlates against SENTRY-9 and keys on a schedule. The customer is losing packets to something that is coming down through their own transponder; the annex is not the problem and closing the ticket leaves the team with nothing.', + }, + { + label: 'The 1398 carrier is the interferer - it is the stronger of the two and it sits inside the TP-1 downlink. Work it back to 5977 MHz and correlate on that', + feedback: + '5977 MHz is below the TP-1 passband; nothing at that frequency is relayed, so there is nothing at 1398 for the correlator to find. Strong is not the same as through the bird. Strong and continuous at a single dish is what a nearby ground emitter looks like.', + }, + ], + explanation: + 'The correlator answers the question for you if you let it: it cross-correlates what SENTRY-7 and SENTRY-9 both received, so anything only PA-22 can hear comes back NO CORRELATION. The cycling carrier fits a TP-1 uplink, correlates, and is the target. The continuous carrier fits no uplink, never correlates, and is somebody else’s ticket.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 25, + }, + { + id: 'collect-measurements', + nice: ['K0926', 'K1032', 'S0421'], + title: 'Capture the Uplink Carrier', + description: + 'Correlator on the 1374 carrier only: uplink = (5150 - 1374) + 2225 = 6001 MHz, correlation bandwidth matched to the 2.5 MHz haystack. Thirty-six seconds up in every 120 is three captures per window if you start at the top of it. Six good captures across at least two cycles.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['call-the-path'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 6 TDOA/FDOA captures on the uplink carrier', + params: { minCount: 6, interferenceEventId: 'dalhart-uplink' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'compute-fix', + nice: ['K1032', 'K0926'], + title: 'Fix the Uplink Carrier', + description: 'COMPUTE FIX. The cell needs the emitter inside 25 km. If the ellipse is still wide, capture through further cycles and compute again.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['collect-measurements'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'Fix within 25 km of the emitter', + params: { maxErrorKm: 25 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'file-the-disposition', + nice: ['S0842', 'S0593', 'K0926'], + title: 'File the Disposition', + description: 'Two carriers, two dispositions. EW-27-0251 needs the uplink frequency for the one the team is driving to and a referral for the one they are not.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['compute-fix'], + conditions: [ + { + type: 'status-check', + description: 'Uplink Frequency Reported', + params: { + character: Character.SYSTEM, + question: 'EW-27-0251, field 3 - FREQUENCY. Report the uplink center of the carrier the field team is driving to.', + options: [ + '6001 MHz (3776 MHz downlink, 1374 MHz IF, via TP-1)', + '5977 MHz (3752 MHz downlink, 1398 MHz IF, via TP-1)', + '6021 MHz (3796 MHz downlink, 1354 MHz IF, via TP-1)', + '3776 MHz (the correlator is tuned to the downlink)', + ], + correctIndex: 0, + explanation: + 'Logged. IF 1374 -> downlink 3776 -> uplink 6001, inside TP-1 and on the transponder polarization. The correlator was tuned to the uplink because that is what both satellites received.', + pointPenalty: 5, + documentLine: 'Frequency: 6001 MHz uplink (3776 MHz downlink / 1374 MHz IF), 2.5 MHz occupied, H-pol via TP-1.', + documentSection: 'Characterization', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Terrestrial Carrier Referred', + params: { + character: Character.SYSTEM, + question: 'EW-27-0251, field 8 - DISPOSITION OF THE 1398 MHz IF CARRIER. Where does it go?', + options: [ + 'Referred to spectrum management as terrestrial RFI at PA-22: continuous, no correlation on the SENTRY pair, consistent with a fixed-wireless site in the 3.7 GHz band south-east of the annex', + 'Held on the incident as a second uplink interferer pending a fix from a different satellite pair', + 'Closed as a customer-side fault: the carrier is on the customer’s transponder, so it is the customer’s problem', + 'Escalated to the field team as a second target on the same drive', + ], + correctIndex: 0, + explanation: + 'Logged. A carrier only this dish can hear, in a band terrestrial systems share with C-band downlinks, is a coordination problem at the receive site. It hurts the customer, so it does not get ignored - it gets the right office.', + pointPenalty: 5, + documentLine: 'Disposition: 1398 MHz IF (3752 MHz) referred to spectrum management as terrestrial RFI at PA-22 - continuous, NO CORRELATION on the SENTRY-7/9 pair.', + documentSection: 'Disposition', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'refine-fix', + nice: ['K1032'], + title: 'Close the Ellipse', + description: + 'Stretch: twelve or more captures on the uplink carrier and a fix inside 15 km. Every capture on the wrong carrier is twelve seconds the team does not get back.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['compute-fix'], + isOptional: true, + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 12 captures on the uplink carrier', + params: { minCount: 12, interferenceEventId: 'dalhart-uplink' }, + mustMaintain: false, + }, + { + type: 'geolocation-fix-accuracy', + description: 'Fix within 15 km of the emitter', + params: { maxErrorKm: 15 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/signal-hunter/scenario3.ts b/src/campaigns/signal-hunter/scenario3.ts new file mode 100644 index 00000000..3751b074 --- /dev/null +++ b/src/campaigns/signal-hunter/scenario3.ts @@ -0,0 +1,391 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { petersonGroundStation } from './ground-stations'; +import { sentry7Satellite, sentry9Satellite } from './satellites'; + +/** + * Campaign 5 (Signal Hunter) - Scenario 3: "Cold Trail" + * + * Characterising and recording an event that will not sit still. The TP-1 + * interferer is back on a new frequency, PA-22 fixes it and hands the + * ellipse to a field team - and twenty minutes into the team's drive the + * carrier stops. It comes back on another frequency from eighty kilometres + * away. The operator has to notice the trail going cold, keep the first fix + * as history rather than as a destination, clear the correlator (the solver + * uses every capture on the pair, and two emitters solve to nowhere), and + * fix the second carrier on its own captures. + * + * Two emitter-bearing events, both on SENTRY-7 TP-1, 36 s on / 84 s off: + * - 'clayton-a': 6018 MHz uplink -> 3793 MHz downlink -> 1357 MHz IF, from + * T+20 to T+1520, a ranch road south of Clayton, Union County NM + * (36.45N 103.18W). + * - 'clayton-b': 6001 MHz -> 3776 -> 1374 MHz IF, from T+1800 on, a caprock + * overlook in Harding County NM (36.02N 103.90W) - about 80 km south-west + * of the first site. + * + * Achievability of both fixes is held by + * test/campaigns/signal-hunter-scenario3-validation.test.ts. + * + * Scenario clock 2027-09-07 04:45:00 UTC (2245 MDT on the 6th). + * + * NICE Framework Alignment: + * Primary Codes: + * - S0593: Skill in handling incidents + * - K0926: Knowledge of signal jamming tools and techniques + * - S0648: Skill in detecting anomalies + * Supporting Codes: + * - K1032: Knowledge of satellite-based communication systems + * - S0842: Skill in recording operational events + * - S0421: Skill in operating communications equipment + * - K0645: Knowledge of standard operating procedures + */ +export const signalHunterScenario3Data: ScenarioData = { + id: 'signal-hunter-scenario3', + url: 'signal-hunter/scenarios/signal-hunter-scenario3', + imageUrl: 'nats/23/card.png', + number: 3, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['signal-hunter-scenario2'], + title: 'Cold Trail', + subtitle: 'Incident EW-27-0263 - SENTRY-7 TP-1', + duration: '35-45 min', + missionType: 'Interference Geolocation', + description: `The TP-1 interferer is back, on a new frequency, and this time there is a field team at the ready. Find it, fix it, and hand them an ellipse they can search.

Then keep watching. An emitter that has been fixed once has a habit of not being where the fix says by the time anyone gets there. If the carrier stops while the team is driving, the first fix becomes history, not a destination - and if it comes back somewhere else on the passband, the correlator has to be cleared and the hunt starts over on the new carrier alone. The report at the end has to say which fix is current, which is not, and what the emitter did in between.`, + equipment: [ + '9-meter C-band Antenna (program-track on SENTRY-7)', + 'C-band RF Front End (5150 MHz LNB LO)', + 'Spectrum Analyzer (RX IF tap)', + 'Two-Satellite Geolocation Console (SENTRY-9 collector)', + ], + settings: { + isSync: true, + groundStations: [petersonGroundStation], + satellites: [sentry7Satellite, sentry9Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-09-07', + scenarioStartWallTime: '04:45:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-5/scenario-3?content-only=true&dark=true', + + workingDocument: { + title: 'Incident Report EW-27-0263', + description: 'SENTRY-7 TP-1 interference, PA-22, 2027-09-07. Sections: Fix 1, Fix 2, Mobility.', + }, + + interferenceEvents: [ + { + id: 'clayton-a', + satelliteNoradId: 71001, + frequency: 6018e6, // Uplink, inside SENTRY-7 TP-1 -> 3793 MHz downlink, 1357 MHz IF + bandwidth: 2.5e6, + power: 4, + polarization: 'H', + startTime: 20, + duration: 1500, // Stops at T+25:20 - while the team is on the road + periodSeconds: 120, // 36 s on / 84 s off + onSeconds: 36, + // Hidden ground truth: a ranch road south of Clayton, Union County, New Mexico + emitter: { + latitude: 36.45, + longitude: -103.18, + altitudeKm: 1.5, + }, + }, + { + id: 'clayton-b', + satelliteNoradId: 71001, + frequency: 6001e6, // Retuned: -> 3776 MHz downlink, 1374 MHz IF, above the service carrier + bandwidth: 2.5e6, + power: 4, + polarization: 'H', + startTime: 1800, // Back on the air 280 s after the first carrier stopped - two silent cycles and change + duration: 7200, + periodSeconds: 120, + onSeconds: 36, + // Hidden ground truth: a caprock overlook in Harding County, New Mexico - ~80 km south-west of the first site + emitter: { + latitude: 36.02, + longitude: -103.9, + altitudeKm: 1.75, + }, + }, + ], + geolocation: { + primaryNoradId: 71001, + adjacentNoradIds: [71002], + tdoaSigmaS: 1.5e-6, + fdoaSigmaHz: 3, + areaOfInterest: { latMin: 26, latMax: 42, lonMin: -112, lonMax: -96 }, + captureWindowS: 12, + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645'], + title: 'Review the Incident Package', + description: 'Open the package for EW-27-0263: the field team’s position and drive times, the transponder plan, and what the cell wants logged if the emitter moves.', + groundStation: 'PA-22', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Incident Package Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'find-carrier-a', + nice: ['K0926', 'S0648'], + title: 'Find the Carrier Below the Service', + description: 'Max-hold on the TP-1 downlink. The interferer is back a few MHz below the service carrier; get it on the trace during an on-window.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'Carrier Observed at 1357 MHz IF', + params: { + signalId: 'INTERFERER-clayton-a', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'capture-carrier-a', + nice: ['K0926', 'K1032', 'S0421'], + title: 'Capture the First Carrier', + description: 'Uplink = (5150 - 1357) + 2225 = 6018 MHz, 2.5 MHz correlation bandwidth. Six captures inside the on-windows, across at least two cycles.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['find-carrier-a'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 6 captures on the 1357 MHz IF carrier', + params: { minCount: 6, interferenceEventId: 'clayton-a' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'fix-carrier-a', + nice: ['K1032', 'K0926'], + title: 'Fix the First Carrier', + description: 'COMPUTE FIX. Inside 25 km - that is the ellipse the team drives to.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['capture-carrier-a'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'First fix within 25 km of the emitter', + params: { maxErrorKm: 25 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'brief-the-team', + nice: ['S0593', 'S0842'], + title: 'Brief the Team', + description: 'The team lead wants to know what he is driving into. Tell him what a fix is, and what it is not.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['fix-carrier-a'], + conditions: [ + { + type: 'status-check', + description: 'Team Briefed on the Fix', + params: { + character: Character.SYSTEM, + question: 'Team lead: "Give me the site." What do you hand him?', + options: [ + 'A 95% ellipse and its centre, the carrier’s cadence so his DF kit knows when to listen, and a standing instruction: call in if the carrier stops before he arrives', + 'The centre coordinates - the ellipse is a statistical nicety and the team needs a point to drive to', + 'The centre coordinates plus 25 km in every direction, since that is the accuracy the cell asked for', + 'A hold: six captures is not enough to send a team anywhere', + ], + correctIndex: 0, + explanation: + 'Logged. The fix is the ellipse: the emitter is somewhere inside it with 95% probability, and nowhere in particular inside it. The cadence is what lets a DF team pick the carrier out on arrival. And a carrier that stops while a team closes in is the single most useful thing you can tell them.', + pointPenalty: 5, + documentLine: 'Fix 1 (6018 MHz / 1357 IF): 95% ellipse handed to field team with cadence (36 s on / 84 s off); team instructed to report if the carrier stops.', + documentSection: 'Fix 1', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'the-trail-goes-cold', + nice: ['S0648', 'S0593'], + title: 'The Trail Goes Cold', + description: + 'Keep the analyzer on the passband while the team drives. If the carrier at 1357 stops and stays stopped for two full cycles, the first fix is history: say so, and say what happens next.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['brief-the-team'], + conditions: [ + { + type: 'interference-event-ended', + description: 'Carrier at 1357 MHz IF Has Stopped', + params: { interferenceEventId: 'clayton-a' }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Cold Trail Called', + params: { + character: Character.SYSTEM, + question: 'The carrier at 1357 MHz IF has not keyed for two full cycles. The team is twenty minutes out. What is your call?', + options: [ + 'Tell the team to hold short and stand by. Keep max-hold across the whole passband: an emitter that stops as a team closes in has moved or retuned, and the first fix is now a last-known position with a time on it', + 'Tell the team to press on to the ellipse - the fix is good and the emitter is just in a long off-window', + 'Clear the correlator and stand the team down; without a carrier there is nothing to fix', + 'Recompute the fix with more captures from the console’s history to tighten the ellipse for the team', + ], + correctIndex: 0, + explanation: + 'Logged. Two silent cycles from a carrier that has kept a 120 s cadence all night is not a long off-window. Fix 1 keeps its value as history - when the emitter was where - and loses its value as a destination. The team holds; the analyzer watches the whole passband, because a retune is the cheapest move an emitter can make.', + pointPenalty: 5, + documentLine: 'Fix 1 downgraded to last-known: carrier at 1357 MHz IF silent for two full cycles; field team held short; max-hold across full TP-1 passband.', + documentSection: 'Mobility', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'find-carrier-b', + nice: ['K0926', 'S0648'], + title: 'New Carrier Above the Service', + description: 'Max-hold picks up a new carrier above the service carrier, on the same cadence. Get it on the trace during an on-window.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['the-trail-goes-cold'], + conditions: [ + { + type: 'signal-detected', + description: 'Carrier Observed at 1374 MHz IF', + params: { + signalId: 'INTERFERER-clayton-b', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'capture-carrier-b', + nice: ['K0926', 'K1032', 'S0421'], + title: 'Clear and Recapture', + description: + 'CLEAR the console first. The solver uses every capture on the pair, and six captures from the first site mixed with captures from the second solve to a place that is neither. Retune: uplink = (5150 - 1374) + 2225 = 6001 MHz, 2.5 MHz bandwidth. Six captures on the new carrier alone.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['find-carrier-b'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 6 captures on the 1374 MHz IF carrier', + params: { minCount: 6, interferenceEventId: 'clayton-b' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'fix-carrier-b', + nice: ['K1032', 'K0926'], + title: 'Fix the Second Carrier', + description: 'COMPUTE FIX on the new captures alone. Inside 25 km. If the fix lands between the two sites, you did not clear.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['capture-carrier-b'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'Second fix within 25 km of the emitter', + params: { maxErrorKm: 25 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'file-the-trail', + nice: ['S0842', 'S0593', 'K0926'], + title: 'File the Trail', + description: 'EW-27-0263 has two fixes in it now. The report has to say which one is current, what the emitter did between them, and what that means for the team.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['fix-carrier-b'], + conditions: [ + { + type: 'status-check', + description: 'Mobility Reported', + params: { + character: Character.SYSTEM, + question: 'EW-27-0263, field 9 - EMITTER MOBILITY. What does the report say the emitter did?', + options: [ + 'Mobile: retuned from 6018 MHz to 6001 MHz and displaced roughly 80 km south-west between fix 1 and fix 2; cadence unchanged at 36 s on / 84 s off; stopped transmitting while the field team was inbound', + 'Fixed site with a frequency-agile transmitter: same location, new frequency', + 'Two separate emitters operating in turn; no evidence either one moved', + 'Unknown - the correlator cannot tell a moved emitter from a retuned one', + ], + correctIndex: 0, + explanation: + 'Logged. Two fixes on the same pair, eighty kilometres apart, with the same cadence on two frequencies and a silence between them that began as the team closed in: that is one emitter that moved and knew why it was moving. The cadence carried over is what ties the two carriers to one operator.', + pointPenalty: 5, + documentLine: 'Mobility: MOBILE - retuned 6018 -> 6001 MHz, displaced ~80 km SW between fixes, cadence unchanged (36/84), went silent with field team inbound.', + documentSection: 'Mobility', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Current Fix Identified', + params: { + character: Character.SYSTEM, + question: 'EW-27-0263, field 7 - FIX. Which fix does the team get, and how is the other one recorded?', + options: [ + 'Fix 2 (6001 MHz) as current, with its ellipse and time; fix 1 (6018 MHz) retained as last-known with the time the carrier stopped', + 'Both fixes as current targets - the team splits and searches both ellipses', + 'Fix 1 only - it has the longer capture history and the tighter ellipse', + 'Neither - a mobile emitter cannot be fixed; the report recommends the ticket be closed', + ], + correctIndex: 0, + explanation: + 'Logged. One current fix with its time; one historical fix with its time. A report that keeps both, labelled, is the one that lets the next shift see the emitter’s track instead of a single stale point.', + pointPenalty: 5, + documentLine: 'Fix 2 (6001 MHz / 1374 IF) current - ellipse to field team; Fix 1 retained as last-known with time of loss.', + documentSection: 'Fix 2', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + ], +}; diff --git a/src/campaigns/signal-hunter/scenario4.ts b/src/campaigns/signal-hunter/scenario4.ts new file mode 100644 index 00000000..d2bfae04 --- /dev/null +++ b/src/campaigns/signal-hunter/scenario4.ts @@ -0,0 +1,441 @@ +import { Character } from '@app/modal/character-enum'; +import type { ScenarioData } from '@app/ScenarioData'; +import type { dBm } from '@app/types'; +import { petersonGroundStation } from './ground-stations'; +import { sentry7Satellite, sentry9Satellite } from './satellites'; + +/** + * Campaign 5 (Signal Hunter) - Scenario 4: "Prove It" + * + * The limits of a result, and the report that states them. Last night's fix + * on a TP-1 interferer put an ellipse thirty-odd kilometres from Cannon AFB, + * whose own C-band terminal is authorised on TP-1, and the customer's J6 + * has said in writing that PA-22 is pointing at an allied site. Before a + * field team drives anywhere the fix has to be proven: + * - the correlator is checked against a known emitter first: the Cannon + * terminal's carrier ('cannon-reference', 6003 MHz uplink -> 1372 MHz IF, + * continuous) is captured and fixed, and the fix has to land on Cannon. + * A fix that lands on a known terminal is the pair's own calibration; + * - the console is cleared and the hostile ('curry-hostile', 6021 MHz -> + * 1354 MHz IF, 36 s on / 84 s off) is captured on its own and fixed; + * - the ellipse is closed to 20 km until it cannot reach Cannon (the + * hostile sits ~56 km east of the base), which is the new + * geolocation-ellipse-within condition: graded on the ellipse the console + * reports, not on hidden truth; + * - the call is made on the evidence and the report states its basis and + * its confidence in words the team lead can act on. + * + * The decision is graded on transponder-interference-active: the hostile is + * still keying while the call is made, so the answer is to task the team + * now, not to file a last-known position. + * + * Emitter ground truth: a centre-pivot field access outside Friona, Parmer + * County, Texas (34.75N 102.90W, 1.20 km MSL). Achievability of the fix and + * ellipse targets is held by + * test/campaigns/signal-hunter-scenario4-validation.test.ts. + * + * Scenario clock 2027-09-10 05:15:00 UTC (2315 MDT on the 9th). + * + * NICE Framework Alignment: + * Primary Codes: + * - K1032: Knowledge of satellite-based communication systems + * - K0926: Knowledge of signal jamming tools and techniques + * - S0593: Skill in handling incidents + * Supporting Codes: + * - K0946: Knowledge of incident reporting requirements + * - S0842: Skill in recording operational events + * - S0421: Skill in operating communications equipment + * - K0645: Knowledge of standard operating procedures + */ +export const signalHunterScenario4Data: ScenarioData = { + id: 'signal-hunter-scenario4', + url: 'signal-hunter/scenarios/signal-hunter-scenario4', + imageUrl: 'nats/24/card.png', + number: 4, + isDisabled: false, + difficulty: 'advanced', + prerequisiteScenarioIds: ['signal-hunter-scenario3'], + title: 'Prove It', + subtitle: 'Incident EW-27-0270 - SENTRY-7 TP-1', + duration: '35-45 min', + missionType: 'Interference Geolocation', + description: `Last night's fix on the TP-1 interferer landed in eastern New Mexico, thirty-some kilometres from Cannon AFB - and Cannon has an authorised C-band terminal on the same transponder. JTF-S J6 has put it in writing: PA-22 is geolocating an allied site, and the field team is not moving until somebody proves otherwise.

So prove it. Fix the Cannon terminal first, because a correlator that puts a known emitter where it belongs is a correlator you can trust tonight. Clear the console, fix the hostile on its own, and close the ellipse until it excludes the base. Then make the call and write the report so that a team lead, a J6, and a major at 0100Z can all read where the emitter is, where it is not, and how sure you are.`, + equipment: [ + '9-meter C-band Antenna (program-track on SENTRY-7)', + 'C-band RF Front End (5150 MHz LNB LO)', + 'Spectrum Analyzer (RX IF tap)', + 'Two-Satellite Geolocation Console (SENTRY-9 collector)', + ], + settings: { + isSync: true, + groundStations: [petersonGroundStation], + satellites: [sentry7Satellite, sentry9Satellite], + isExtraSatellitesVisible: true, + scenarioStartDate: '2027-09-10', + scenarioStartWallTime: '05:15:00', + + missionBriefUrl: 'https://docs.signalrange.space/campaign-5/scenario-4?content-only=true&dark=true', + + workingDocument: { + title: 'Incident Report EW-27-0270', + description: 'SENTRY-7 TP-1 interference, PA-22, 2027-09-10. Sections: Calibration, Fix, Basis, Confidence.', + }, + + interferenceEvents: [ + { + // The allied terminal at Cannon AFB: an authorised carrier on TP-1, + // modelled as an emitter-bearing event so the correlator can fix it. + id: 'cannon-reference', + satelliteNoradId: 71001, + frequency: 6003e6, // Uplink, inside TP-1 -> 3778 MHz downlink, 1372 MHz IF + bandwidth: 2e6, + power: 2, // dBm at transponder input + polarization: 'H', + startTime: 0, + duration: 7200, + periodSeconds: 7200, // Continuous: it is a working terminal + onSeconds: 7200, + // Cannon AFB, Clovis, New Mexico (known site; printed in the package) + emitter: { + latitude: 34.383, + longitude: -103.322, + altitudeKm: 1.31, + }, + }, + { + id: 'curry-hostile', + satelliteNoradId: 71001, + frequency: 6021e6, // Uplink, inside TP-1 -> 3796 MHz downlink, 1354 MHz IF + bandwidth: 3e6, + power: 4, + polarization: 'H', + startTime: 20, + duration: 7200, + periodSeconds: 120, // 36 s on / 84 s off + onSeconds: 36, + // Hidden ground truth: a centre-pivot field access outside Friona, Parmer County, Texas - ~56 km east of Cannon + emitter: { + latitude: 34.75, + longitude: -102.9, + altitudeKm: 1.2, + }, + }, + ], + geolocation: { + primaryNoradId: 71001, + adjacentNoradIds: [71002], + tdoaSigmaS: 1.5e-6, + fdoaSigmaHz: 3, + areaOfInterest: { latMin: 26, latMax: 42, lonMin: -112, lonMax: -96 }, + captureWindowS: 12, + }, + }, + objectives: [ + { + id: 'review-mission-brief', + nice: ['K0645', 'K0946'], + title: 'Review the Incident Package', + description: + 'Open the package for EW-27-0270: last night’s fix, the J6 message, the Cannon terminal’s authorised carrier, and what a report that survives a challenge has to contain.', + groundStation: 'PA-22', + freezesScenarioTimer: true, + prerequisiteObjectiveIds: [], + conditions: [ + { + type: 'mission-brief-opened', + description: 'Incident Package Reviewed', + params: { boxId: 'mission-brief' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'find-both-carriers', + nice: ['K0926', 'S0648'], + title: 'Find the Reference and the Hostile', + description: + 'Two carriers off the plan tonight, and one of them is supposed to be there. The Cannon terminal is continuous at 1372 MHz IF; the hostile cycles at 1354. Observe both on RX Analysis before you touch the correlator.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['review-mission-brief'], + conditions: [ + { + type: 'signal-detected', + description: 'Hostile Carrier Observed at 1354 MHz IF', + params: { + signalId: 'INTERFERER-curry-hostile', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'signal-detected', + description: 'Cannon Reference Carrier Observed at 1372 MHz IF', + params: { + signalId: 'INTERFERER-cannon-reference', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'capture-the-reference', + nice: ['K1032', 'S0421'], + title: 'Capture the Reference', + description: + 'Tune the correlator to the Cannon terminal first: uplink 6003 MHz, 2 MHz correlation bandwidth. It is continuous, so every capture correlates. Six captures - and let a couple of minutes pass between them so the FDOA lines rotate.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['find-both-carriers'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 6 captures on the Cannon reference carrier', + params: { minCount: 6, interferenceEventId: 'cannon-reference' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'fix-the-reference', + nice: ['K1032'], + title: 'Fix the Reference', + description: + 'COMPUTE FIX on the reference captures. The Cannon terminal is at a known position; the fix has to land within 20 km of it or the pair is not to be trusted tonight.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['capture-the-reference'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'Reference fix within 20 km of Cannon AFB', + params: { maxErrorKm: 20 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'read-the-calibration', + nice: ['K1032', 'S0593'], + title: 'Read the Calibration', + description: 'A fix on a known emitter is the correlator’s own check. Say what it proves, and what it does not.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['fix-the-reference'], + conditions: [ + { + type: 'status-check', + description: 'Calibration Read', + params: { + character: Character.SYSTEM, + question: 'The reference fix landed on Cannon AFB inside 20 km. What does that prove?', + options: [ + 'That the pair’s geometry, timing, and frequency references are sound tonight - a known emitter went where it belongs. It says nothing yet about the hostile', + 'That the hostile is not at Cannon - the correlator has now accounted for the base', + 'That the hostile fix from last night was correct, since the same correlator produced both', + 'That Cannon’s terminal is the interferer - it is the carrier the correlator could fix', + ], + correctIndex: 0, + explanation: + 'Logged. A calibration fix is a statement about the instrument, not about the target: it tells the J6 that when this pair says a carrier is somewhere, it is. The hostile still has to be fixed on its own captures.', + pointPenalty: 5, + documentLine: 'Calibration: Cannon AFB terminal (6003 MHz) fixed within 20 km of its surveyed position on the SENTRY-7/9 pair before the hostile was worked.', + documentSection: 'Calibration', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + { + id: 'clear-and-hunt', + nice: ['K0926', 'K1032', 'S0421'], + title: 'Clear and Hunt', + description: + 'CLEAR the console - the solver uses every capture on the pair, and reference captures mixed into the hostile set would drag the fix toward Cannon and hand the J6 his argument. Retune to 6021 MHz, 3 MHz bandwidth, and capture inside the on-windows. Eight captures across at least three cycles.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['read-the-calibration'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 8 captures on the hostile carrier', + params: { minCount: 8, interferenceEventId: 'curry-hostile' }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'fix-the-hostile', + nice: ['K1032', 'K0926'], + title: 'Fix the Hostile', + description: 'COMPUTE FIX on the hostile captures alone. Inside 20 km.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['clear-and-hunt'], + conditions: [ + { + type: 'geolocation-fix-accuracy', + description: 'Hostile fix within 20 km of the emitter', + params: { maxErrorKm: 20 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 10, + }, + { + id: 'prove-it', + nice: ['K1032', 'S0593'], + title: 'Close the Ellipse Past Cannon', + description: + 'A fix is an ellipse, and the ellipse is the claim. Keep capturing through further cycles - twelve or more - and recompute until the 95% ellipse’s semi-major axis is 20 km or less. Cannon is more than fifty kilometres from where the hostile captures are solving; an ellipse that small cannot reach it.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['fix-the-hostile'], + conditions: [ + { + type: 'geolocation-measurements-collected', + description: 'At least 12 captures on the hostile carrier', + params: { minCount: 12, interferenceEventId: 'curry-hostile' }, + mustMaintain: false, + }, + { + type: 'geolocation-ellipse-within', + description: '95% error ellipse semi-major axis 20 km or less', + params: { maxSemiMajorKm: 20 }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'make-the-call', + nice: ['S0593', 'K0926', 'K1032'], + title: 'Make the Call', + description: 'The team lead is on the line with the J6 message in one hand and your two fixes in the other. With the hostile on the trace in front of you, make the call.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['prove-it'], + conditions: [ + { + id: 'hostile-seen', + type: 'signal-detected', + description: 'Hostile carrier on the trace', + params: { + signalId: 'INTERFERER-curry-hostile', + minPower: -110 as dBm, + requiresObservation: true, + observationTab: 'rx-analysis', + }, + mustMaintain: false, + }, + { + type: 'decision', + description: 'Where the Team Goes', + params: { + character: Character.SYSTEM, + prompt: + 'Team lead: "J6 says I’m about to drive onto an Air Force base with a direction-finding kit. You have two fixes. Where am I going, and how sure are you?" Make the call.', + evidence: ['hostile-seen'], + decisionOptions: [ + { + label: + 'Hostile, not Cannon. The reference fix put the Cannon terminal on Cannon, so the pair is good tonight; the hostile fix on its own captures sits over fifty kilometres east of the base with an ellipse of 20 km or less; and it is still keying on its 36/120 cycle right now. Task the team to the ellipse; the correlator stays on it while they drive', + correctWhen: { fact: 'transponder-interference-active', is: true }, + consequence: { log: 'EW-27-0270: hostile fix affirmed against the Cannon calibration; field team tasked to the ellipse with the emitter still active' }, + }, + { + label: 'The emitter has gone quiet - file the fix as last-known, stand the team down, and re-task if it comes back', + correctWhen: { fact: 'transponder-interference-active', is: false }, + feedback: + 'It keyed up inside the last two minutes and it is on the trace in front of you. Last-known is what you write when the carrier has stopped; while it is still cycling, the fix is current and the team can drive to it.', + }, + { + label: 'Cannon’s terminal is the source - ask the J6 to have it shut down and watch the net recover', + feedback: + 'You fixed the Cannon terminal and it went to Cannon. You fixed the hostile and it went somewhere else, fifty kilometres away, on the same pair, the same night. Two carriers, two places. Shutting down an authorised terminal to test a theory the correlator has already disproved costs the customer a working link.', + }, + { + label: 'Inconclusive - two carriers eighteen MHz apart on one transponder need a third satellite to separate. Hold the team', + feedback: + 'The correlator separates carriers by tuning, not by geometry: each capture correlates on one carrier at a time, and you solved each set on its own. A third satellite tightens an ellipse; it is not what tells two carriers apart.', + }, + ], + explanation: + 'The proof is three things in order: the instrument checked against a known emitter, the target fixed on its own captures, and an ellipse small enough to exclude the alternative. With the carrier still keying, that fix is current and the team drives to it.', + pointPenalty: 10, + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 15, + }, + { + id: 'file-the-proof', + nice: ['K0946', 'S0842', 'S0593'], + title: 'File the Proof', + description: + 'EW-27-0270 goes to the J6 as well as to Holt. Two fields carry the argument: the basis for excluding Cannon, and a confidence statement someone can hold you to.', + groundStation: 'PA-22', + prerequisiteObjectiveIds: ['make-the-call'], + conditions: [ + { + type: 'status-check', + description: 'Basis for Exclusion Reported', + params: { + character: Character.SYSTEM, + question: 'EW-27-0270, field 10 - BASIS FOR EXCLUDING THE ALLIED TERMINAL. What goes in the report?', + options: [ + 'Reference fix on the Cannon terminal (6003 MHz) landed within 15 km of its surveyed position on the same pair tonight; hostile (6021 MHz) fixed on its own captures after a console clear; hostile 95% ellipse semi-major 20 km or less, centred over 50 km from Cannon', + 'The hostile is on a different frequency from the Cannon terminal, so it cannot be the same emitter', + 'The J6 was asked and confirmed the Cannon terminal was not transmitting on 6021 MHz', + 'The hostile cycles and an authorised terminal would not, so it is not Cannon', + ], + correctIndex: 0, + explanation: + 'Logged. Frequency and cadence are suggestive; the basis that survives a challenge is the measurement chain: instrument checked, target solved alone, ellipse too small to reach the alternative.', + pointPenalty: 5, + documentLine: + 'Basis: Cannon reference fix within 15 km on the same pair; hostile solved on its own captures after clear; hostile 95% ellipse <= 20 km semi-major, centred > 50 km from Cannon AFB.', + documentSection: 'Basis', + }, + mustMaintain: false, + }, + { + type: 'status-check', + description: 'Confidence Statement Filed', + params: { + character: Character.SYSTEM, + question: 'EW-27-0270, field 11 - CONFIDENCE. Which statement belongs in the report?', + options: [ + 'Emitter assessed inside the reported 95% ellipse with high confidence; Cannon AFB excluded; emitter active at time of report and being tracked', + 'Emitter located at the fix coordinates', + 'Emitter probably somewhere in eastern New Mexico or the Texas panhandle', + 'Emitter confirmed by the field team', + ], + correctIndex: 0, + explanation: + 'Logged. A confidence statement names the region, the probability, what was excluded, and the state of the emitter when you wrote it. A point is a claim you cannot support; a panhandle is a claim nobody can use; the team has not confirmed anything yet.', + pointPenalty: 5, + documentLine: 'Confidence: emitter assessed inside the reported 95% ellipse (high); Cannon AFB excluded; emitter active at time of report, correlator tracking.', + documentSection: 'Confidence', + }, + mustMaintain: false, + }, + ], + conditionLogic: 'AND', + points: 5, + }, + ], +}; diff --git a/src/commanding/commanding-manager.ts b/src/commanding/commanding-manager.ts new file mode 100644 index 00000000..0b761b89 --- /dev/null +++ b/src/commanding/commanding-manager.ts @@ -0,0 +1,283 @@ +/** + * @file CommandingManager - LEO uplink ops + command-link key ops (nats-eu M2/M5) + * @description Models the TT&C command link to a LEO bird during a pass: uplink + * Doppler compensation, a small command queue whose entries ACK only inside a + * valid command window with compensation engaged and a valid key, plus the + * COMSEC key lifecycle (scheduled rotation, emergency zeroize). A command that + * is sent without Doppler comp, out of window, or on an invalid/zeroized key is + * rejected - which is exactly the failure the S3/S4/S21 scenarios teach. + * + * Started only when settings.commanding is present. Time-of-send is taken from + * the mission clock in-app; tests pass an explicit elapsed value for + * determinism. No other simulation coupling, so it is unit-testable in isolation. + */ + +import { OrbitalSatellite, observerFromLocation } from '@app/equipment/satellite/orbital-satellite'; +import { InterferenceManager } from '@app/interference/interference-manager'; +import { ScenarioManager } from '@app/scenario-manager'; +import { missionNowMs } from '@app/simulation/mission-clock'; +import { SimulationManager } from '@app/simulation/simulation-manager'; +import { TransecManager } from '@app/transec/transec-manager'; + +export type CommandKeyStatus = 'Valid' | 'Pending Rotation' | 'Zeroized'; +export type CommandStatus = 'pending' | 'acked' | 'rejected'; +export type CommandRejectReason = 'no-doppler-comp' | 'key-invalid' | 'out-of-window' | 'uplink-jammed'; + +/** Guard either side of the command carrier that an interferer must reach to deny it (Hz) */ +export const UPLINK_JAM_GUARD_HZ = 500e3; + +/** settings.commanding */ +export interface CommandingConfig { + /** Ground station mounting the command uplink (for display) */ + groundStationId?: string; + /** Target satellite being commanded (for display) */ + targetNoradId?: number; + /** Command window opens at this elapsed second (omit = open from t0) */ + windowStartS?: number; + /** Command window closes at this elapsed second (omit = never closes) */ + windowEndS?: number; + /** Require a Valid key for a command to ACK (default true) */ + requireValidKey?: boolean; + /** Require uplink Doppler compensation for a command to ACK (default true) */ + requireDopplerComp?: boolean; + /** Canned TT&C commands the console offers as one-click sends */ + commands?: Array<{ id: string; label?: string }>; + /** + * Command carrier RF (Hz). With it set, a scripted transponder-path + * interference event on the target bird that overlaps this frequency denies + * the fixed-mode carrier: commands are rejected 'uplink-jammed' until the + * TRANSEC waveform is hopping with sync locked (nats-eu M7). Omit it and + * jamming never touches commanding. + */ + uplinkFrequencyHz?: number; + /** + * Phase 18 E: ranging. A RANGE tone through the command path records the + * true slant range to the target; requiredMeasurements make an OD solution. + */ + ranging?: { requiredMeasurements: number; toneId?: string }; +} + +/** One ranging measurement (phase 18 E) */ +export interface RangingMeasurement { + /** Mission elapsed second the tone returned */ + elapsedS: number; + rangeKm: number; +} + +interface CommandRecord { + id: string; + status: CommandStatus; + reason?: CommandRejectReason; + /** Ranging tone only: the slant range the tone measured, km */ + rangeKm?: number; + /** Mission elapsed second the record was resolved at */ + elapsedS?: number; +} + +interface CommandingState { + dopplerCompEnabled: boolean; + keyStatus: CommandKeyStatus; + keyRotationCompleted: boolean; + zeroized: boolean; + commands: CommandRecord[]; + rangingMeasurements: RangingMeasurement[]; +} + +export class CommandingManager { + private static instance_: CommandingManager | null = null; + + private readonly config_: CommandingConfig; + private readonly missionStartTime_ = missionNowMs(); + private readonly state_: CommandingState = { + dopplerCompEnabled: false, + keyStatus: 'Valid', + keyRotationCompleted: false, + zeroized: false, + commands: [], + rangingMeasurements: [], + }; + + private constructor() { + this.config_ = (ScenarioManager.getInstance().settings.commanding as CommandingConfig | undefined) ?? {}; + } + + static getInstance(): CommandingManager { + this.instance_ ??= new CommandingManager(); + + return this.instance_; + } + + static isInitialized(): boolean { + return this.instance_ !== null; + } + + static destroy(): void { + this.instance_ = null; + } + + get state(): Readonly { + return this.state_; + } + + getConfig(): CommandingConfig { + return this.config_; + } + + /** + * Whether the command window is open. `atElapsedS` overrides the mission + * clock (used by tests); omit in-app to use the real elapsed time. + */ + isWindowOpen(atElapsedS?: number): boolean { + const elapsed = atElapsedS ?? (missionNowMs() - this.missionStartTime_) / 1000; + + return this.isWithinWindow_(elapsed); + } + + /** Engage / disengage uplink Doppler compensation on the command carrier. */ + setDopplerComp(enabled: boolean): void { + this.state_.dopplerCompEnabled = enabled; + } + + /** Begin a scheduled key rotation (key becomes Pending Rotation until completed). */ + beginKeyRotation(): void { + if (this.state_.zeroized) { + return; + } + this.state_.keyStatus = 'Pending Rotation'; + } + + /** Complete the scheduled key rotation (key returns to Valid). */ + completeKeyRotation(): void { + if (this.state_.zeroized) { + return; + } + this.state_.keyStatus = 'Valid'; + this.state_.keyRotationCompleted = true; + } + + /** Emergency key destruction - all subsequent commands are rejected until re-keyed. */ + zeroizeKey(): void { + this.state_.zeroized = true; + this.state_.keyStatus = 'Zeroized'; + } + + /** + * Send a TT&C command. Returns the resolved record. `atElapsedS` overrides the + * mission clock for the window check (used by tests); omit in-app to use the + * real elapsed time. + */ + sendCommand(id: string, atElapsedS?: number): CommandRecord { + const elapsed = atElapsedS ?? (missionNowMs() - this.missionStartTime_) / 1000; + const record: CommandRecord = { id, status: 'pending', elapsedS: elapsed }; + + if ((this.config_.requireDopplerComp ?? true) && !this.state_.dopplerCompEnabled) { + record.status = 'rejected'; + record.reason = 'no-doppler-comp'; + } else if ((this.config_.requireValidKey ?? true) && this.state_.keyStatus !== 'Valid') { + record.status = 'rejected'; + record.reason = 'key-invalid'; + } else if (!this.isWithinWindow_(elapsed)) { + record.status = 'rejected'; + record.reason = 'out-of-window'; + } else if (this.isUplinkJammed()) { + record.status = 'rejected'; + record.reason = 'uplink-jammed'; + } else { + record.status = 'acked'; + } + + this.state_.commands.push(record); + + return record; + } + + /** + * Whether the command carrier is currently denied by interference: a + * transponder-path event on the target bird, in its envelope, overlapping + * `uplinkFrequencyHz` - and no TRANSEC hop-sync to ride over it. A jammer + * cannot follow a keyed hop set, so a synced hopping waveform is never + * jammed here. + */ + isUplinkJammed(): boolean { + const uplinkHz = this.config_.uplinkFrequencyHz; + const target = this.config_.targetNoradId; + if (uplinkHz === undefined || target === undefined || !InterferenceManager.isInitialized()) return false; + if (TransecManager.isInitialized() && TransecManager.getInstance().isSyncLocked()) return false; + + const interference = InterferenceManager.getInstance(); + + return interference.getEvents().some((event) => { + if ((event.path ?? 'transponder') !== 'transponder' || event.satelliteNoradId !== target) return false; + if (Math.abs(event.frequency - uplinkHz) > event.bandwidth / 2 + UPLINK_JAM_GUARD_HZ) return false; + + return interference.isEventInEnvelope(event.id); + }); + } + + /** + * Send a ranging tone through the command path (phase 18 E). Subject to the + * same gates as a command (Doppler, key, window, jamming); when it ACKs the + * true slant range from the station to the target is recorded. + */ + sendRangingTone(atElapsedS?: number): CommandRecord { + const toneId = this.config_.ranging?.toneId ?? 'RANGE'; + const record = this.sendCommand(toneId, atElapsedS); + if (record.status !== 'acked') return record; + + const rangeKm = this.measureRangeKm_(); + if (rangeKm !== null) { + const elapsed = atElapsedS ?? (missionNowMs() - this.missionStartTime_) / 1000; + this.state_.rangingMeasurements.push({ elapsedS: elapsed, rangeKm }); + record.rangeKm = rangeKm; + } + + return record; + } + + /** Enough ranging measurements for an orbit-determination solution. */ + isRangingSolutionReady(): boolean { + const required = this.config_.ranging?.requiredMeasurements ?? 1; + + return this.state_.rangingMeasurements.length >= required; + } + + /** Slant range from the command station to the target right now, km (null when unknown). */ + private measureRangeKm_(): number | null { + if (this.config_.targetNoradId === undefined || !SimulationManager.hasInstance()) return null; + const sim = SimulationManager.getInstance(); + const sat = sim.getSatByNoradId(this.config_.targetNoradId); + if (!sat) return null; + const gs = sim.groundStations.find((g) => g.state.id === this.config_.groundStationId) ?? sim.groundStations[0]; + if (sat instanceof OrbitalSatellite && gs) { + return sat.geometryFor(observerFromLocation(gs.state.location)).rangeKm; + } + const rangeKm = (sat as { rangeKm?: number }).rangeKm; + + return typeof rangeKm === 'number' ? rangeKm : null; + } + + /** Mission second a command was first acknowledged at (undefined = never). */ + acknowledgedAt(commandId: string): number | undefined { + return this.state_.commands.find((c) => c.id === commandId && c.status === 'acked')?.elapsedS; + } + + /** Whether a command (or a specific one) has been acknowledged. */ + isCommandAcknowledged(commandId?: string): boolean { + if (commandId) { + return this.state_.commands.some((c) => c.id === commandId && c.status === 'acked'); + } + + return this.state_.commands.some((c) => c.status === 'acked'); + } + + private isWithinWindow_(elapsedS: number): boolean { + if (this.config_.windowStartS !== undefined && elapsedS < this.config_.windowStartS) { + return false; + } + if (this.config_.windowEndS !== undefined && elapsedS > this.config_.windowEndS) { + return false; + } + + return true; + } +} diff --git a/src/components/base-element.ts b/src/components/base-element.ts index c169e647..d3d8ba91 100644 --- a/src/components/base-element.ts +++ b/src/components/base-element.ts @@ -1,4 +1,3 @@ - export abstract class BaseElement { protected abstract html_: string; protected dom_: HTMLElement | null = null; @@ -44,4 +43,4 @@ export abstract class BaseElement { * To be overridden by subclasses */ protected abstract addEventListeners_(): void; -} \ No newline at end of file +} diff --git a/src/components/card-alarm-badge/card-alarm-badge.ts b/src/components/card-alarm-badge/card-alarm-badge.ts index 85eb3fbd..d7092a65 100644 --- a/src/components/card-alarm-badge/card-alarm-badge.ts +++ b/src/components/card-alarm-badge/card-alarm-badge.ts @@ -1,6 +1,6 @@ -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { AlarmStatus } from "@app/equipment/base-equipment"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { AlarmStatus } from '@app/equipment/base-equipment'; import './card-alarm-badge.css'; /** @@ -74,19 +74,19 @@ export class CardAlarmBadge { */ private getHighestSeverity_(alarms: AlarmStatus[]): string { // Filter out 'off' and 'success' for determining if there are actual alarms - const activeAlarms = alarms.filter(a => a.severity !== 'off' && a.severity !== 'success'); + const activeAlarms = alarms.filter((a) => a.severity !== 'off' && a.severity !== 'success'); if (activeAlarms.length === 0) { return 'success'; } - if (activeAlarms.some(a => a.severity === 'error')) { + if (activeAlarms.some((a) => a.severity === 'error')) { return 'error'; } - if (activeAlarms.some(a => a.severity === 'warning')) { + if (activeAlarms.some((a) => a.severity === 'warning')) { return 'warning'; } - if (activeAlarms.some(a => a.severity === 'info')) { + if (activeAlarms.some((a) => a.severity === 'info')) { return 'info'; } @@ -97,9 +97,7 @@ export class CardAlarmBadge { * Get alarm messages for tooltip */ private getAlarmMessages_(alarms: AlarmStatus[]): string { - const activeAlarms = alarms.filter(a => - a.severity !== 'off' && a.severity !== 'success' && a.message - ); + const activeAlarms = alarms.filter((a) => a.severity !== 'off' && a.severity !== 'success' && a.message); if (activeAlarms.length === 0) { return ''; @@ -107,11 +105,9 @@ export class CardAlarmBadge { // Sort by severity (error first) const severityOrder: Record = { error: 0, warning: 1, info: 2 }; - activeAlarms.sort((a, b) => - (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3) - ); + activeAlarms.sort((a, b) => (severityOrder[a.severity] ?? 3) - (severityOrder[b.severity] ?? 3)); - return activeAlarms.map(a => a.message).join('\n'); + return activeAlarms.map((a) => a.message).join('\n'); } dispose(): void { diff --git a/src/components/fine-adjust-control/fine-adjust-control.ts b/src/components/fine-adjust-control/fine-adjust-control.ts index db61b6e2..85d97fc0 100644 --- a/src/components/fine-adjust-control/fine-adjust-control.ts +++ b/src/components/fine-adjust-control/fine-adjust-control.ts @@ -1,5 +1,5 @@ -import { html } from "@app/engine/utils/development/formatter"; -import { qs, qsa } from "@app/engine/utils/query-selector"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs, qsa } from '@app/engine/utils/query-selector'; import './fine-adjust-control.css'; /** @@ -41,14 +41,7 @@ export class FineAdjustControl { * @param steps Step configurations (default: [10, 1, 0.01]) * @param decimals Number of decimal places to display */ - constructor( - uniqueId: string, - label: string, - initialValue: number = 0, - unit: string = '°', - steps: number[] = [10, 1, 0.01], - decimals: number = 2 - ) { + constructor(uniqueId: string, label: string, initialValue: number = 0, unit: string = '°', steps: number[] = [10, 1, 0.01], decimals: number = 2) { this.uniqueId = uniqueId; this.label = label; this.unit = unit; @@ -58,7 +51,7 @@ export class FineAdjustControl { // Build step configs with labels this.steps = steps.map((value, index) => ({ value, - label: '<'.repeat(steps.length - index) + label: '<'.repeat(steps.length - index), })); this.html_ = this.buildHtml_(); @@ -66,14 +59,18 @@ export class FineAdjustControl { private buildHtml_(): string { // Generate decrease buttons (largest step first) - const decreaseButtons = this.steps.map(step => - `` - ).join(''); + const decreaseButtons = this.steps + .map((step) => ``) + .join(''); // Generate increase buttons (smallest step first) - const increaseButtons = this.steps.slice().reverse().map(step => - `` - ).join(''); + const increaseButtons = this.steps + .slice() + .reverse() + .map( + (step) => `` + ) + .join(''); return html`
@@ -98,14 +95,7 @@ export class FineAdjustControl { return `${value.toFixed(this.decimals)}${this.unit}`; } - static create( - uniqueId: string, - label: string, - initialValue: number = 0, - unit: string = '°', - steps: number[] = [10, 1, 0.01], - decimals: number = 2 - ): FineAdjustControl { + static create(uniqueId: string, label: string, initialValue: number = 0, unit: string = '°', steps: number[] = [10, 1, 0.01], decimals: number = 2): FineAdjustControl { return new FineAdjustControl(uniqueId, label, initialValue, unit, steps, decimals); } @@ -135,7 +125,7 @@ export class FineAdjustControl { addEventListeners(callback: (delta: number) => void): void { this.callback_ = callback; const buttons = qsa('.btn-fine', this.dom); - buttons.forEach(btn => { + buttons.forEach((btn) => { btn.addEventListener('click', (e) => { const delta = parseFloat((e.target as HTMLElement).dataset.delta!); // No sound - operator is remote from antenna @@ -150,19 +140,27 @@ export class FineAdjustControl { * @param pendingValue Optional pending value (staged but not applied) */ sync(value: number, pendingValue: number | null = null): void { + // A control whose DOM has been torn down (a tab instance that outlived a + // station switch and is still on the EventBus) tracks the value silently: + // there is nothing to paint, and throwing from inside the game loop + // stops the simulation. + const valueEl = (this.valueDisplay_ ??= this.find_('-value') ?? undefined); + const pendingEl = (this.pendingDisplay_ ??= this.find_('-pending') ?? undefined); + // Update active value if (this.value_ !== value) { this.value_ = value; - this.valueDisplay.textContent = this.formatValue_(value); + if (valueEl) valueEl.textContent = this.formatValue_(value); } // Update pending value display if (this.pendingValue_ !== pendingValue) { this.pendingValue_ = pendingValue; + if (!pendingEl) return; if (pendingValue !== null && pendingValue !== value) { - this.pendingDisplay.textContent = `→ ${this.formatValue_(pendingValue)}`; + pendingEl.textContent = `→ ${this.formatValue_(pendingValue)}`; } else { - this.pendingDisplay.textContent = ''; + pendingEl.textContent = ''; } } } @@ -172,10 +170,17 @@ export class FineAdjustControl { * @param enabled Whether the control should be enabled */ setEnabled(enabled: boolean): void { - const buttons = qsa('.btn-fine', this.dom); - buttons.forEach(btn => { + const dom = (this.dom_ ??= this.find_('') ?? undefined); + if (!dom) return; // DOM torn down: nothing to enable + const buttons = qsa('.btn-fine', dom); + buttons.forEach((btn) => { (btn as HTMLButtonElement).disabled = !enabled; }); - this.dom.classList.toggle('disabled', !enabled); + dom.classList.toggle('disabled', !enabled); + } + + /** Non-throwing lookup of one of this control's elements; null while unmounted. */ + private find_(suffix: '' | '-value' | '-pending'): HTMLElement | null { + return document.getElementById(`${this.uniqueId}${suffix}`); } } diff --git a/src/components/geo-map/geo-map.css b/src/components/geo-map/geo-map.css new file mode 100644 index 00000000..6840216e --- /dev/null +++ b/src/components/geo-map/geo-map.css @@ -0,0 +1,20 @@ +.geo-map { + display: block; + width: 100%; + line-height: 0; + border: 1px solid var(--mc-border); + border-radius: 4px; + overflow: hidden; +} + +.geo-map-canvas { + display: block; + width: 100%; + height: auto; + cursor: grab; + touch-action: none; /* let the canvas own wheel/drag gestures */ +} + +.geo-map-canvas:active { + cursor: grabbing; +} diff --git a/src/components/geo-map/geo-map.ts b/src/components/geo-map/geo-map.ts new file mode 100644 index 00000000..f6f5f36a --- /dev/null +++ b/src/components/geo-map/geo-map.ts @@ -0,0 +1,814 @@ +/** + * @file GeoMap - Interactive geographic canvas map + * @description A dependency-free canvas map over a real Earth basemap + * (earthmap4k.jpg). Two consumers today: + * + * - the Campaign 5 geolocation console: satellite subpoints, TDOA/FDOA lines of + * position, the computed fix circle + error ellipse, truth markers; + * - the ground-track tab (Campaign 2+): sub-satellite tracks split past/future, + * ground-station visibility circles, and the day/night terminator. + * + * Every layer is optional, so a consumer only pays for what it sets. + * + * The view is a pannable/zoomable window onto the globe, independent of the + * solver's area of interest: it opens fully zoomed out on the whole world and + * the operator scroll-wheel-zooms (centered on the cursor) and drags to pan in + * to where the lines of position cross. No auto-zoom to the answer. + * + * The basemap is a standard full-Earth equirectangular image (lon -180..180 + * across, lat 90..-90 down), and the projection is uniform-degrees so overlays + * register exactly with the imagery. Overlay accent colors read from the + * --mc-* theme variables. New in Campaign 5; no other campaign references it. + */ + +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import type { ErrorEllipse } from '@app/services/geolocation-service'; +import { type GroundPoint, interpolateGroundPoint, type LonLat, nightPolygon, splitAtAntimeridian, subsolarPoint, visibilityCircle } from '@app/services/ground-track-math'; +import './geo-map.css'; + +export interface GeoMarker { + lat: number; + lon: number; + label: string; + kind: 'station' | 'satellite' | 'fix' | 'truth'; +} + +/** + * A line of position, defined by its constraint rather than sampled points: + * `residual(lat, lon)` is zero exactly on the line (e.g. predicted TDOA minus + * the measured value). The map traces the zero contour across the current view + * at draw time, so the line spans the whole visible map and stays solid at any + * zoom level. + */ +export interface GeoLop { + residual: (lat: number, lon: number) => number; + kind: 'tdoa' | 'fdoa'; +} + +/** + * A sub-satellite ground track. `nowMs` splits it into a solid past leg and a + * dotted future leg, so the operator can read the direction of travel at a + * glance without an animation. + */ +export interface GeoTrack { + points: GroundPoint[]; + nowMs: number; + label: string; + color: string; + /** Draw thicker and label the live sub-point (the focused satellite). */ + isHighlighted?: boolean; +} + +/** + * A visibility circle. Same math either way - `visibilityRadiusDeg`, shared + * with the pass planner - but two different questions, so they are drawn + * differently: + * + * - `coverage` (default): centered on a satellite's sub-point. "Everywhere on + * the ground that can work this bird." The conventional satellite footprint. + * - `access`: centered on a ground station, sized by one satellite's altitude. + * "Everywhere that bird could be and still clear my mask." Only meaningful + * against a named satellite - the radius is a property of the pair, not of + * the station, which is why an unlabeled one reads as sensor coverage. + */ +export interface GeoFootprint { + lat: number; + lon: number; + radiusDeg: number; + label: string; + kind?: 'coverage' | 'access'; +} + +export interface GeoMapLayers { + markers: GeoMarker[]; + lops: GeoLop[]; + fix?: { lat: number; lon: number; ellipse: ErrorEllipse | null } | null; + tracks?: GeoTrack[]; + footprints?: GeoFootprint[]; + /** Render the day/night terminator for this instant (scenario time, not wall clock). */ + terminator?: Date | null; +} + +/** Current view window onto the globe (uniform degrees per pixel). */ +interface Viewport { + centerLat: number; + centerLon: number; + degPerPx: number; +} + +/** + * Overlay ink resolved from `--mc-geo-*` CSS custom properties at draw time, + * so the map follows the campaign theme like the accent colors already do. + * The defaults are the previous hardcoded literals: a campaign that declares + * no geo tokens (C1-C3) draws exactly what it always drew. + */ +interface GeoMapInk { + footprintAccess: string; + footprintCoverage: string; + lopFdoa: string; + markerStation: string; + markerSatellite: string; + markerFix: string; + markerTruth: string; +} + +const DEFAULT_INK: GeoMapInk = { + footprintAccess: 'rgba(224, 168, 82, 0.8)', + footprintCoverage: 'rgba(90, 169, 220, 0.85)', + lopFdoa: '#3ec8ff', + markerStation: '#5aa9dc', + markerSatellite: '#e8ebee', + markerFix: '#ffd07a', + markerTruth: '#ff5a4f', +}; + +const KM_PER_DEG_LAT = 111.32; +/** Full-Earth equirectangular basemap served from public/images */ +const EARTH_BASEMAP_URL = '/images/earthmap4k.jpg'; +/** City-lights companion to the day basemap, clipped to the night polygon */ +const EARTH_NIGHT_BASEMAP_URL = '/images/earthmap-night4k.jpg'; +/** Tightest zoom (deg/px) - ~a third of a degree across a 600px canvas */ +const MIN_DEG_PER_PX = 0.0005; +/** Scroll-wheel zoom factor per notch */ +const ZOOM_STEP = 1.2; + +export class GeoMap { + private readonly uniqueId_: string; + private readonly width_: number; + private readonly height_: number; + private readonly html_: string; + private readonly earthImage_: HTMLImageElement; + private readonly boundWheel_: (e: WheelEvent) => void; + private readonly boundPointerDown_: (e: PointerEvent) => void; + private readonly boundPointerMove_: (e: PointerEvent) => void; + private readonly boundPointerUp_: (e: PointerEvent) => void; + + private readonly nightImage_: HTMLImageElement; + + private dom_: HTMLElement | null = null; + private canvas_: HTMLCanvasElement | null = null; + private ctx_: CanvasRenderingContext2D | null = null; + private layers_: GeoMapLayers = { markers: [], lops: [] }; + private earthLoaded_ = false; + private nightLoaded_ = false; + private readonly view_: Viewport; + private dragLast_: { x: number; y: number } | null = null; + private drawScheduled_ = false; + /** Overlay ink for the current frame; re-resolved from CSS in draw() */ + private ink_: GeoMapInk = DEFAULT_INK; + + constructor(uniqueId: string, config: { width?: number; height?: number } = {}) { + this.uniqueId_ = uniqueId; + // 2:1 aspect so the whole equirectangular globe fits when fully zoomed out + this.width_ = config.width ?? 600; + this.height_ = config.height ?? 300; + this.html_ = html` +
+ +
+ `; + + // Open fully zoomed out on the whole world; the operator zooms in manually. + this.view_ = { centerLat: 0, centerLon: 0, degPerPx: this.maxDegPerPx_() }; + + this.boundWheel_ = this.handleWheel_.bind(this); + this.boundPointerDown_ = this.handlePointerDown_.bind(this); + this.boundPointerMove_ = this.handlePointerMove_.bind(this); + this.boundPointerUp_ = this.handlePointerUp_.bind(this); + + // Load the Earth basemap; redraw once it's available (falls back to a + // solid theme surface until then, and on load error). + this.earthImage_ = new Image(); + this.earthImage_.onload = () => { + this.earthLoaded_ = true; + this.draw(); + }; + this.earthImage_.src = EARTH_BASEMAP_URL; + + // Night lights are only used when a consumer asks for the terminator; the + // shading falls back to a flat dark fill until (or if) this loads. + this.nightImage_ = new Image(); + this.nightImage_.onload = () => { + this.nightLoaded_ = true; + this.draw(); + }; + this.nightImage_.src = EARTH_NIGHT_BASEMAP_URL; + } + + get outerHtml(): string { + return this.html_; + } + + get dom(): HTMLElement | null { + return this.dom_; + } + + /** Bind to the rendered DOM (call after the parent innerHTML is set) */ + attach(parent: HTMLElement): void { + this.dom_ = qs(`#${this.uniqueId_}`, parent); + this.canvas_ = qs('.geo-map-canvas', this.dom_); + this.ctx_ = this.canvas_.getContext('2d'); + this.canvas_.addEventListener('wheel', this.boundWheel_, { passive: false }); + this.canvas_.addEventListener('pointerdown', this.boundPointerDown_); + this.canvas_.addEventListener('pointermove', this.boundPointerMove_); + this.canvas_.addEventListener('pointerup', this.boundPointerUp_); + this.canvas_.addEventListener('pointerleave', this.boundPointerUp_); + this.draw(); + } + + dispose(): void { + if (this.canvas_) { + this.canvas_.removeEventListener('wheel', this.boundWheel_); + this.canvas_.removeEventListener('pointerdown', this.boundPointerDown_); + this.canvas_.removeEventListener('pointermove', this.boundPointerMove_); + this.canvas_.removeEventListener('pointerup', this.boundPointerUp_); + this.canvas_.removeEventListener('pointerleave', this.boundPointerUp_); + } + } + + setLayers(layers: GeoMapLayers): void { + this.layers_ = layers; + this.draw(); + } + + /** + * Recenter the view without changing zoom. Consumers use this to open on an + * asset of interest; the operator's pan/zoom afterwards is never overridden. + */ + centerOn(lat: number, lon: number, degPerPx?: number): void { + if (degPerPx !== undefined) { + this.view_.degPerPx = Math.min(this.maxDegPerPx_(), Math.max(MIN_DEG_PER_PX, degPerPx)); + } + this.view_.centerLat = lat; + this.view_.centerLon = lon; + this.clampView_(); + this.draw(); + } + + /** Coalesce rapid interaction redraws (pan/zoom) to one per animation frame */ + private scheduleDraw_(): void { + if (this.drawScheduled_) { + return; + } + this.drawScheduled_ = true; + requestAnimationFrame(() => { + this.drawScheduled_ = false; + this.draw(); + }); + } + + draw(): void { + const ctx = this.ctx_; + if (!ctx) { + return; + } + + const styles = getComputedStyle(this.dom_ ?? document.body); + const surface = styles.getPropertyValue('--mc-surface-0').trim() || '#121314'; + const accentRgb = styles.getPropertyValue('--mc-accent-red-rgb').trim() || '143, 111, 70'; + const accentBright = styles.getPropertyValue('--mc-accent-red-bright').trim() || '#d2a86a'; + + const readInk = (name: string, fallback: string): string => styles.getPropertyValue(name).trim() || fallback; + this.ink_ = { + footprintAccess: readInk('--mc-geo-footprint-access', DEFAULT_INK.footprintAccess), + footprintCoverage: readInk('--mc-geo-footprint-coverage', DEFAULT_INK.footprintCoverage), + lopFdoa: readInk('--mc-geo-lop-fdoa', DEFAULT_INK.lopFdoa), + markerStation: readInk('--mc-geo-marker-station', DEFAULT_INK.markerStation), + markerSatellite: readInk('--mc-geo-marker-satellite', DEFAULT_INK.markerSatellite), + markerFix: readInk('--mc-geo-marker-fix', DEFAULT_INK.markerFix), + markerTruth: readInk('--mc-geo-marker-truth', DEFAULT_INK.markerTruth), + }; + + ctx.clearRect(0, 0, this.width_, this.height_); + + if (this.drawBasemap_(ctx, this.earthImage_, this.earthLoaded_)) { + this.drawTerminator_(ctx); + // Slight veil so overlay lines, labels, and markers stay legible over + // the imagery without washing the map out. + ctx.fillStyle = 'rgba(8, 12, 18, 0.28)'; + ctx.fillRect(0, 0, this.width_, this.height_); + } else { + ctx.fillStyle = surface; + ctx.fillRect(0, 0, this.width_, this.height_); + } + + this.drawGraticule_(ctx); + this.drawFootprints_(ctx); + this.drawTracks_(ctx); + this.drawLops_(ctx, accentBright); + this.drawFix_(ctx, accentBright, accentRgb); + this.drawMarkers_(ctx); + } + + // ── Projection ────────────────────────────────────────────────────────── + + private project_(lat: number, lon: number): { x: number; y: number } { + const { centerLat, centerLon, degPerPx } = this.view_; + return { + x: this.width_ / 2 + (lon - centerLon) / degPerPx, + y: this.height_ / 2 - (lat - centerLat) / degPerPx, + }; + } + + private unproject_(x: number, y: number): { lat: number; lon: number } { + const { centerLat, centerLon, degPerPx } = this.view_; + return { + lon: centerLon + (x - this.width_ / 2) * degPerPx, + lat: centerLat - (y - this.height_ / 2) * degPerPx, + }; + } + + // ── Interaction: scroll-wheel zoom + drag pan ───────────────────────────── + + /** deg/px at which the whole globe just fits the canvas (max zoom out) */ + private maxDegPerPx_(): number { + return Math.min(360 / this.width_, 180 / this.height_); + } + + /** Convert a pointer event to canvas pixel coordinates (accounts for CSS scaling) */ + private canvasXy_(e: { clientX: number; clientY: number }): { x: number; y: number } { + const rect = this.canvas_?.getBoundingClientRect(); + if (!rect) { + return { x: 0, y: 0 }; + } + return { + x: (e.clientX - rect.left) * (this.width_ / rect.width), + y: (e.clientY - rect.top) * (this.height_ / rect.height), + }; + } + + private handleWheel_(e: WheelEvent): void { + e.preventDefault(); + const { x, y } = this.canvasXy_(e); + const geo = this.unproject_(x, y); // keep the point under the cursor fixed + + const factor = e.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; + const degPerPx = Math.min(this.maxDegPerPx_(), Math.max(MIN_DEG_PER_PX, this.view_.degPerPx * factor)); + this.view_.degPerPx = degPerPx; + + // Re-center so `geo` stays under (x, y) at the new scale + this.view_.centerLon = geo.lon - (x - this.width_ / 2) * degPerPx; + this.view_.centerLat = geo.lat + (y - this.height_ / 2) * degPerPx; + this.clampView_(); + this.scheduleDraw_(); + } + + private handlePointerDown_(e: PointerEvent): void { + this.dragLast_ = this.canvasXy_(e); + this.canvas_?.setPointerCapture(e.pointerId); + } + + private handlePointerMove_(e: PointerEvent): void { + if (!this.dragLast_) { + return; + } + const now = this.canvasXy_(e); + const degPerPx = this.view_.degPerPx; + this.view_.centerLon -= (now.x - this.dragLast_.x) * degPerPx; + this.view_.centerLat += (now.y - this.dragLast_.y) * degPerPx; + this.dragLast_ = now; + this.clampView_(); + this.scheduleDraw_(); + } + + private handlePointerUp_(e: PointerEvent): void { + this.dragLast_ = null; + this.canvas_?.releasePointerCapture?.(e.pointerId); + } + + /** Keep the view window within the basemap bounds (no off-world panning) */ + private clampView_(): void { + const spanLon = this.width_ * this.view_.degPerPx; + const spanLat = this.height_ * this.view_.degPerPx; + this.view_.centerLon = spanLon >= 360 ? 0 : Math.min(180 - spanLon / 2, Math.max(-180 + spanLon / 2, this.view_.centerLon)); + this.view_.centerLat = spanLat >= 180 ? 0 : Math.min(90 - spanLat / 2, Math.max(-90 + spanLat / 2, this.view_.centerLat)); + } + + // ── Layers ──────────────────────────────────────────────────────────────── + + /** + * Draw an equirectangular basemap for the current view window. Returns false + * (caller falls back to a solid surface) until the image has loaded. + */ + private drawBasemap_(ctx: CanvasRenderingContext2D, img: HTMLImageElement, loaded: boolean): boolean { + if (!loaded || !img.naturalWidth) { + return false; + } + + const topLeft = this.unproject_(0, 0); + const bottomRight = this.unproject_(this.width_, this.height_); + const imgW = img.naturalWidth; + const imgH = img.naturalHeight; + // Equirectangular: x = (lon+180)/360, y = (90-lat)/180 (top = higher lat) + const sx = ((topLeft.lon + 180) / 360) * imgW; + const sw = ((bottomRight.lon - topLeft.lon) / 360) * imgW; + const sy = ((90 - topLeft.lat) / 180) * imgH; + const sh = ((topLeft.lat - bottomRight.lat) / 180) * imgH; + ctx.drawImage(img, sx, sy, sw, sh, 0, 0, this.width_, this.height_); + + return true; + } + + /** + * Shade the night side, clipped to the terminator polygon. Where the city- + * lights basemap is available it is drawn inside the clip so the dark side + * stays readable; otherwise a flat darkening does the job. + * + * The polygon is traced in *screen* space, so it follows the current pan/zoom + * for free. + * + * Its longitudes are already monotonic (-180 → 180, then the two closing + * points at the dark pole), so they are projected literally. Do NOT "unwrap" + * them: the deliberate 180 → -180 step in the closing pair is what runs the + * path along the pole edge, and smoothing it out closes the polygon with a + * diagonal across the map instead. + * + * Near an equinox the curve is genuinely near-vertical (the terminator runs + * pole to pole), so lat(lon) jumps between ±88° between adjacent samples. + * That is physically correct, not an artifact — the fill handles it because + * each column still spans from the curve to the dark pole. + */ + private drawTerminator_(ctx: CanvasRenderingContext2D): void { + const date = this.layers_.terminator; + + if (!date) { + return; + } + + const polygon = nightPolygon(subsolarPoint(date)); + + ctx.save(); + ctx.beginPath(); + + for (const [i, point] of polygon.entries()) { + const { x, y } = this.project_(point.lat, point.lon); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + + ctx.closePath(); + ctx.clip(); + + if (!this.drawBasemap_(ctx, this.nightImage_, this.nightLoaded_)) { + ctx.fillStyle = 'rgba(2, 6, 14, 0.55)'; + ctx.fillRect(0, 0, this.width_, this.height_); + } else { + // Even with city lights, keep the night side clearly darker than day. + ctx.fillStyle = 'rgba(2, 6, 14, 0.35)'; + ctx.fillRect(0, 0, this.width_, this.height_); + } + + ctx.restore(); + } + + /** + * Visibility circles. Drawn under the tracks so a track crossing into a + * footprint reads as "this is the pass" without hiding the line. + * + * Satellite coverage is blue and dashed; station access is amber and dotted. + * The two can be on at once and mean different things, so they must not share + * a style - see GeoFootprint. + */ + private drawFootprints_(ctx: CanvasRenderingContext2D): void { + for (const footprint of this.layers_.footprints ?? []) { + const ring = visibilityCircle({ lat: footprint.lat, lon: footprint.lon }, footprint.radiusDeg); + + if (ring.length === 0) { + continue; + } + + const isAccess = footprint.kind === 'access'; + + ctx.lineWidth = 1.25; + ctx.strokeStyle = isAccess ? this.ink_.footprintAccess : this.ink_.footprintCoverage; + ctx.setLineDash(isAccess ? [2, 4] : [4, 3]); + + for (const segment of splitAtAntimeridian(ring)) { + this.strokePath_(ctx, segment); + } + + ctx.setLineDash([]); + } + } + + /** + * Sub-satellite tracks: solid behind the satellite, dotted ahead of it, with + * a marker at the live sub-point. Each leg is split at the antimeridian so it + * runs to the map edge instead of streaking back across the world. + */ + private drawTracks_(ctx: CanvasRenderingContext2D): void { + for (const track of this.layers_.tracks ?? []) { + if (track.points.length < 2) { + continue; + } + + const past = track.points.filter((p) => p.t <= track.nowMs); + const future = track.points.filter((p) => p.t >= track.nowMs); + const width = track.isHighlighted ? 2 : 1.25; + + const drawLeg = (points: GroundPoint[], dash: number[]): void => { + if (points.length < 2) { + return; + } + ctx.setLineDash(dash); + for (const segment of splitAtAntimeridian(points)) { + // Dark halo keeps the line readable over bright terrain. + ctx.lineWidth = width + 2; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.6)'; + this.strokePath_(ctx, segment); + ctx.lineWidth = width; + ctx.strokeStyle = track.color; + this.strokePath_(ctx, segment); + } + ctx.setLineDash([]); + }; + + drawLeg(past, []); + drawLeg(future, [5, 4]); + + const now = interpolateGroundPoint(track.points, track.nowMs); + + if (now) { + const { x, y } = this.project_(now.lat, now.lon); + + ctx.beginPath(); + ctx.arc(x, y, track.isHighlighted ? 5 : 3.5, 0, 2 * Math.PI); + ctx.fillStyle = track.color; + ctx.fill(); + ctx.lineWidth = 1.5; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.stroke(); + + ctx.font = track.isHighlighted ? 'bold 11px monospace' : '10px monospace'; + ctx.textBaseline = 'bottom'; + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.strokeText(track.label, x + 8, y - 2); + ctx.fillStyle = track.color; + ctx.fillText(track.label, x + 8, y - 2); + } + } + } + + /** Stroke a lon/lat polyline as a single projected path. */ + private strokePath_(ctx: CanvasRenderingContext2D, points: LonLat[]): void { + if (points.length < 2) { + return; + } + + ctx.beginPath(); + for (const [i, point] of points.entries()) { + const { x, y } = this.project_(point.lat, point.lon); + + if (i === 0) { + ctx.moveTo(x, y); + } else { + ctx.lineTo(x, y); + } + } + ctx.stroke(); + } + + private drawGraticule_(ctx: CanvasRenderingContext2D): void { + const topLeft = this.unproject_(0, 0); + const bottomRight = this.unproject_(this.width_, this.height_); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.20)'; + ctx.font = '10px monospace'; + ctx.textBaseline = 'top'; + + const label = (text: string, x: number, y: number): void => { + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.65)'; + ctx.strokeText(text, x, y); + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.fillText(text, x, y); + ctx.lineWidth = 1; + ctx.strokeStyle = 'rgba(255, 255, 255, 0.20)'; + }; + + const lonStep = GeoMap.gridStep_(bottomRight.lon - topLeft.lon); + for (let lon = Math.ceil(topLeft.lon / lonStep) * lonStep; lon <= bottomRight.lon; lon += lonStep) { + const { x } = this.project_(0, lon); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, this.height_); + ctx.stroke(); + label(`${lon.toFixed(1)}°`, x + 2, 2); + } + + const latStep = GeoMap.gridStep_(topLeft.lat - bottomRight.lat); + for (let lat = Math.ceil(bottomRight.lat / latStep) * latStep; lat <= topLeft.lat; lat += latStep) { + const { y } = this.project_(lat, 0); + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(this.width_, y); + ctx.stroke(); + label(`${lat.toFixed(1)}°`, 2, y + 2); + } + } + + private drawLops_(ctx: CanvasRenderingContext2D, tdoaColor: string): void { + // Grid cells ~8 px across the current view; the contour is re-traced every + // draw so it always spans the visible map and stays solid at any zoom. + const cols = Math.max(2, Math.round(this.width_ / 8)); + const rows = Math.max(2, Math.round(this.height_ / 8)); + + for (const lop of this.layers_.lops) { + const segments = this.traceContour_(lop.residual, cols, rows); + + const stroke = (color: string, lineWidth: number): void => { + ctx.strokeStyle = color; + ctx.lineWidth = lineWidth; + ctx.beginPath(); + for (const [x1, y1, x2, y2] of segments) { + ctx.moveTo(x1, y1); + ctx.lineTo(x2, y2); + } + ctx.stroke(); + }; + + // Dark halo under a bright line keeps the LOP high-contrast over terrain. + stroke('rgba(0, 0, 0, 0.75)', 4.5); + stroke(lop.kind === 'tdoa' ? tdoaColor : this.ink_.lopFdoa, 2); + } + } + + /** + * Marching squares: trace the residual == 0 contour across the current view + * as a list of pixel-space line segments. Sampling in screen space means the + * line is drawn at whatever resolution the current zoom needs. + */ + private traceContour_(residual: (lat: number, lon: number) => number, cols: number, rows: number): Array<[number, number, number, number]> { + const cellW = this.width_ / cols; + const cellH = this.height_ / rows; + const nx = cols + 1; + const ny = rows + 1; + + // Residual at every grid node (shared between adjacent cells) + const values = new Float64Array(nx * ny); + for (let j = 0; j < ny; j++) { + for (let i = 0; i < nx; i++) { + const geo = this.unproject_(i * cellW, j * cellH); + values[j * nx + i] = residual(geo.lat, geo.lon); + } + } + + const interp = (x1: number, y1: number, v1: number, x2: number, y2: number, v2: number): [number, number] => { + const t = v1 / (v1 - v2); + return [x1 + (x2 - x1) * t, y1 + (y2 - y1) * t]; + }; + + const segments: Array<[number, number, number, number]> = []; + for (let j = 0; j < rows; j++) { + for (let i = 0; i < cols; i++) { + const x0 = i * cellW; + const y0 = j * cellH; + const x1 = (i + 1) * cellW; + const y1 = (j + 1) * cellH; + const tl = values[j * nx + i]; + const tr = values[j * nx + i + 1]; + const br = values[(j + 1) * nx + i + 1]; + const bl = values[(j + 1) * nx + i]; + if (!Number.isFinite(tl + tr + br + bl)) { + continue; + } + + const crossings: Array<[number, number]> = []; + if (tl < 0 !== tr < 0) crossings.push(interp(x0, y0, tl, x1, y0, tr)); // top + if (tr < 0 !== br < 0) crossings.push(interp(x1, y0, tr, x1, y1, br)); // right + if (br < 0 !== bl < 0) crossings.push(interp(x1, y1, br, x0, y1, bl)); // bottom + if (bl < 0 !== tl < 0) crossings.push(interp(x0, y1, bl, x0, y0, tl)); // left + + if (crossings.length === 2) { + segments.push([crossings[0][0], crossings[0][1], crossings[1][0], crossings[1][1]]); + } else if (crossings.length === 4) { + // Saddle - connect as two segments (either pairing reads fine here) + segments.push([crossings[0][0], crossings[0][1], crossings[1][0], crossings[1][1]]); + segments.push([crossings[2][0], crossings[2][1], crossings[3][0], crossings[3][1]]); + } + } + } + + return segments; + } + + private drawFix_(ctx: CanvasRenderingContext2D, accentBright: string, accentRgb: string): void { + const fix = this.layers_.fix; + if (!fix?.ellipse) { + return; + } + + const center = this.project_(fix.lat, fix.lon); + const degPerPx = this.view_.degPerPx; + const kmPerDegLon = KM_PER_DEG_LAT * Math.cos(fix.lat * (Math.PI / 180)); + const pxPerKmX = 1 / (degPerPx * kmPerDegLon); + const pxPerKmY = 1 / (degPerPx * KM_PER_DEG_LAT); + + // orientationDeg is bearing of the semi-major axis clockwise from north + const theta = fix.ellipse.orientationDeg * (Math.PI / 180); + ctx.save(); + ctx.translate(center.x, center.y); + ctx.beginPath(); + for (let a = 0; a <= 360; a += 6) { + const rad = a * (Math.PI / 180); + const north = fix.ellipse.semiMajorKm * Math.cos(rad) * Math.cos(theta) - fix.ellipse.semiMinorKm * Math.sin(rad) * Math.sin(theta); + const east = fix.ellipse.semiMajorKm * Math.cos(rad) * Math.sin(theta) + fix.ellipse.semiMinorKm * Math.sin(rad) * Math.cos(theta); + const px = east * pxPerKmX; + const py = -north * pxPerKmY; + if (a === 0) { + ctx.moveTo(px, py); + } else { + ctx.lineTo(px, py); + } + } + ctx.closePath(); + ctx.strokeStyle = accentBright; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.fillStyle = `rgba(${accentRgb}, 0.12)`; + ctx.fill(); + ctx.restore(); + } + + private drawMarkers_(ctx: CanvasRenderingContext2D): void { + for (const marker of this.layers_.markers) { + const { x, y } = this.project_(marker.lat, marker.lon); + const style = this.markerStyle_(marker.kind); + + ctx.fillStyle = style.color; + ctx.strokeStyle = style.color; + ctx.lineWidth = 2; + + if (marker.kind === 'satellite') { + ctx.beginPath(); + ctx.moveTo(x, y - 5); + ctx.lineTo(x + 5, y); + ctx.lineTo(x, y + 5); + ctx.lineTo(x - 5, y); + ctx.closePath(); + ctx.stroke(); + } else if (marker.kind === 'fix') { + // The computed emitter fix: a ring with a center dot, sitting on top + // of the TDOA/FDOA lines of position and the error ellipse. + ctx.beginPath(); + ctx.arc(x, y, 7, 0, 2 * Math.PI); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(x, y, 2.5, 0, 2 * Math.PI); + ctx.fill(); + } else if (marker.kind === 'truth') { + ctx.beginPath(); + ctx.moveTo(x - 5, y - 5); + ctx.lineTo(x + 5, y + 5); + ctx.moveTo(x + 5, y - 5); + ctx.lineTo(x - 5, y + 5); + ctx.stroke(); + } else { + ctx.beginPath(); + ctx.arc(x, y, 4, 0, 2 * Math.PI); + ctx.fill(); + } + + // Outlined label for contrast over the basemap imagery + ctx.font = '10px monospace'; + ctx.textBaseline = 'bottom'; + ctx.lineWidth = 3; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.7)'; + ctx.strokeText(marker.label, x + 9, y - 1); + ctx.fillStyle = style.color; + ctx.fillText(marker.label, x + 9, y - 1); + } + } + + private markerStyle_(kind: GeoMarker['kind']): { color: string } { + switch (kind) { + case 'station': + return { color: this.ink_.markerStation }; + case 'satellite': + return { color: this.ink_.markerSatellite }; + case 'fix': + return { color: this.ink_.markerFix }; + case 'truth': + return { color: this.ink_.markerTruth }; + } + } + + /** Pick a graticule step (deg) that yields ~4-8 gridlines across a span */ + private static gridStep_(spanDeg: number): number { + const target = spanDeg / 6; + const candidates = [0.05, 0.1, 0.25, 0.5, 1, 2, 5, 10, 15, 30, 45]; + for (const candidate of candidates) { + if (candidate >= target) { + return candidate; + } + } + + return candidates.at(-1) ?? 1; + } +} diff --git a/src/components/help-btn/help-btn.ts b/src/components/help-btn/help-btn.ts index a8869176..3d2654fa 100644 --- a/src/components/help-btn/help-btn.ts +++ b/src/components/help-btn/help-btn.ts @@ -1,8 +1,8 @@ -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; -import { ModalManager } from "@app/modal/modal-manager"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { ModalManager } from '@app/modal/modal-manager'; import './help-btn.css'; export class HelpButton { @@ -12,12 +12,7 @@ export class HelpButton { private readonly helpTitle_: string; private readonly helpContent_: string; private readonly helpUrl_: string; - constructor( - uniqueId: string, - helpTitle: string, - helpContent: string, - helpUrl: string | null = null - ) { + constructor(uniqueId: string, helpTitle: string, helpContent: string, helpUrl: string | null = null) { this.html_ = html`
+
+ +
${this.renderPluginsList_()}
+
`; } + /** + * One switch per manifest entry. Toggles persist through SettingsManager and + * apply on the next load; a plugin with no module in this build (private-only + * in the OSS bundle) is shown but cannot be switched on. + */ + private renderPluginsList_(): string { + const plugins = PluginManager.getInstance().list(); + + if (plugins.length === 0) { + return html`
${t7e('devMenu.noPlugins')}
`; + } + + const statusOf = (plugin: { error?: string; enabled: boolean }): string => { + if (plugin.error) { + return ` · ${t7e('devMenu.pluginFailed')}`; + } + + return plugin.enabled ? '' : ` · ${t7e('devMenu.pluginDisabled')}`; + }; + const rows = plugins + .map((plugin) => { + const status = statusOf(plugin); + const version = plugin.version ? ` v${plugin.version}` : ''; + + return html` +
+ + +
+ `; + }) + .join(''); + + return html`${rows}
${t7e('devMenu.reloadHint')}
`; + } + private createDom_(): void { if (this.domCreated_) return; const parentDom = document.getElementsByTagName('body')[0]; - parentDom.insertAdjacentHTML('beforeend', html` + parentDom.insertAdjacentHTML( + 'beforeend', + html` -`; \ No newline at end of file +`; diff --git a/src/equipment/lcd-screen/lcd-screen.css b/src/equipment/lcd-screen/lcd-screen.css index 180b89c7..5f0e85f1 100644 --- a/src/equipment/lcd-screen/lcd-screen.css +++ b/src/equipment/lcd-screen/lcd-screen.css @@ -24,7 +24,7 @@ padding: 12px; background: #003477; box-shadow: 0 0 40px 2px #1ec6ff55 inset; - border: 4px solid #444; + border: 4px solid var(--mc-equip-border-light); border-radius: 8px; } diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control-box.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control-box.ts index b0bea387..a4138e77 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control-box.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control-box.ts @@ -1,8 +1,8 @@ -import { DraggableBox } from "@app/engine/ui/draggable-box"; -import { html } from "@app/engine/utils/development/formatter"; -import { getEl } from "@app/engine/utils/get-el"; -import { AnalyzerControl } from "./analyzer-control"; -import { RealTimeSpectrumAnalyzer } from "./real-time-spectrum-analyzer"; +import { DraggableBox } from '@app/engine/ui/draggable-box'; +import { html } from '@app/engine/utils/development/formatter'; +import { getEl } from '@app/engine/utils/get-el'; +import { AnalyzerControl } from './analyzer-control'; +import { RealTimeSpectrumAnalyzer } from './real-time-spectrum-analyzer'; export class AnalyzerControlBox extends DraggableBox { private readonly spectrumAnalyzer: RealTimeSpectrumAnalyzer; @@ -10,15 +10,14 @@ export class AnalyzerControlBox extends DraggableBox { private readonly control: AnalyzerControl; constructor(specA: RealTimeSpectrumAnalyzer) { - super(`spec-a-${specA.state.uuid}-control-popup-box`, - { - title: `Spectrum Analyzer ${specA.state.uuid.split('-')[0]} Control Panel`, - width: 'fit-content', - boxContentHtml: html` + super(`spec-a-${specA.state.uuid}-control-popup-box`, { + title: `Spectrum Analyzer ${specA.state.uuid.split('-')[0]} Control Panel`, + width: 'fit-content', + boxContentHtml: html`
- `.trim() - }); + `.trim(), + }); this.spectrumAnalyzer = specA; this.popupDom = getEl(`spec-a-${this.spectrumAnalyzer.state.uuid}-control-popup-content`)!; diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control.css b/src/equipment/real-time-spectrum-analyzer/analyzer-control.css index dfdf0d28..0eca4547 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control.css +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control.css @@ -2,7 +2,7 @@ .draggable-box { .draggable-box__title span { - color: #fff; + color: var(--mc-equip-text); font-size: 16px; font-weight: bold; letter-spacing: 2px; @@ -15,7 +15,7 @@ justify-content: space-between; align-items: center; height: 40px; - border-bottom: 2px solid #444; + border-bottom: 2px solid var(--mc-equip-border-light); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } @@ -41,7 +41,7 @@ background: #6a7b85; width: 100px; border-radius: 8px; - box-shadow: inset 0 2px 4px #3a4a55, 0 1px 2px #222; + box-shadow: inset 0 2px 4px #3a4a55, 0 1px 2px var(--mc-equip-panel); border: 2px solid #5a6a75; padding: 6px 0; display: flex; @@ -68,7 +68,7 @@ .physical-button.label-select-button { width: 35px; height: 35px; - background: #1b1b1b !important; + background: var(--mc-equip-bg-alt) !important; font-size: 0.9rem; } @@ -86,7 +86,7 @@ border: 2px solid var(--color-dark-border); border-radius: 6px; background: linear-gradient(180deg, #f0f0f0 0%, #e2e2e2 100%); - color: #1b1b1b; + color: var(--mc-equip-bg-alt); font-weight: bold; transition: all 0.15s ease; box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3), inset 0 1px 2px rgba(255, 255, 255, 0.5); @@ -137,8 +137,8 @@ width: 40px; height: 40px; font-size: 0.8rem; - background: #1b1b1b !important; - color: #fff; + background: var(--mc-equip-bg-alt) !important; + color: var(--mc-equip-text); } /* Sub-Menu Selection on Left Side */ @@ -214,8 +214,8 @@ /* Reused by POWER button */ .num-button.special-button { - background: #1b1b1b !important; - color: #fff; + background: var(--mc-equip-bg-alt) !important; + color: var(--mc-equip-text); font-size: 0.8em; width: 60px; } @@ -238,8 +238,8 @@ /* Bottom Row: Power Button */ .power-button { - background: #ba160c !important; - color: #fff; + background: var(--mc-accent-red, #ba160c) !important; + color: var(--mc-equip-text); font-size: 0.8em; width: 60px; } diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control.ts index 69b436e9..789b0df4 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control.ts @@ -1,11 +1,11 @@ +import { BaseElement } from '@app/components/base-element'; import { ContinuousRotaryKnob } from '@app/components/rotary-knob/continuous-rotary-knob'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs, qsa } from '@app/engine/utils/query-selector'; import { Logger } from '@app/logging/logger'; import { Sfx } from '@app/sound/sfx-enum'; import SoundManager from '@app/sound/sound-manager'; import { parseLocalizedNumber } from '@app/utils/parse-number'; -import { BaseElement } from '@app/components/base-element'; -import { html } from "@app/engine/utils/development/formatter"; -import { qs, qsa } from "@app/engine/utils/query-selector"; import './analyzer-control.css'; import { ACAmptBtn } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ampt-btn/ac-ampt-btn'; import { ACBWBtn } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/ac-bw-btn/ac-bw-btn'; @@ -92,21 +92,9 @@ export class AnalyzerControl extends BaseElement { this.specA = options.spectrumAnalyzer; this.dom_ = options.element; - this.minorAdjKnob = ContinuousRotaryKnob.create( - `analyzer-control-selector-knob-${this.specA.state.uuid}`, - 0, - 10, - this.handleMinorTickChange.bind(this), - "Minor Tick", - ); - - this.majorAdjKnob = ContinuousRotaryKnob.create( - `analyzer-control-selector-knob-${this.specA.state.uuid}-major`, - 0, - 10, - this.handleMajorTickChange.bind(this), - "Major Tick", - ); + this.minorAdjKnob = ContinuousRotaryKnob.create(`analyzer-control-selector-knob-${this.specA.state.uuid}`, 0, 10, this.handleMinorTickChange.bind(this), 'Minor Tick'); + + this.majorAdjKnob = ContinuousRotaryKnob.create(`analyzer-control-selector-knob-${this.specA.state.uuid}-major`, 0, 10, this.handleMajorTickChange.bind(this), 'Major Tick'); } init_(parentId: string, type: 'add' | 'replace' = 'replace'): void { @@ -287,7 +275,7 @@ export class AnalyzerControl extends BaseElement { - ` + `; // Reinitialize DOM with final HTML const parentDom = super.initDom_(parentId, type); @@ -342,7 +330,7 @@ export class AnalyzerControl extends BaseElement { // Number pad buttons const numButtons = qsa('.num-button', this.dom_); - numButtons.forEach(button => { + numButtons.forEach((button) => { button.addEventListener('click', () => { const value = button.dataset.value; if (value) { @@ -437,4 +425,4 @@ export class AnalyzerControl extends BaseElement { private playSound(): void { SoundManager.getInstance().play(Sfx.SPEC_A_BTN_PRESS); } -} \ No newline at end of file +} diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ampt-btn/ac-ampt-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ampt-btn/ac-ampt-btn.ts index 826ae20b..f903444e 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ampt-btn/ac-ampt-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ampt-btn/ac-ampt-btn.ts @@ -1,6 +1,6 @@ -import { dB } from "@app/types"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { dB } from '@app/types'; import './ac-ampt-btn.css'; export class ACAmptBtn extends BaseControlButton { @@ -131,18 +131,18 @@ export class ACAmptBtn extends BaseControlButton { break; case 'min': this.analyzerControl.specA.state.minAmplitude += adjustment; - this.analyzerControl.specA.state.scaleDbPerDiv = (this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10 as dB; + this.analyzerControl.specA.state.scaleDbPerDiv = ((this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10) as dB; break; case 'max': this.analyzerControl.specA.state.maxAmplitude += adjustment; - this.analyzerControl.specA.state.scaleDbPerDiv = (this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10 as dB; + this.analyzerControl.specA.state.scaleDbPerDiv = ((this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10) as dB; break; case 'dbperdiv': { - const newScale = this.analyzerControl.specA.state.scaleDbPerDiv + adjustment as dB; + const newScale = (this.analyzerControl.specA.state.scaleDbPerDiv + adjustment) as dB; this.analyzerControl.specA.state.scaleDbPerDiv = newScale; // Adjust min amplitude to keep max amplitude consistent - this.analyzerControl.specA.state.minAmplitude = this.analyzerControl.specA.state.maxAmplitude - (newScale * 10) as dB; + this.analyzerControl.specA.state.minAmplitude = (this.analyzerControl.specA.state.maxAmplitude - newScale * 10) as dB; } break; } @@ -163,21 +163,20 @@ export class ACAmptBtn extends BaseControlButton { } } break; - case 'min': - { - const minInputValue = parseFloat(this.analyzerControl.specA.state.inputValue); - if (!isNaN(minInputValue)) { - this.analyzerControl.specA.state.minAmplitude = minInputValue; - this.analyzerControl.specA.state.scaleDbPerDiv = (this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10 as dB; - } - break; + case 'min': { + const minInputValue = parseFloat(this.analyzerControl.specA.state.inputValue); + if (!isNaN(minInputValue)) { + this.analyzerControl.specA.state.minAmplitude = minInputValue; + this.analyzerControl.specA.state.scaleDbPerDiv = ((this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10) as dB; } + break; + } case 'max': { const maxInputValue = parseFloat(this.analyzerControl.specA.state.inputValue); if (!isNaN(maxInputValue)) { this.analyzerControl.specA.state.maxAmplitude = maxInputValue; - this.analyzerControl.specA.state.scaleDbPerDiv = (this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10 as dB; + this.analyzerControl.specA.state.scaleDbPerDiv = ((this.analyzerControl.specA.state.maxAmplitude - this.analyzerControl.specA.state.minAmplitude) / 10) as dB; } } break; @@ -188,7 +187,7 @@ export class ACAmptBtn extends BaseControlButton { const newScale = scaleInputValue as dB; this.analyzerControl.specA.state.scaleDbPerDiv = newScale; // Adjust min amplitude to keep max amplitude consistent - this.analyzerControl.specA.state.minAmplitude = this.analyzerControl.specA.state.maxAmplitude - (newScale * 10) as dB; + this.analyzerControl.specA.state.minAmplitude = (this.analyzerControl.specA.state.maxAmplitude - newScale * 10) as dB; } } break; diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-bw-btn/ac-bw-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-bw-btn/ac-bw-btn.ts index 74315b97..3897d6b3 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-bw-btn/ac-bw-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-bw-btn/ac-bw-btn.ts @@ -1,7 +1,7 @@ -import { Logger } from "@app/logging/logger"; -import { Hertz } from "@app/types"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { Logger } from '@app/logging/logger'; +import { Hertz } from '@app/types'; import './ac-bw-btn.css'; export class ACBWBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra1-btn/ac-extra1-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra1-btn/ac-extra1-btn.ts index 5ea8a708..ce3c6e2d 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra1-btn/ac-extra1-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra1-btn/ac-extra1-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-extra1-btn.css'; export class ACExtra1Btn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra2-btn/ac-extra2-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra2-btn/ac-extra2-btn.ts index 41a0f7fd..b9ad3305 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra2-btn/ac-extra2-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra2-btn/ac-extra2-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-extra2-btn.css'; export class ACExtra2Btn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra3-btn/ac-extra3-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra3-btn/ac-extra3-btn.ts index 14e95c2b..ec9a8642 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra3-btn/ac-extra3-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra3-btn/ac-extra3-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-extra3-btn.css'; export class ACExtra3Btn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra4-btn/ac-extra4-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra4-btn/ac-extra4-btn.ts index 7e009afa..e90b0a17 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra4-btn/ac-extra4-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-extra4-btn/ac-extra4-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-extra4-btn.css'; export class ACExtra4Btn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-freq-btn/ac-freq-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-freq-btn/ac-freq-btn.ts index 403a05e9..e246ccb9 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-freq-btn/ac-freq-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-freq-btn/ac-freq-btn.ts @@ -1,8 +1,8 @@ -import { Logger } from "@app/logging/logger"; -import { Hertz } from "@app/types"; -import { parseLocalizedNumber } from "@app/utils/parse-number"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { Logger } from '@app/logging/logger'; +import { Hertz } from '@app/types'; +import { parseLocalizedNumber } from '@app/utils/parse-number'; import './ac-freq-btn.css'; export class ACFreqBtn extends BaseControlButton { @@ -96,17 +96,17 @@ export class ACFreqBtn extends BaseControlButton { if (this.subMenuSelected === 'center') { newVal = this.analyzerControl.specA.state.centerFrequency + adjustmentInHz; } else if (this.subMenuSelected === 'start') { - const currentStartFreq = this.analyzerControl.specA.state.centerFrequency - (this.analyzerControl.specA.state.span / 2) as Hertz; + const currentStartFreq = (this.analyzerControl.specA.state.centerFrequency - this.analyzerControl.specA.state.span / 2) as Hertz; newVal = currentStartFreq + adjustmentInHz; } else if (this.subMenuSelected === 'stop') { - const currentStopFreq = this.analyzerControl.specA.state.centerFrequency + (this.analyzerControl.specA.state.span / 2) as Hertz; + const currentStopFreq = (this.analyzerControl.specA.state.centerFrequency + this.analyzerControl.specA.state.span / 2) as Hertz; newVal = currentStopFreq + adjustmentInHz; } // Round to nearest Hertz newVal = Math.round(newVal); - this.adjustValueInHz((newVal as Hertz)); + this.adjustValueInHz(newVal as Hertz); } onMajorTickChange(value: number): void { @@ -209,4 +209,4 @@ export class ACFreqBtn extends BaseControlButton { analyzerState.lockedControl = 'freq'; this.notifyStateChange_(); } -} \ No newline at end of file +} diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ghz-btn/ac-ghz-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ghz-btn/ac-ghz-btn.ts index a4621a62..37695881 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ghz-btn/ac-ghz-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-ghz-btn/ac-ghz-btn.ts @@ -1,6 +1,6 @@ -import { parseLocalizedNumber } from "@app/utils/parse-number"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { parseLocalizedNumber } from '@app/utils/parse-number'; import './ac-ghz-btn.css'; export class ACGhzBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-hz-btn/ac-hz-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-hz-btn/ac-hz-btn.ts index b351497c..f3210021 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-hz-btn/ac-hz-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-hz-btn/ac-hz-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-hz-btn.css'; export class ACHzBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-khz-btn/ac-khz-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-khz-btn/ac-khz-btn.ts index bb0a4ccb..7e96950c 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-khz-btn/ac-khz-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-khz-btn/ac-khz-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-khz-btn.css'; export class ACKhzBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-meas-btn/ac-meas-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-meas-btn/ac-meas-btn.ts index c1b77999..18d2a780 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-meas-btn/ac-meas-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-meas-btn/ac-meas-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-meas-btn.css'; export class ACMeasBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mhz-btn/ac-mhz-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mhz-btn/ac-mhz-btn.ts index f1d39cd8..11fb99cd 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mhz-btn/ac-mhz-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mhz-btn/ac-mhz-btn.ts @@ -1,6 +1,6 @@ -import { parseLocalizedNumber } from "@app/utils/parse-number"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { parseLocalizedNumber } from '@app/utils/parse-number'; import './ac-mhz-btn.css'; export class ACMhzBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr-btn/ac-mkr-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr-btn/ac-mkr-btn.ts index fd4c98ec..2052e264 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr-btn/ac-mkr-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr-btn/ac-mkr-btn.ts @@ -1,6 +1,6 @@ -import { Logger } from "@app/logging/logger"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { Logger } from '@app/logging/logger'; import './ac-mkr-btn.css'; export class ACMkrBtn extends BaseControlButton { @@ -43,8 +43,7 @@ export class ACMkrBtn extends BaseControlButton { } const numMarkers = this.analyzerControl.specA.state.topMarkers.length; - this.analyzerControl.specA.state.markerIndex = - (this.analyzerControl.specA.state.markerIndex + delta + numMarkers) % numMarkers; + this.analyzerControl.specA.state.markerIndex = (this.analyzerControl.specA.state.markerIndex + delta + numMarkers) % numMarkers; Logger.info('ACMkrBtn', 'changeMarkerIndex', `Marker index changed to ${this.analyzerControl.specA.state.markerIndex}`); } diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr2-btn/ac-mkr2-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr2-btn/ac-mkr2-btn.ts index 7c3deb0d..1d4c975b 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr2-btn/ac-mkr2-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mkr2-btn/ac-mkr2-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-mkr2-btn.css'; export class ACMkr2Btn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mode-btn/ac-mode-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mode-btn/ac-mode-btn.ts index 218fd773..389fd136 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mode-btn/ac-mode-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-mode-btn/ac-mode-btn.ts @@ -1,7 +1,7 @@ -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; import './ac-mode-btn.css'; export class ACModeBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-save-btn/ac-save-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-save-btn/ac-save-btn.ts index ca03b14f..c0209219 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-save-btn/ac-save-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-save-btn/ac-save-btn.ts @@ -1,6 +1,6 @@ -import { getEl } from "@app/engine/utils/get-el"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { getEl } from '@app/engine/utils/get-el'; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-save-btn.css'; export class ACSaveBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-span-btn/ac-span-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-span-btn/ac-span-btn.ts index 4fcad727..8f488c99 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-span-btn/ac-span-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-span-btn/ac-span-btn.ts @@ -1,7 +1,7 @@ -import { Logger } from "@app/logging/logger"; -import { Hertz } from "@app/types"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { Logger } from '@app/logging/logger'; +import { Hertz } from '@app/types'; import './ac-span-btn.css'; export class ACSpanBtn extends BaseControlButton { @@ -93,7 +93,7 @@ export class ACSpanBtn extends BaseControlButton { // Round to nearest Hertz newVal = Math.round(newVal); - this.adjustValueInHz((newVal as Hertz)); + this.adjustValueInHz(newVal as Hertz); } onMajorTickChange(value: number): void { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-sweep-btn/ac-sweep-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-sweep-btn/ac-sweep-btn.ts index c364369f..13dc6846 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-sweep-btn/ac-sweep-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-sweep-btn/ac-sweep-btn.ts @@ -1,5 +1,5 @@ -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; import './ac-sweep-btn.css'; export class ACSweepBtn extends BaseControlButton { diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-trace-btn/ac-trace-btn.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-trace-btn/ac-trace-btn.ts index 1b92155a..28b3aaad 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-trace-btn/ac-trace-btn.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/ac-trace-btn/ac-trace-btn.ts @@ -1,6 +1,6 @@ -import { Logger } from "@app/logging/logger"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; -import { BaseControlButton } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button"; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { BaseControlButton } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button'; +import { Logger } from '@app/logging/logger'; import './ac-trace-btn.css'; export type TraceMode = 'clearwrite' | 'hold' | 'maxhold' | 'minhold' | 'average'; diff --git a/src/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button.ts b/src/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button.ts index 6caa5d70..7a34528b 100644 --- a/src/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button.ts +++ b/src/equipment/real-time-spectrum-analyzer/analyzer-control/base-control-button.ts @@ -1,11 +1,11 @@ -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; -import { Logger } from "@app/logging/logger"; -import { Sfx } from "@app/sound/sfx-enum"; -import SoundManager from "@app/sound/sound-manager"; -import { AnalyzerControl } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { AnalyzerControl } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { Logger } from '@app/logging/logger'; +import { Sfx } from '@app/sound/sfx-enum'; +import SoundManager from '@app/sound/sound-manager'; export abstract class BaseControlButton { private readonly html_: string; @@ -17,7 +17,7 @@ export abstract class BaseControlButton { uniqueId, label, ariaLabel, - classNames = "physical-button control-button", + classNames = 'physical-button control-button', subtext, analyzerControl, }: { diff --git a/src/equipment/real-time-spectrum-analyzer/defaultSpectrumAnalyzerState.ts b/src/equipment/real-time-spectrum-analyzer/defaultSpectrumAnalyzerState.ts index 012f0412..61dbfddb 100644 --- a/src/equipment/real-time-spectrum-analyzer/defaultSpectrumAnalyzerState.ts +++ b/src/equipment/real-time-spectrum-analyzer/defaultSpectrumAnalyzerState.ts @@ -1,9 +1,9 @@ -import { Hertz, dB } from "@app/types"; -import { RealTimeSpectrumAnalyzerState } from "./real-time-spectrum-analyzer"; +import { dB, Hertz } from '@app/types'; +import { RealTimeSpectrumAnalyzerState } from './real-time-spectrum-analyzer'; export const defaultSpectrumAnalyzerState: Partial = { - isUseTapA: false, // TX IF - disabled by default to avoid confusion with RX signals - isUseTapB: true, // RX IF - primary tap for receive analysis + isUseTapA: false, // TX IF - disabled by default to avoid confusion with RX signals + isUseTapB: true, // RX IF - primary tap for receive analysis isPaused: false, isMaxHold: false, isMinHold: false, @@ -24,7 +24,7 @@ export const defaultSpectrumAnalyzerState: Partial Rng.stream('display:analyzer').next(); type MarkerPoint = { x: number; y: number; signal: number }; @@ -22,7 +26,7 @@ export interface RealTimeSpectrumAnalyzerState { /** Scale in dB per division */ scaleDbPerDiv: dB; isUseTapB: boolean; - isUseTapA: boolean + isUseTapA: boolean; /** This is the reference level that all dBm values are relative to */ referenceLevel: number; minFrequency: Hertz; @@ -73,6 +77,11 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { // Data processor - centralized data generation private dataProcessor: SpectrumDataProcessor; + /** Shared spectrum data source (read-only view for campaign UIs like the SDR Console) */ + get spectrumDataProcessor(): SpectrumDataProcessor { + return this.dataProcessor; + } + // Screen renderer screen: SpectralDensityPlot | WaterfallDisplay | null = null; spectralDensity: SpectralDensityPlot | null = null; @@ -231,36 +240,12 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { this.dataProcessor = new SpectrumDataProcessor(this, 824); // Initialize single-mode screens with shared data processor - this.spectralDensity = new SpectralDensityPlot( - this.domCache['canvas'] as HTMLCanvasElement, - this, - this.dataProcessor, - 824, - 460 - ); - this.waterfall = new WaterfallDisplay( - this.domCache['canvas'] as HTMLCanvasElement, - this, - this.dataProcessor, - 824, - 460 - ); + this.spectralDensity = new SpectralDensityPlot(this.domCache['canvas'] as HTMLCanvasElement, this, this.dataProcessor, 824, 460); + this.waterfall = new WaterfallDisplay(this.domCache['canvas'] as HTMLCanvasElement, this, this.dataProcessor, 824, 460); // Initialize "both" mode screens with their dedicated canvases and shared data processor - this.spectralDensityBoth = new SpectralDensityPlot( - this.domCache['canvasSpectral'] as HTMLCanvasElement, - this, - this.dataProcessor, - 824, - 230 - ); - this.waterfallBoth = new WaterfallDisplay( - this.domCache['canvasWaterfall'] as HTMLCanvasElement, - this, - this.dataProcessor, - 824, - 230 - ); + this.spectralDensityBoth = new SpectralDensityPlot(this.domCache['canvasSpectral'] as HTMLCanvasElement, this, this.dataProcessor, 824, 230); + this.waterfallBoth = new WaterfallDisplay(this.domCache['canvasWaterfall'] as HTMLCanvasElement, this, this.dataProcessor, 824, 230); // Set initial screen mode this.updateScreenVisibility(); @@ -365,7 +350,7 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { // If rbw is null we are in auto mode so use span as bandwidth const bandwidth = this.state.rbw ?? this.state.span; - let signals: (IfSignal | RfSignal)[] = []; + const signals: (IfSignal | RfSignal)[] = []; const tapPoints = []; if (this.state.isUseTapA) { @@ -385,9 +370,8 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { signals.push(...this.getSignalsAtTapPoint(tapPoint)); // Get noise floor using SignalPathManager - const { noiseFloorNoGain, shouldApplyGain } = - this.rfFrontEnd_.couplerModule.signalPathManager.getNoiseFloorAt(tapPoint, bandwidth); - const noiseFloorWithGain = noiseFloorNoGain + this.rfFrontEnd_.couplerModule.signalPathManager.getTotalGainTo(tapPoint) + const { noiseFloorNoGain, shouldApplyGain } = this.rfFrontEnd_.couplerModule.signalPathManager.getNoiseFloorAt(tapPoint, bandwidth); + const noiseFloorWithGain = noiseFloorNoGain + this.rfFrontEnd_.couplerModule.signalPathManager.getTotalGainTo(tapPoint); // Keep the highest noise floor from both tap points if (noiseFloorWithGain > maxNoiseFloorNoGain) { @@ -396,13 +380,11 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { } } - // Create copies when clamping bandwidth to avoid mutating original signals - signals = signals.map(sig => { - if (sig.bandwidth > bandwidth) { - return { ...sig, bandwidth }; - } - return sig; - }); + // NOTE: signals are deliberately NOT clamped to the RBW. RBW sets the + // display resolution and noise floor, not a signal's occupied bandwidth — + // a 2 MHz signal viewed with a 30 kHz RBW still spans 2 MHz on screen. + // (The renderer applies the matching PSD correction so wideband signals + // show per-bin power, not total power.) // Update state with the maximum noise floor found this.state.noiseFloorNoGain = maxNoiseFloorNoGain; @@ -531,8 +513,8 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { for (const signal of this.inputSignals) { if ( this.state.span < 320e6 && // 320 MHz minimum span - signal.frequency >= (this.state.centerFrequency - this.state.span / 2) && - signal.frequency <= (this.state.centerFrequency + this.state.span / 2) + signal.frequency >= this.state.centerFrequency - this.state.span / 2 && + signal.frequency <= this.state.centerFrequency + this.state.span / 2 ) { if (!strongestSignal || signal.power > strongestSignal.power) { strongestSignal = signal; @@ -543,7 +525,7 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { // If strongest signal is below the noise floor replace it with a fake signal at noise floor if (!strongestSignal || (strongestSignal && strongestSignal.power < this.noiseFloorAndGain)) { strongestSignal = { - frequency: Math.random() * this.state.span + (this.state.centerFrequency - this.state.span / 2), + frequency: random() * this.state.span + (this.state.centerFrequency - this.state.span / 2), power: this.noiseFloorAndGain, bandwidth: Math.min(this.state.span, 320e6), } as IfSignal | RfSignal; @@ -620,14 +602,14 @@ export class RealTimeSpectrumAnalyzer extends BaseEquipment { * Public getters for canvas elements (for adapter access) */ public getCanvas(): HTMLCanvasElement | null { - return this.domCache['canvas'] as HTMLCanvasElement || null; + return (this.domCache['canvas'] as HTMLCanvasElement) || null; } public getSpectralCanvas(): HTMLCanvasElement | null { - return this.domCache['canvasSpectral'] as HTMLCanvasElement || null; + return (this.domCache['canvasSpectral'] as HTMLCanvasElement) || null; } public getWaterfallCanvas(): HTMLCanvasElement | null { - return this.domCache['canvasWaterfall'] as HTMLCanvasElement || null; + return (this.domCache['canvasWaterfall'] as HTMLCanvasElement) || null; } -} \ No newline at end of file +} diff --git a/src/equipment/real-time-spectrum-analyzer/rtsa-screen/rtsa-screen.ts b/src/equipment/real-time-spectrum-analyzer/rtsa-screen/rtsa-screen.ts index 0b84121a..b445a12a 100644 --- a/src/equipment/real-time-spectrum-analyzer/rtsa-screen/rtsa-screen.ts +++ b/src/equipment/real-time-spectrum-analyzer/rtsa-screen/rtsa-screen.ts @@ -1,5 +1,5 @@ -import { Logger } from "@app/logging/logger"; -import { RealTimeSpectrumAnalyzer } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; +import { RealTimeSpectrumAnalyzer } from '@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer'; +import { Logger } from '@app/logging/logger'; export abstract class RTSAScreen { // Canvas elements @@ -30,15 +30,15 @@ export abstract class RTSAScreen { return this.width_; } - get height(): number { - return this.height_; - } - set width(value: number) { this.width_ = value; Logger.warn('RTSAScreen', 'set width', `Width set to ${value}, resizing canvas`); } + get height(): number { + return this.height_; + } + set height(value: number) { this.height_ = value; Logger.warn('RTSAScreen', 'set height', `Height set to ${value}, resizing canvas`); @@ -63,14 +63,19 @@ export abstract class RTSAScreen { */ static rgb2hex(rgb: number[]): string { - return '#' + rgb.map(x => { - const hex = x.toString(16); - return hex.length === 1 ? '0' + hex : hex; - }).join(''); + return ( + '#' + + rgb + .map((x) => { + const hex = x.toString(16); + return hex.length === 1 ? '0' + hex : hex; + }) + .join('') + ); } static getRandomRgb(i: number): string { - let rgb = [255, 0, 0]; + const rgb = [255, 0, 0]; if (i % 3 === 0) { rgb[0] = 255; rgb[1] = (i * 32) % 255; @@ -90,4 +95,4 @@ export abstract class RTSAScreen { } return RTSAScreen.rgb2hex(rgb); } -} \ No newline at end of file +} diff --git a/src/equipment/real-time-spectrum-analyzer/rtsa-screen/spectral-density-plot.ts b/src/equipment/real-time-spectrum-analyzer/rtsa-screen/spectral-density-plot.ts index 56ec9557..fca25f86 100644 --- a/src/equipment/real-time-spectrum-analyzer/rtsa-screen/spectral-density-plot.ts +++ b/src/equipment/real-time-spectrum-analyzer/rtsa-screen/spectral-density-plot.ts @@ -1,9 +1,13 @@ -import { SimulationManager } from "@app/simulation/simulation-manager"; -import { Hertz, IfSignal, MHz, RfSignal } from "@app/types"; -import type { TraceMode } from "@app/equipment/real-time-spectrum-analyzer/analyzer-control/ac-trace-btn/ac-trace-btn"; -import { RealTimeSpectrumAnalyzer } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; -import { SpectrumDataProcessor } from "@app/equipment/real-time-spectrum-analyzer/spectrum-data-processor"; -import { RTSAScreen } from "./rtsa-screen"; +import type { TraceMode } from '@app/equipment/real-time-spectrum-analyzer/analyzer-control/ac-trace-btn/ac-trace-btn'; +import { RealTimeSpectrumAnalyzer } from '@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer'; +import { SpectrumDataProcessor } from '@app/equipment/real-time-spectrum-analyzer/spectrum-data-processor'; +import { Rng } from '@app/simulation/rng'; +import { SimulationManager } from '@app/simulation/simulation-manager'; +import { Hertz, IfSignal, MHz, RfSignal } from '@app/types'; +import { RTSAScreen } from './rtsa-screen'; + +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('display:spectral-density').next(); /** * SpectralDensityPlot - Handles spectral density rendering @@ -34,10 +38,10 @@ export class SpectralDensityPlot extends RTSAScreen { // Trace colors based on mode private readonly traceColors = { clearwrite: '#fff647ff', // Yellow (live data) - maxhold: '#ff0000', // Red - minhold: '#0000ff', // Blue - average: '#ff00ff', // Magenta - hold: '#4eb136ff', // Yellow (maintains color from before hold) + maxhold: '#ff0000', // Red + minhold: '#0000ff', // Blue + average: '#ff00ff', // Magenta + hold: '#4eb136ff', // Yellow (maintains color from before hold) }; // Frequency label caching @@ -51,13 +55,7 @@ export class SpectralDensityPlot extends RTSAScreen { private cachedMaxAmplitude: number = 0; cachedReferenceLevel: number; - constructor( - canvas: HTMLCanvasElement, - specA: RealTimeSpectrumAnalyzer, - dataProcessor: SpectrumDataProcessor, - width: number, - height: number - ) { + constructor(canvas: HTMLCanvasElement, specA: RealTimeSpectrumAnalyzer, dataProcessor: SpectrumDataProcessor, width: number, height: number) { super(canvas, specA, width, height); // Store reference to shared data processor @@ -76,7 +74,7 @@ export class SpectralDensityPlot extends RTSAScreen { // Start after a random delay to stagger multiple analyzers setTimeout(() => { this.running = true; - }, Math.random() * 1000); + }, random() * 1000); } setFrequencyRange(minFreq: Hertz, maxFreq: Hertz): void { @@ -129,9 +127,11 @@ export class SpectralDensityPlot extends RTSAScreen { const minIntervalMs = 1000 / this.specA.state.refreshRate; if (now - this.lastDrawTime > Math.max(sweepTimeMs, minIntervalMs)) { // Invalidate grid cache if amplitude range changed - if (this.cachedMinAmplitude !== this.specA.state.minAmplitude || + if ( + this.cachedMinAmplitude !== this.specA.state.minAmplitude || this.cachedMaxAmplitude !== this.specA.state.maxAmplitude || - this.cachedReferenceLevel !== this.specA.state.referenceLevel) { + this.cachedReferenceLevel !== this.specA.state.referenceLevel + ) { this.cachedGridImageData = null; } @@ -191,7 +191,7 @@ export class SpectralDensityPlot extends RTSAScreen { case 'average': // Rolling average: 20% new, 80% old for (let x = 0; x < this.width; x++) { - this.traceData[traceIndex][x] = (this.traceData[traceIndex][x] * 0.8) + (currentData[x] * 0.2); + this.traceData[traceIndex][x] = this.traceData[traceIndex][x] * 0.8 + currentData[x] * 0.2; } break; @@ -201,7 +201,6 @@ export class SpectralDensityPlot extends RTSAScreen { } } - draw(): void { if (!this.specA.state.isPaused && this.running) { const now = Date.now(); @@ -211,11 +210,13 @@ export class SpectralDensityPlot extends RTSAScreen { const isDualScreenMode = this.canvas.id.endsWith('-spectral'); // Generate cached grid+labels ImageData if needed - if (!this.cachedGridImageData || + if ( + !this.cachedGridImageData || this.cachedMinFreq !== this.minFreq || this.cachedMaxFreq !== this.maxFreq || this.cachedMinAmplitude !== this.specA.state.minAmplitude || - this.cachedMaxAmplitude !== this.specA.state.maxAmplitude) { + this.cachedMaxAmplitude !== this.specA.state.maxAmplitude + ) { this.cachedGridImageData = this.generateGridImageData(isDualScreenMode); this.cachedMinFreq = this.minFreq; this.cachedMaxFreq = this.maxFreq; @@ -310,13 +311,13 @@ export class SpectralDensityPlot extends RTSAScreen { const topPeaks = peaks.slice(0, 10); // Convert to marker format - topPeaks.forEach(peak => { + topPeaks.forEach((peak) => { const y = (peak.signal - this.specA.state.minAmplitude) / this.range; if (y > 0 && y < 1) { this.specA.state.topMarkers.push({ x: peak.x, y: 1 - y, - signal: peak.signal + signal: peak.signal, }); } }); @@ -329,12 +330,12 @@ export class SpectralDensityPlot extends RTSAScreen { if (y > 0 && y < 1) { // Check if we already have a marker near this position - const tooClose = this.specA.state.topMarkers.some(m => Math.abs(m.x - x) < 10); + const tooClose = this.specA.state.topMarkers.some((m) => Math.abs(m.x - x) < 10); if (!tooClose) { this.specA.state.topMarkers.push({ x, y: 1 - y, - signal + signal, }); } } @@ -401,33 +402,17 @@ export class SpectralDensityPlot extends RTSAScreen { const formatCandidates = (freq: number): string[] => { if (freq >= 1e9) { const g = freq / 1e9; - return [ - `${g.toFixed(3)} GHz`, - `${g.toFixed(3)}GHz`, - `${Math.round(g)} GHz`, - `${Math.round(g)}GHz`, - `${Math.round(g)}G` - ]; + return [`${g.toFixed(3)} GHz`, `${g.toFixed(3)}GHz`, `${Math.round(g)} GHz`, `${Math.round(g)}GHz`, `${Math.round(g)}G`]; } else if (freq >= 1e6) { const m = freq / 1e6; - return [ - `${Math.round(m)} MHz`, - `${Math.round(m)}MHz`, - `${(m >= 1000) ? `${(m / 1000).toFixed(1)} GHz` : `${Math.round(m / 10) / 100}M`}`, - `${Math.round(m / 1000)}G` - ].filter(Boolean); + return [`${Math.round(m)} MHz`, `${Math.round(m)}MHz`, `${m >= 1000 ? `${(m / 1000).toFixed(1)} GHz` : `${Math.round(m / 10) / 100}M`}`, `${Math.round(m / 1000)}G`].filter( + Boolean + ); } else if (freq >= 1e3) { const k = freq / 1e3; - return [ - `${k.toFixed(3)} kHz`, - `${Math.round(k)} kHz`, - `${Math.round(k)}kHz`, - `${Math.round(freq)} Hz` - ]; + return [`${k.toFixed(3)} kHz`, `${Math.round(k)} kHz`, `${Math.round(k)}kHz`, `${Math.round(freq)} Hz`]; } else { - return [ - `${Math.round(freq)} Hz` - ]; + return [`${Math.round(freq)} Hz`]; } }; @@ -608,13 +593,12 @@ export class SpectralDensityPlot extends RTSAScreen { // Draw frequency label ctx.fillStyle = '#fff'; - const freqMhz = (this.minFreq + (maxX * (this.maxFreq - this.minFreq)) / this.width) / 1e6 as MHz; + const freqMhz = ((this.minFreq + (maxX * (this.maxFreq - this.minFreq)) / this.width) / 1e6) as MHz; ctx.fillText(`${freqMhz.toFixed(1)} MHz`, maxX - 20, this.height * maxY - 30); ctx.fillText(`${(maxSignalFreq).toFixed(1)} dB`, maxX - 20, this.height * maxY - 15); } } - /** * Signal Processing Methods * Note: Noise generation is now handled by SpectrumDataProcessor @@ -628,7 +612,7 @@ export class SpectralDensityPlot extends RTSAScreen { // Use outOfBandWidth as the basis for sigma to create a wider, more realistic Gaussian // This creates the smooth bell curve shape const sigma = outOfBandWidth / 3; // Adjust factor to control width - const gaussian = Math.exp(-0.5 * Math.pow(distance / sigma, 2)); + const gaussian = Math.exp(-0.5 * (distance / sigma) ** 2); // Convert gaussian to dB (this creates the smooth exponential rise/fall) const gaussianDb = 20 * Math.log10(Math.max(gaussian, 1e-10)); @@ -638,11 +622,11 @@ export class SpectralDensityPlot extends RTSAScreen { // Main lobe (center region) - add minimal jitter if (absDist <= inBandWidth) { - y += (Math.random() - 0.5) * 0.4; + y += (random() - 0.5) * 0.4; } // Transition region - slight additional rolloff for realism else if (absDist <= outOfBandWidth * 0.7) { - y += (Math.random() - 0.5) * 0.6; + y += (random() - 0.5) * 0.6; // Very subtle side lobe effect (much smaller than before) const sideLobeEffect = Math.sin((distance / outOfBandWidth) * Math.PI * 4) * 0.5; y += sideLobeEffect; @@ -650,19 +634,19 @@ export class SpectralDensityPlot extends RTSAScreen { // Outer region - more pronounced side lobes and taper else if (absDist <= outOfBandWidth) { const sideLobeEffect = Math.sin((distance / outOfBandWidth) * Math.PI * 6) * 0.8; - y += sideLobeEffect + (Math.random() - 0.5) * 1.0; + y += sideLobeEffect + (random() - 0.5) * 1.0; } // Beyond outOfBandWidth - natural exponential decay else { const excessDistance = absDist - outOfBandWidth; const decayFactor = Math.exp(-excessDistance / (outOfBandWidth * 0.3)); y += -20 * (1 - decayFactor); // Additional -20 dB taper beyond the main signal - y += (Math.random() - 0.5) * 1.5; + y += (random() - 0.5) * 1.5; } // Simulate occasional deep nulls for realism - if (Math.random() < 0.001) { - y -= 10 + Math.random() * 4; + if (random() < 0.001) { + y -= 10 + random() * 4; } // If noise floor is external, add RF front-end gain to match noise @@ -677,4 +661,4 @@ export class SpectralDensityPlot extends RTSAScreen { // Fixed size for consistency return; } -} \ No newline at end of file +} diff --git a/src/equipment/real-time-spectrum-analyzer/rtsa-screen/waterfall-display.ts b/src/equipment/real-time-spectrum-analyzer/rtsa-screen/waterfall-display.ts index cda9d4d5..70ca5ea5 100644 --- a/src/equipment/real-time-spectrum-analyzer/rtsa-screen/waterfall-display.ts +++ b/src/equipment/real-time-spectrum-analyzer/rtsa-screen/waterfall-display.ts @@ -1,7 +1,11 @@ -import { Hertz } from "@app/types"; -import { RealTimeSpectrumAnalyzer, RealTimeSpectrumAnalyzerState } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; -import { SpectrumDataProcessor } from "@app/equipment/real-time-spectrum-analyzer/spectrum-data-processor"; -import { RTSAScreen } from "./rtsa-screen"; +import { RealTimeSpectrumAnalyzer, RealTimeSpectrumAnalyzerState } from '@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer'; +import { SpectrumDataProcessor } from '@app/equipment/real-time-spectrum-analyzer/spectrum-data-processor'; +import { Rng } from '@app/simulation/rng'; +import { Hertz } from '@app/types'; +import { RTSAScreen } from './rtsa-screen'; + +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('display:waterfall').next(); export class WaterfallDisplay extends RTSAScreen { private running: boolean = false; @@ -29,13 +33,7 @@ export class WaterfallDisplay extends RTSAScreen { cacheMinDb: number = 0; cacheGain: number = 0; - constructor( - canvas: HTMLCanvasElement, - specA: RealTimeSpectrumAnalyzer, - dataProcessor: SpectrumDataProcessor, - width: number, - height: number - ) { + constructor(canvas: HTMLCanvasElement, specA: RealTimeSpectrumAnalyzer, dataProcessor: SpectrumDataProcessor, width: number, height: number) { super(canvas, specA, width, height); // Store reference to shared data processor @@ -65,7 +63,7 @@ export class WaterfallDisplay extends RTSAScreen { setTimeout(() => { this.running = true; - }, Math.random() * 1000); + }, random() * 1000); } private initializeColorCache(): void { @@ -117,7 +115,7 @@ export class WaterfallDisplay extends RTSAScreen { row.set(this.dataProcessor.combinedData); // Scroll buffer DOWN: remove oldest (bottom), add newest (top) - this.buffer.pop(); // Remove oldest from bottom + this.buffer.pop(); // Remove oldest from bottom this.buffer.unshift(row); // Add newest to top } } @@ -139,7 +137,7 @@ export class WaterfallDisplay extends RTSAScreen { const quarters = [ [0, Math.floor(this.height / 4)], [Math.floor(this.height / 4), Math.floor(this.height / 2)], - [Math.floor(this.height / 2), Math.floor((this.height * 3) / 4)] + [Math.floor(this.height / 2), Math.floor((this.height * 3) / 4)], ]; const renderQuarter = (index: number) => { @@ -153,7 +151,6 @@ export class WaterfallDisplay extends RTSAScreen { requestAnimationFrame(() => renderQuarter(0)); } } - } private renderWaterfallToImageData(start: number = 0, end: number = this.height): void { @@ -175,7 +172,7 @@ export class WaterfallDisplay extends RTSAScreen { for (let x = 0; x < this.width; x++) { // Map amplitude to color cache index const norm = Math.max(0, Math.min(1, (rowData[x] - minDb) / range)); - let cacheIndex = Math.floor(norm * (this.COLOR_CACHE_STEPS - 1)); + const cacheIndex = Math.floor(norm * (this.COLOR_CACHE_STEPS - 1)); const color = this.colorCache.get(cacheIndex) || [0, 0, 0]; const pixelOffset = rowOffset + x * 4; @@ -200,7 +197,7 @@ export class WaterfallDisplay extends RTSAScreen { let norm = (amplitude - minDb) / (maxDb - minDb); norm = Math.max(0, Math.min(1, norm)); // Bias towards darker blue to make signals stand out from noise - norm = norm ** 2.5; + norm **= 2.5; // Realistic spectrum analyzer gradient: dark blue -> light blue -> yellow -> orange -> red -> dark red if (norm < 0.2) { @@ -210,11 +207,7 @@ export class WaterfallDisplay extends RTSAScreen { } else if (norm < 0.4) { // Light Blue to Yellow const t = (norm - 0.2) / 0.2; - return [ - Math.floor(255 * t), - Math.floor(100 + 155 * t), - Math.floor(255 * (1 - t)) - ]; + return [Math.floor(255 * t), Math.floor(100 + 155 * t), Math.floor(255 * (1 - t))]; } else if (norm < 0.6) { // Yellow to Orange const t = (norm - 0.4) / 0.2; @@ -300,4 +293,4 @@ export class WaterfallDisplay extends RTSAScreen { return true; } -} \ No newline at end of file +} diff --git a/src/equipment/real-time-spectrum-analyzer/spectrum-data-processor.ts b/src/equipment/real-time-spectrum-analyzer/spectrum-data-processor.ts index 319412dc..6132d566 100644 --- a/src/equipment/real-time-spectrum-analyzer/spectrum-data-processor.ts +++ b/src/equipment/real-time-spectrum-analyzer/spectrum-data-processor.ts @@ -1,5 +1,6 @@ -import { Hertz, IfSignal, RfSignal } from "@app/types"; -import { RealTimeSpectrumAnalyzer } from "./real-time-spectrum-analyzer"; +import { Rng, RngStream } from '@app/simulation/rng'; +import { Hertz, IfSignal, RfSignal } from '@app/types'; +import { RealTimeSpectrumAnalyzer } from './real-time-spectrum-analyzer'; /** * SpectrumDataProcessor - Centralized data generation for spectrum analysis @@ -21,6 +22,8 @@ export class SpectrumDataProcessor { private minFreq: Hertz = 0 as Hertz; private maxFreq: Hertz = 0 as Hertz; private width: number; + /** Display-noise stream; refreshed in generateData() */ + private rng_: RngStream = Rng.stream('display:spectrum'); constructor(specA: RealTimeSpectrumAnalyzer, width: number) { this.specA = specA; @@ -45,6 +48,9 @@ export class SpectrumDataProcessor { * This should be called once per update cycle */ generateData(): void { + // Re-read each cycle: a scenario load re-seeds and replaces the stream + this.rng_ = Rng.stream('display:spectrum'); + // Generate noise data this.generateNoise(); @@ -70,48 +76,48 @@ export class SpectrumDataProcessor { // Generate multiple noise layers for (let x = 0; x < len; x++) { // Add randomized phase offsets to prevent coherent patterns - const randPhase1 = Math.random() * Math.PI * 2; - const randPhase2 = Math.random() * Math.PI * 2; - const randPhase3 = Math.random() * Math.PI * 2; - const randAmp1 = 0.8 + Math.random() * 0.4; - const randAmp2 = 1.2 + Math.random() * 0.6; - const randAmp3 = 0.2 + Math.random() * 0.4; + const randPhase1 = this.rng_.next() * Math.PI * 2; + const randPhase2 = this.rng_.next() * Math.PI * 2; + const randPhase3 = this.rng_.next() * Math.PI * 2; + const randAmp1 = 0.8 + this.rng_.next() * 0.4; + const randAmp2 = 1.2 + this.rng_.next() * 0.6; + const randAmp3 = 0.2 + this.rng_.next() * 0.4; // Layer 1: Gaussian-distributed base noise (natural thermal noise distribution) // stdDev of 0.6 dB gives realistic spread - most samples within ±1.2 dB let noise = this.gaussianRandom_(base, 0.6); // Layer 2: Smooth low-frequency drift (additive, not multiplicative) - noise += Math.sin((x / 300) + time / 8 + randPhase1) * randAmp1 * 0.15; + noise += Math.sin(x / 300 + time / 8 + randPhase1) * randAmp1 * 0.15; // Layer 3: Very subtle high-frequency jitter (additive) - noise += Math.sin((x * 0.5 + time * 2 + randPhase2)) * randAmp2 * 0.005; + noise += Math.sin(x * 0.5 + time * 2 + randPhase2) * randAmp2 * 0.005; // Layer 4: Band-limited noise (simulate mild interference, additive) if (x > len * 0.4 && x < len * 0.6) { - noise += Math.sin((x / 40) + time * 1.5 + randPhase3) * randAmp3 * 0.02; + noise += Math.sin(x / 40 + time * 1.5 + randPhase3) * randAmp3 * 0.02; } // Layer 5: Frequent small random peaks (creates natural "grass" above baseline) // ~3% of samples get small bumps - if (Math.random() < 0.03) { - noise += 0.5 + Math.random() * 1.5; + if (this.rng_.next() < 0.03) { + noise += 0.5 + this.rng_.next() * 1.5; } // Layer 6: Frequent small random dips (natural variation below baseline) // ~3% of samples get small dips - if (Math.random() < 0.03) { - noise -= 0.3 + Math.random() * 1.0; + if (this.rng_.next() < 0.03) { + noise -= 0.3 + this.rng_.next() * 1.0; } // Layer 7: Rare larger impulse spikes (fixed amplitude, not scaled by base) - if (Math.random() < 0.0001) { - noise += 2 + Math.random() * 3; + if (this.rng_.next() < 0.0001) { + noise += 2 + this.rng_.next() * 3; } // Layer 8: Rare larger dropouts (fixed amplitude) - if (Math.random() < 0.0002) { - noise -= 1 + Math.random() * 2; + if (this.rng_.next() < 0.0002) { + noise -= 1 + this.rng_.next() * 2; } // If noise floor is external, add RF front-end gain @@ -130,6 +136,8 @@ export class SpectrumDataProcessor { // Initialize signal data with minimum amplitude this.signalData.fill(this.specA.state.minAmplitude); + const rbw = this.specA.state.rbw; + // Process each input signal this.specA.inputSignals.forEach((signal) => { const center = ((signal.frequency - this.minFreq) / (this.maxFreq - this.minFreq)) * this.width; @@ -145,7 +153,13 @@ export class SpectrumDataProcessor { // Out-of-band width is the total distance from center to signal edge const outOfBandWidth = halfBandwidthPixels; - this.addSignalToData(signal, center, inBandWidth, outOfBandWidth); + // Spectral density: a signal wider than the RBW shows per-bin power + // (total power spread across bandwidth/RBW bins), matching how a real + // analyzer renders wideband signals. Narrow signals are unaffected, and + // the displayed noise floor is already per-RBW, so heights stay honest. + const psdCorrection_dB = rbw && signal.bandwidth > rbw ? 10 * Math.log10(signal.bandwidth / rbw) : 0; + + this.addSignalToData(signal, center, inBandWidth, outOfBandWidth, psdCorrection_dB); }); } @@ -153,17 +167,15 @@ export class SpectrumDataProcessor { * Add a single signal to the signal data array * Uses a flat-top shape with steep roll-off at the band edges */ - private addSignalToData( - signal: IfSignal | RfSignal, - center: number, - inBandWidth: number, - outOfBandWidth: number - ): void { + private addSignalToData(signal: IfSignal | RfSignal, center: number, inBandWidth: number, outOfBandWidth: number, psdCorrection_dB: number = 0): void { // inBandWidth = flat-top region (e.g., 17.5 MHz from center for 36 MHz signal) // outOfBandWidth = total signal edge (e.g., 18 MHz from center = half-bandwidth) // Roll-off region spans from inBandWidth to outOfBandWidth const rollOffWidth = outOfBandWidth - inBandWidth; + // Displayed level: per-RBW-bin power for wideband signals (see generateSignals) + const displayPower = signal.power - psdCorrection_dB; + // Only process pixels within the signal's influence region const startX = Math.max(0, Math.floor(center - outOfBandWidth)); const endX = Math.min(this.width, Math.ceil(center + outOfBandWidth)); @@ -176,24 +188,24 @@ export class SpectrumDataProcessor { // Flat-top region - near peak amplitude if (absDist <= inBandWidth) { - y = signal.power; + y = displayPower; // Add noise-like variation similar to noise floor // Base random variation y += this.gaussianRandom_(0, 0.4); // Occasional small bumps (like noise "grass") - if (Math.random() < 0.05) { - y += 0.3 + Math.random() * 0.8; + if (this.rng_.next() < 0.05) { + y += 0.3 + this.rng_.next() * 0.8; } // Occasional small dips - if (Math.random() < 0.05) { - y -= 0.2 + Math.random() * 0.6; + if (this.rng_.next() < 0.05) { + y -= 0.2 + this.rng_.next() * 0.6; } // Very subtle slow variation across the flat top - y += Math.sin((x / 50) + Date.now() / 2000) * 0.15; + y += Math.sin(x / 50 + Date.now() / 2000) * 0.15; } // Roll-off region - steep transition at the band edges else if (absDist <= outOfBandWidth) { @@ -203,7 +215,7 @@ export class SpectrumDataProcessor { const rolloff = 0.5 * (1 + Math.cos(Math.PI * rollOffProgress)); const rolloffDb = 20 * Math.log10(Math.max(rolloff, 1e-10)); - y = signal.power + rolloffDb; + y = displayPower + rolloffDb; // Add variation that increases as we move away from center y += this.gaussianRandom_(0, 0.5 + rollOffProgress * 0.3); @@ -217,8 +229,8 @@ export class SpectrumDataProcessor { } // Simulate occasional deep nulls for realism - if (Math.random() < 0.001) { - y -= 8 + Math.random() * 4; + if (this.rng_.next() < 0.001) { + y -= 8 + this.rng_.next() * 4; } // NOTE: Signals from agcModule.outputSignals already include all chain gains @@ -275,8 +287,8 @@ export class SpectrumDataProcessor { * Generate Gaussian-distributed random number using Box-Muller transform */ private gaussianRandom_(mean: number, stdDev: number): number { - const u1 = Math.random(); - const u2 = Math.random(); + const u1 = this.rng_.next(); + const u2 = this.rng_.next(); const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2); return mean + z * stdDev; } diff --git a/src/equipment/receiver/adc-degradation.ts b/src/equipment/receiver/adc-degradation.ts index 9ab3d2c4..b4f818d0 100644 --- a/src/equipment/receiver/adc-degradation.ts +++ b/src/equipment/receiver/adc-degradation.ts @@ -37,10 +37,7 @@ export interface ADCDegradationResult { * @param config - ADC configuration (optional, uses defaults) * @returns Degradation result with penalties and status */ -export function calculateADCDegradation( - signalLevel_dBm: dBm, - config: ADCConfig = DEFAULT_ADC_CONFIG -): ADCDegradationResult { +export function calculateADCDegradation(signalLevel_dBm: dBm, config: ADCConfig = DEFAULT_ADC_CONFIG): ADCDegradationResult { const level_dBFS = dBmToDbfs(signalLevel_dBm, config); let clipPenalty = 0 as dB; @@ -54,7 +51,7 @@ export function calculateADCDegradation( // Exponential degradation above clip threshold // Mild overdrive (0-3 dB): 1-4 dB penalty // Severe overdrive (>6 dB): 10+ dB penalty - clipPenalty = ((Math.pow(2, overdrive / 3) - 1) * 3) as dB; + clipPenalty = ((2 ** (overdrive / 3) - 1) * 3) as dB; status = overdrive > 6 ? 'severe-clipping' : 'clipping'; } diff --git a/src/equipment/receiver/fec-simulator.ts b/src/equipment/receiver/fec-simulator.ts index 2dda4503..d27c975f 100644 --- a/src/equipment/receiver/fec-simulator.ts +++ b/src/equipment/receiver/fec-simulator.ts @@ -1,3 +1,4 @@ +import { SimClock } from '@app/simulation/sim-clock'; /** * @file FEC Simulator Module * @description Simulates Forward Error Correction (FEC) metrics based on signal quality. @@ -6,7 +7,7 @@ * statistics from carrier-to-noise ratio and modulation parameters. */ -import { ModulationType, FECType } from '@app/types'; +import { FECType, ModulationType } from '@app/types'; /** * Input parameters for FEC simulation @@ -81,7 +82,7 @@ export class FECSimulator { private smoothedViterbi_: number = 0.95; // Timing for rate calculations - private lastUpdateTime_: number = Date.now(); + private lastUpdateTime_: number = SimClock.runMs(); private framesPerSecond_: number = 125; // Default frame rate // Fault injection overrides @@ -95,11 +96,11 @@ export class FECSimulator { * Higher order modulations require higher C/N for same BER */ private static readonly MODULATION_OFFSETS: Record = { - 'BPSK': 0, // 1 bit/symbol - most robust - 'QPSK': 3, // 2 bits/symbol - '8QAM': 5.5, // 3 bits/symbol - '16QAM': 7, // 4 bits/symbol - least robust - 'null': 0, + BPSK: 0, // 1 bit/symbol - most robust + QPSK: 3, // 2 bits/symbol + '8QAM': 5.5, // 3 bits/symbol + '16QAM': 7, // 4 bits/symbol - least robust + null: 0, }; /** @@ -107,20 +108,21 @@ export class FECSimulator { * Lower rate codes have more redundancy and better correction */ private static readonly FEC_CODING_GAIN: Record = { - '1/2': 5.0, // 50% redundancy - best correction - '2/3': 4.0, // 33% redundancy - '3/4': 3.0, // 25% redundancy - '5/6': 2.0, // 17% redundancy - '7/8': 1.5, // 12.5% redundancy - least correction - 'null': 0, + '1/2': 5.0, // 50% redundancy - best correction + '2/3': 4.0, // 33% redundancy + '3/4': 3.0, // 25% redundancy + '5/6': 2.0, // 17% redundancy + '7/8': 1.5, // 12.5% redundancy - least correction + null: 0, }; /** * Calculate FEC metrics from signal parameters */ calculate(input: FECSimulatorInput): FECMetrics { - const now = Date.now(); - const deltaTime = now - this.lastUpdateTime_; + const now = SimClock.runMs(); + // Clamped: run time restarts at 0 each scenario + const deltaTime = Math.max(0, now - this.lastUpdateTime_); this.lastUpdateTime_ = now; const effectiveCn = input.effectiveCnRatio_dB ?? input.cnRatio_dB; @@ -137,12 +139,7 @@ export class FECSimulator { // Determine channel status using SMOOTHED metrics for stability // Combined with hysteresis in determineChannelStatus_, this prevents // status flickering when signal quality hovers near thresholds - const channelStatus = this.determineChannelStatus_( - frameSyncLocked, - this.smoothedBer_, - this.smoothedViterbi_, - this.rsUncorrectableRecent_ - ); + const channelStatus = this.determineChannelStatus_(frameSyncLocked, this.smoothedBer_, this.smoothedViterbi_, this.rsUncorrectableRecent_); // Calculate data rate based on modulation and FEC const dataRate = this.calculateDataRate_(input.modulation, input.fec); @@ -243,7 +240,7 @@ export class FECSimulator { // Convert C/N to Eb/N0 using modulation offset const offset = FECSimulator.MODULATION_OFFSETS[modulation] ?? 0; const ebN0_dB = cnRatio_dB - offset; - const ebN0_linear = Math.pow(10, ebN0_dB / 10); + const ebN0_linear = 10 ** (ebN0_dB / 10); // BER using erfc approximation // erfc(x) ≈ exp(-x²) / (x * sqrt(π)) for large x @@ -281,7 +278,7 @@ export class FECSimulator { x = Math.abs(x); const t = 1.0 / (1.0 + p * x); - const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); + const y = 1.0 - ((((a5 * t + a4) * t + a3) * t + a2) * t + a1) * t * Math.exp(-x * x); return sign === 1 ? 1 - y : 1 + y; } @@ -378,12 +375,7 @@ export class FECSimulator { * - Requires crossing threshold by a margin to change status * - Prevents flicker when signal is borderline */ - private determineChannelStatus_( - frameSyncLocked: boolean, - ber: number, - viterbiMetric: number, - rsUncorrectable: number - ): 'Good' | 'Degraded' | 'Critical' | 'No Lock' { + private determineChannelStatus_(frameSyncLocked: boolean, ber: number, viterbiMetric: number, rsUncorrectable: number): 'Good' | 'Degraded' | 'Critical' | 'No Lock' { // No frame sync = No Lock (no hysteresis needed - binary condition) if (!frameSyncLocked) { this.lastChannelStatus_ = 'No Lock'; @@ -442,20 +434,14 @@ export class FECSimulator { } /** Check if newStatus is worse than oldStatus */ - private isWorse_( - newStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock', - oldStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock' - ): boolean { - const rank = { 'Good': 0, 'Degraded': 1, 'Critical': 2, 'No Lock': 3 }; + private isWorse_(newStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock', oldStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock'): boolean { + const rank = { Good: 0, Degraded: 1, Critical: 2, 'No Lock': 3 }; return rank[newStatus] > rank[oldStatus]; } /** Check if newStatus is better than oldStatus */ - private isBetter_( - newStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock', - oldStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock' - ): boolean { - const rank = { 'Good': 0, 'Degraded': 1, 'Critical': 2, 'No Lock': 3 }; + private isBetter_(newStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock', oldStatus: 'Good' | 'Degraded' | 'Critical' | 'No Lock'): boolean { + const rank = { Good: 0, Degraded: 1, Critical: 2, 'No Lock': 3 }; return rank[newStatus] < rank[oldStatus]; } @@ -465,11 +451,11 @@ export class FECSimulator { private calculateDataRate_(modulation: ModulationType, fec: FECType): string { // Bits per symbol const bitsPerSymbol: Record = { - 'BPSK': 1, - 'QPSK': 2, + BPSK: 1, + QPSK: 2, '8QAM': 3, '16QAM': 4, - 'null': 0, + null: 0, }; // FEC efficiency (data bits / total bits) @@ -479,7 +465,7 @@ export class FECSimulator { '3/4': 0.75, '5/6': 0.833, '7/8': 0.875, - 'null': 1.0, + null: 1.0, }; // Assume 2.048 Msps symbol rate (common SATCOM) diff --git a/src/equipment/receiver/receiver.css b/src/equipment/receiver/receiver.css index 977a6bda..4855c76f 100644 --- a/src/equipment/receiver/receiver.css +++ b/src/equipment/receiver/receiver.css @@ -10,11 +10,11 @@ align-items: center; margin-bottom: 16px; padding-bottom: 12px; - border-bottom: 1px solid #444; + border-bottom: 1px solid var(--mc-equip-border-light); } .receiver-title { - color: #fff; + color: var(--mc-equip-text); font-size: 18px; font-weight: bold; } @@ -29,26 +29,26 @@ .status-found, .status-signal-found { background-color: #4caf50; - color: #fff; + color: var(--mc-equip-text); } .status-degraded, .status-signal-degraded { background-color: #ff9800; - color: #fff; + color: var(--mc-equip-text); } .status-no-power, .status-denied, .status-signal-denied { background-color: #f44336; - color: #fff; + color: var(--mc-equip-text); } .status-standby, .status-no-signal { - background-color: #757575; - color: #fff; + background-color: var(--mc-equip-unlit); + color: var(--mc-equip-text); } /* Controls Container - now horizontal layout */ @@ -69,9 +69,9 @@ .btn-modem { padding: 12px; - background-color: #555; - color: #fff; - border: 2px solid #777; + background-color: var(--mc-equip-control); + color: var(--mc-equip-text); + border: 2px solid var(--mc-equip-control-lighter); border-radius: 4px; font-weight: bold; font-size: 16px; @@ -80,7 +80,7 @@ } .btn-modem:hover { - background-color: #666; + background-color: var(--mc-equip-control-light); transform: translateX(2px); } @@ -135,7 +135,7 @@ flex-direction: column; gap: 5px; padding: 12px; - background-color: #1a1a1a; + background-color: var(--mc-equip-bg); border-radius: 8px; width: 285px; height: 100%; @@ -150,7 +150,7 @@ } .config-row label { - color: #fff; + color: var(--mc-equip-text); font-size: 14px; text-align: right; } @@ -158,9 +158,9 @@ .config-row input, .config-row select { padding: 5px; - background-color: #2a2a2a; - color: #fff; - border: 1px solid #555; + background-color: var(--mc-equip-panel-raised); + color: var(--mc-equip-text); + border: 1px solid var(--mc-equip-control); border-radius: 4px; font-size: 14px; } @@ -190,7 +190,7 @@ .video-monitor { padding: 8px; background-color: #000; - border: 4px solid #333; + border: 4px solid var(--mc-equip-border); border-radius: 8px; box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.8); display: flex; @@ -204,7 +204,7 @@ display: flex; align-items: center; justify-content: center; - background: radial-gradient(ellipse at center, #0a0a0a 0%, #000 100%); + background: radial-gradient(ellipse at center, var(--mc-equip-bg-deep) 0%, #000 100%); border-radius: 4px; position: relative; overflow: hidden; @@ -223,7 +223,7 @@ } .monitor-screen.no-power { - background: #050505; + background: var(--mc-equip-bg-deepest); } .video-feed { @@ -235,7 +235,7 @@ } .no-signal-text { - color: #666; + color: var(--mc-equip-control-light); font-size: 18px; font-weight: bold; letter-spacing: 2px; @@ -287,14 +287,14 @@ gap: 12px; margin-top: auto; padding-top: 12px; - border-top: 1px solid #444; + border-top: 1px solid var(--mc-equip-border-light); } .btn-apply { flex: 1; padding: 12px; - background-color: #555; - color: #fff; + background-color: var(--mc-equip-control); + color: var(--mc-equip-text); border: none; border-radius: 4px; font-weight: bold; @@ -304,7 +304,7 @@ } .btn-apply:hover { - background-color: #666; + background-color: var(--mc-equip-control-light); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } diff --git a/src/equipment/receiver/receiver.ts b/src/equipment/receiver/receiver.ts index 1acbfb12..54e221c1 100644 --- a/src/equipment/receiver/receiver.ts +++ b/src/equipment/receiver/receiver.ts @@ -1,13 +1,14 @@ -import { PowerSwitch } from "@app/components/power-switch/power-switch"; -import { EventBus } from "@app/events/event-bus"; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { Events } from "@app/events/events"; -import { dBm, FECType, Hertz, IfSignal, MHz, ModulationType } from "@app/types"; -import { AntennaCore } from "@app/equipment/antenna"; -import { AlarmStatus, BaseEquipment } from "@app/equipment/base-equipment"; -import { TapPoint } from "@app/equipment/rf-front-end/coupler-module/tap-points"; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { PowerSwitch } from '@app/components/power-switch/power-switch'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { AntennaCore } from '@app/equipment/antenna'; +import { AlarmStatus, BaseEquipment } from '@app/equipment/base-equipment'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { SimClock } from '@app/simulation/sim-clock'; +import { dBm, FECType, Hertz, IfSignal, MHz, ModulationType } from '@app/types'; import { ADCDegradationResult, calculateADCDegradation } from './adc-degradation'; import './receiver.css'; @@ -19,6 +20,12 @@ export interface ReceiverModemState { modulation: ModulationType; fec: FECType; isPowered: boolean; + /** + * Automatic frequency control: when enabled the modem center frequency slews + * toward the received carrier (rate-limited), tracking Doppler drift. + * Opt-in: when omitted/false tuning is fully manual (legacy behavior). + */ + isAfcEnabled?: boolean; } export interface ReceiverState { @@ -39,12 +46,12 @@ export interface ReceiverState { * Uses relaxed filtering to show signals even when modem config doesn't match. */ export interface IQSignalInfo { - hasCarrier: boolean; // Any RF signal in bandwidth - hasLock: boolean; // Modem can demodulate (mod + FEC match) + hasCarrier: boolean; // Any RF signal in bandwidth + hasLock: boolean; // Modem can demodulate (mod + FEC match) actualModulation: ModulationType | null; configuredModulation: ModulationType; - cnRatio_dB: number; // Carrier-to-noise ratio (raw, before ADC effects) - frequencyOffset_Hz: number; // Offset from center frequency + cnRatio_dB: number; // Carrier-to-noise ratio (raw, before ADC effects) + frequencyOffset_Hz: number; // Offset from center frequency modulationMismatch: boolean; fecMismatch: boolean; /** ADC degradation result (clipping/quantization effects) */ @@ -96,9 +103,7 @@ export class Receiver extends BaseEquipment { const server_id = state?.server_id ?? serverId; // Merge modem overrides by modemNumber (so callers don't have to provide a full ordered array) - const overridesByModemNumber = new Map>( - (state?.modems ?? []).map(m => [m.modemNumber, m]) - ); + const overridesByModemNumber = new Map>((state?.modems ?? []).map((m) => [m.modemNumber, m])); const modems: ReceiverModemState[] = defaults.modems.map((def) => { const override = overridesByModemNumber.get(def.modemNumber); @@ -154,10 +159,65 @@ export class Receiver extends BaseEquipment { } update(): void { + this.updateAfc_(); this.checkForAlarms_(); this.syncDomWithState(); } + /** Max AFC retune per update tick (Hz) — slow enough to feel like a tracking loop */ + private static readonly AFC_MAX_STEP_HZ = 200; + /** Offsets below this are considered centered; AFC holds (Hz) */ + private static readonly AFC_DEADBAND_HZ = 10; + + /** + * Automatic frequency control: slew each AFC-enabled modem toward the + * carrier it is receiving. Only acts when a carrier is present; opt-in per + * modem via isAfcEnabled (absent on all legacy configs). + */ + private updateAfc_(): void { + for (const modem of this.state.modems) { + if (!modem.isAfcEnabled || !modem.isPowered) continue; + + const info = this.getSignalsInBandwidth(modem); + if (!info.hasCarrier) continue; + + const offsetHz = info.frequencyOffset_Hz; + if (Math.abs(offsetHz) < Receiver.AFC_DEADBAND_HZ) continue; + + const stepHz = Math.max(-Receiver.AFC_MAX_STEP_HZ, Math.min(Receiver.AFC_MAX_STEP_HZ, offsetHz)); + modem.frequency = (modem.frequency + stepHz / 1e6) as MHz; + } + } + + public handleAfcToggle(modemNumber: number, isEnabled: boolean): void { + const modem = this.state.modems.find((m) => m.modemNumber === modemNumber); + if (modem) { + modem.isAfcEnabled = isEnabled; + } + } + + public handleModemFrequencyChange(modemNumber: number, frequencyMHz: number): void { + const modem = this.state.modems.find((m) => m.modemNumber === modemNumber); + if (modem) { + modem.frequency = frequencyMHz as MHz; + } + } + + public handleModemConfigChange(modemNumber: number, config: { modulation?: ModulationType; fec?: FECType; bandwidthMHz?: number }): void { + const modem = this.state.modems.find((m) => m.modemNumber === modemNumber); + if (!modem) return; + + if (config.modulation !== undefined) { + modem.modulation = config.modulation; + } + if (config.fec !== undefined) { + modem.fec = config.fec; + } + if (config.bandwidthMHz !== undefined && config.bandwidthMHz > 0) { + modem.bandwidth = config.bandwidthMHz as MHz; + } + } + initialSync(): void { this.inputData = { ...this.activeModem }; } @@ -185,13 +245,17 @@ export class Receiver extends BaseEquipment {
- ${this.state.modems.map(modem => html` + ${this.state.modems + .map( + (modem) => html` - `).join('')} + ` + ) + .join('')}
@@ -260,12 +324,13 @@ export class Receiver extends BaseEquipment {
- ${feedUrl.length > 0 - ? html`
+ ${ + feedUrl.length > 0 + ? html`
` - : html`NO SIGNAL` - } + : html`NO SIGNAL` + }
@@ -292,7 +357,7 @@ export class Receiver extends BaseEquipment { // Cache frequently used DOM nodes for efficient updates this.domCache['parent'] = parentDom; this.domCache['led'] = qs('.led', parentDom); - this.state.modems.forEach(modem => { + this.state.modems.forEach((modem) => { this.domCache[`modemButton${modem.modemNumber}`] = qs(`#modem-${modem.modemNumber}`, parentDom); }); this.domCache['inputAntenna'] = qs('.input-rx-antenna', parentDom); @@ -321,7 +386,7 @@ export class Receiver extends BaseEquipment { protected addListeners_(parentDom: HTMLElement): void { // Modem selection buttons const modemButtons = parentDom.querySelectorAll('.btn-modem'); - modemButtons.forEach(btn => { + modemButtons.forEach((btn) => { btn.addEventListener('click', (e) => { const modemNum = Number.parseInt((e.target as HTMLElement).dataset.modem || '1'); this.setActiveModem(modemNum); @@ -330,7 +395,7 @@ export class Receiver extends BaseEquipment { // Input changes const inputs = parentDom.querySelectorAll('input, select'); - inputs.forEach(input => { + inputs.forEach((input) => { input.addEventListener('change', (e) => this.handleInputChange(e)); }); @@ -351,7 +416,7 @@ export class Receiver extends BaseEquipment { if (this.state.availableSignals.length > 0) { alarms.push({ message: `Signal(s) Detected`, - severity: 'info' + severity: 'info', }); } @@ -359,16 +424,19 @@ export class Receiver extends BaseEquipment { } private togglePower(isOn: boolean): void { - setTimeout(() => { - this.activeModem.isPowered = isOn; - - this.emit(Events.RX_CONFIG_CHANGED, { - uuid: this.uuid, - modem: this.state.activeModem, - config: this.state.modems.find(m => m.modemNumber === this.state.activeModem) - }); - this.syncDomWithState(); - }, isOn ? 4000 : 250); + SimClock.setTimeout( + () => { + this.activeModem.isPowered = isOn; + + this.emit(Events.RX_CONFIG_CHANGED, { + uuid: this.uuid, + modem: this.state.activeModem, + config: this.state.modems.find((m) => m.modemNumber === this.state.activeModem), + }); + this.syncDomWithState(); + }, + isOn ? 4000 : 250 + ); } protected initialize_(): void { @@ -409,7 +477,7 @@ export class Receiver extends BaseEquipment { */ get activeModem(): ReceiverModemState { - return this.state.modems.find(m => m.modemNumber === this.state.activeModem) ?? this.state.modems[0]; + return this.state.modems.find((m) => m.modemNumber === this.state.activeModem) ?? this.state.modems[0]; } get antennas(): AntennaCore[] { @@ -428,7 +496,7 @@ export class Receiver extends BaseEquipment { // Emit event for modem change this.emit(Events.RX_ACTIVE_MODEM_CHANGED, { uuid: this.uuid, - activeModem: modemNumber + activeModem: modemNumber, }); } @@ -458,7 +526,7 @@ export class Receiver extends BaseEquipment { public hasSignalForModem(modem: ReceiverModemState): boolean { const visibleSignals = this.getVisibleSignals(modem); - return visibleSignals.some(s => s.feed !== ''); + return visibleSignals.some((s) => s.feed !== ''); } public isSignalDegraded(modem: ReceiverModemState): boolean { @@ -466,7 +534,7 @@ export class Receiver extends BaseEquipment { if (visibleSignals.length === 0) return false; // Check if any signal is degraded - return visibleSignals.some(s => s.isDegraded); + return visibleSignals.some((s) => s.isDegraded); } /** @@ -494,7 +562,7 @@ export class Receiver extends BaseEquipment { // Target signal is the one with the largest bandwidth that fits the modem // This distinguishes the actual signal from narrowband interference - const targetSignal = visibleSignals.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b, visibleSignals[0]); + const targetSignal = visibleSignals.reduce((a, b) => (a.bandwidth > b.bandwidth ? a : b), visibleSignals[0]); return targetSignal.power; } @@ -529,7 +597,7 @@ export class Receiver extends BaseEquipment { } // Bandwidth must fit - if (s.bandwidth > (modem.bandwidth * 1e6 as Hertz)) { + if (s.bandwidth > ((modem.bandwidth * 1e6) as Hertz)) { return false; } @@ -551,18 +619,16 @@ export class Receiver extends BaseEquipment { // Find the target signal - the one with the largest bandwidth that fits // This distinguishes the actual signal from narrowband interference // Among signals with matching modulation/FEC, pick the one with largest bandwidth - const modFecMatches = signalsInBand.filter(s => - s.modulation === modem.modulation && s.fec === modem.fec - ); + const modFecMatches = signalsInBand.filter((s) => s.modulation === modem.modulation && s.fec === modem.fec); // Use mod/FEC matches if available, otherwise all signals in band const candidates = modFecMatches.length > 0 ? modFecMatches : signalsInBand; // Target is the signal with the largest bandwidth - const targetSignal = candidates.reduce((a, b) => a.bandwidth > b.bandwidth ? a : b, candidates[0]); + const targetSignal = candidates.reduce((a, b) => (a.bandwidth > b.bandwidth ? a : b), candidates[0]); // Calculate modem bandwidth for noise floor calculation - const modemBandwidthHz = modem.bandwidth * 1e6 as Hertz; + const modemBandwidthHz = (modem.bandwidth * 1e6) as Hertz; // Calculate thermal noise floor based on modem bandwidth // Narrower bandwidth = lower noise floor, wider bandwidth = higher noise floor @@ -571,21 +637,16 @@ export class Receiver extends BaseEquipment { // Calculate interference power within modem bandwidth const modemCenterHz = modem.frequency * 1e6; - const interferencePower = this.calculateInterferencePower_( - targetSignal, - signalsInBand, - modemBandwidthHz, - modemCenterHz - ); + const interferencePower = this.calculateInterferencePower_(targetSignal, signalsInBand, modemBandwidthHz, modemCenterHz); // Combine thermal noise and interference (linear power addition) - const thermalNoiseMw = Math.pow(10, thermalNoiseFloor / 10); - const interferenceMw = interferencePower > -Infinity ? Math.pow(10, interferencePower / 10) : 0; + const thermalNoiseMw = 10 ** (thermalNoiseFloor / 10); + const interferenceMw = interferencePower > -Infinity ? 10 ** (interferencePower / 10) : 0; const effectiveNoiseFloor = 10 * Math.log10(thermalNoiseMw + interferenceMw); // C/N ratio now includes interference // Signal from AGC output already includes all chain gains, so don't add totalGain again - const signalLevel = targetSignal.power; // Already includes all chain gains + const signalLevel = targetSignal.power; // Already includes all chain gains const cnRatio = signalLevel - effectiveNoiseFloor; // Calculate ADC degradation based on AGC output level @@ -626,7 +687,7 @@ export class Receiver extends BaseEquipment { fecMismatch, adcDegradation, effectiveCnRatio_dB: effectiveCnRatio, - noiseFloor_dBm: effectiveNoiseFloor, // Now includes interference + noiseFloor_dBm: effectiveNoiseFloor, // Now includes interference signalLevel_dBm: signalLevel, expectedBandwidth_Hz, usableBandwidth_Hz, @@ -652,12 +713,7 @@ export class Receiver extends BaseEquipment { * @param modemCenterHz - Modem's center frequency in Hz * @returns Total interference power in dBm, or -Infinity if no interference */ - private calculateInterferencePower_( - targetSignal: IfSignal, - allSignals: IfSignal[], - modemBandwidthHz: number, - modemCenterHz: number - ): dBm { + private calculateInterferencePower_(targetSignal: IfSignal, allSignals: IfSignal[], modemBandwidthHz: number, modemCenterHz: number): dBm { const modemLow = modemCenterHz - modemBandwidthHz / 2; const modemHigh = modemCenterHz + modemBandwidthHz / 2; @@ -680,13 +736,11 @@ export class Receiver extends BaseEquipment { // Proportional power contribution based on overlap const overlapFraction = overlapWidth / signal.bandwidth; - const signalPowerMw = Math.pow(10, signal.power / 10); + const signalPowerMw = 10 ** (signal.power / 10); totalInterferenceMw += signalPowerMw * overlapFraction; } - return (totalInterferenceMw > 0 - ? 10 * Math.log10(totalInterferenceMw) - : -Infinity) as dBm; + return (totalInterferenceMw > 0 ? 10 * Math.log10(totalInterferenceMw) : -Infinity) as dBm; } private handleInputChange(e: Event): void { @@ -699,10 +753,10 @@ export class Receiver extends BaseEquipment { // Parse based on parameter type switch (param) { case 'frequency': - this.inputData.frequency = Number.parseFloat(inputValue) as MHz || 0 as MHz; + this.inputData.frequency = (Number.parseFloat(inputValue) as MHz) || (0 as MHz); break; case 'bandwidth': - this.inputData.bandwidth = (Number.parseFloat(inputValue) as MHz) || 0 as MHz; + this.inputData.bandwidth = (Number.parseFloat(inputValue) as MHz) || (0 as MHz); break; case 'antenna_id': this.inputData.antenna_id = Number.parseInt(inputValue); @@ -718,7 +772,7 @@ export class Receiver extends BaseEquipment { public applyChanges(): void { const activeModem = this.activeModem; - const modemIndex = this.state.modems.findIndex(m => m.modemNumber === this.state.activeModem); + const modemIndex = this.state.modems.findIndex((m) => m.modemNumber === this.state.activeModem); if (!activeModem || modemIndex === -1) return; @@ -733,7 +787,7 @@ export class Receiver extends BaseEquipment { this.emit(Events.RX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[modemIndex] + config: this.state.modems[modemIndex], }); this.syncDomWithState(); @@ -791,10 +845,10 @@ export class Receiver extends BaseEquipment { return false; } - if (s.frequency + (s.bandwidth * 1e6 as Hertz) / 2 < activeModemData.frequency - activeModemData.bandwidth / 2) { + if (s.frequency + ((s.bandwidth * 1e6) as Hertz) / 2 < activeModemData.frequency - activeModemData.bandwidth / 2) { return false; } - if (s.frequency - (s.bandwidth * 1e6 as Hertz) / 2 > activeModemData.frequency + activeModemData.bandwidth / 2) { + if (s.frequency - ((s.bandwidth * 1e6) as Hertz) / 2 > activeModemData.frequency + activeModemData.bandwidth / 2) { return false; } @@ -808,18 +862,17 @@ export class Receiver extends BaseEquipment { }); // Only include signals within 50% bandwidth of center frequency - const signalsInBand = visibleSignals - .filter((s) => { - const frequencyMhz = s.frequency / 1e6 as MHz; - const freqTolerance50 = activeModemData.bandwidth * 0.5; - const lowerBound50 = activeModemData.frequency - freqTolerance50; - const upperBound50 = activeModemData.frequency + freqTolerance50; - return frequencyMhz >= lowerBound50 && frequencyMhz <= upperBound50; - }); + const signalsInBand = visibleSignals.filter((s) => { + const frequencyMhz = (s.frequency / 1e6) as MHz; + const freqTolerance50 = activeModemData.bandwidth * 0.5; + const lowerBound50 = activeModemData.frequency - freqTolerance50; + const upperBound50 = activeModemData.frequency + freqTolerance50; + return frequencyMhz >= lowerBound50 && frequencyMhz <= upperBound50; + }); // Find the strongest signal - signals significantly weaker (>20dB) are considered // suppressed (e.g., by notch filter) and shouldn't count as interference - const maxPower = Math.max(...signalsInBand.map(s => s.power)); + const maxPower = Math.max(...signalsInBand.map((s) => s.power)); const suppressionThreshold = 20; // dB - notch filters typically provide 20-60dB attenuation const survivingSignals = signalsInBand.filter((s) => { @@ -838,7 +891,7 @@ export class Receiver extends BaseEquipment { const notchHigh = notch.centerFrequency + notch.bandwidth / 2; if (signalFreqMHz >= notchLow && signalFreqMHz <= notchHigh) { - return false; // Signal was intentionally notched, don't count as interference + return false; // Signal was intentionally notched, don't count as interference } } } @@ -852,17 +905,14 @@ export class Receiver extends BaseEquipment { return []; } - const strongestSignal = survivingSignals.reduce((best, s) => - s.power > best.power ? s : best, - survivingSignals[0] - ); + const strongestSignal = survivingSignals.reduce((best, s) => (s.power > best.power ? s : best), survivingSignals[0]); return [strongestSignal].map((s) => { // Reset isDegraded flag before checking conditions // (signal objects are shared, so we must reset each time) s.isDegraded = false; - const frequencyMhz = s.frequency / 1e6 as MHz; + const frequencyMhz = (s.frequency / 1e6) as MHz; const freqTolerance10 = activeModemData.bandwidth * 0.1; const lowerBound10 = activeModemData.frequency - freqTolerance10; const upperBound10 = activeModemData.frequency + freqTolerance10; @@ -875,8 +925,10 @@ export class Receiver extends BaseEquipment { // Noise floor based on modem bandwidth: narrower BW = lower noise floor // Noise floor needs totalGain added to match the reference point // Signal from AGC output already includes all chain gains - const noiseFloor = this.rfFrontEnd_.couplerModule.signalPathManager.getNoiseFloorAt(TapPoint.RX_IF, expectedBandwidth_Hz as Hertz).noiseFloorNoGain + this.rfFrontEnd_.couplerModule.signalPathManager.getTotalRxGain(); - const signalLevel = s.power; // Already includes all chain gains + const noiseFloor = + this.rfFrontEnd_.couplerModule.signalPathManager.getNoiseFloorAt(TapPoint.RX_IF, expectedBandwidth_Hz as Hertz).noiseFloorNoGain + + this.rfFrontEnd_.couplerModule.signalPathManager.getTotalRxGain(); + const signalLevel = s.power; // Already includes all chain gains const cn = signalLevel - noiseFloor; @@ -927,21 +979,27 @@ export class Receiver extends BaseEquipment { */ private getMinBandwidthRatioForFec_(fec: FECType): number { switch (fec) { - case '1/2': return 0.40; - case '2/3': return 0.50; - case '3/4': return 0.60; - case '5/6': return 0.75; - case '7/8': return 0.85; - default: return 0.60; + case '1/2': + return 0.4; + case '2/3': + return 0.5; + case '3/4': + return 0.6; + case '5/6': + return 0.75; + case '7/8': + return 0.85; + default: + return 0.6; } } private getModemStatusClass(modem: ReceiverModemState): string { const signals = this.getVisibleSignals(modem); - const denied = signals.find(signal => signal.feed.includes('DENIED')); + const denied = signals.find((signal) => signal.feed.includes('DENIED')); if (denied) return 'modem-denied'; - const degraded = signals.find(signal => signal.feed.includes('DEGRADED')); + const degraded = signals.find((signal) => signal.feed.includes('DEGRADED')); if (degraded) return 'modem-degraded'; if (signals.length > 0) return 'modem-found'; @@ -950,18 +1008,18 @@ export class Receiver extends BaseEquipment { } syncDomWithState(): void { - const visibleSignals = this.getVisibleSignals().map(s => { + const visibleSignals = this.getVisibleSignals().map((s) => { // Return signal with degraded feed if applicable if (s.isDegraded && !s.isImage) { return { ...s, - feed: `degraded-${s.feed.replace(/^degraded-/, '')}` + feed: `degraded-${s.feed.replace(/^degraded-/, '')}`, }; } return s; }); const feedUrl = visibleSignals[0]?.feed || ''; - this.state.availableSignals = visibleSignals.map(s => ({ id: s.signalId, feed: s.feed, isDegraded: s.isDegraded || false })); + this.state.availableSignals = visibleSignals.map((s) => ({ id: s.signalId, feed: s.feed, isDegraded: s.isDegraded || false })); // Avoid unnecessary DOM updates by shallow comparing serialized state if (JSON.stringify(this.state) === JSON.stringify(this.lastRenderState)) { @@ -974,13 +1032,13 @@ export class Receiver extends BaseEquipment { // Update status banner const ledColor = this.getLedColor(); - (this.domCache['led']).className = `led ${ledColor}`; + this.domCache['led'].className = `led ${ledColor}`; // Update modem buttons active & status classes const modemButtons = parentDom.querySelectorAll('.btn-modem'); modemButtons.forEach((btn) => { const modemNum = Number((btn as HTMLElement).dataset.modem); - const modem = this.state.modems.find(m => m.modemNumber === modemNum); + const modem = this.state.modems.find((m) => m.modemNumber === modemNum); const isActive = modemNum === this.state.activeModem; const statusClass = modem ? this.getModemStatusClass(modem) : ''; btn.className = `btn-modem ${isActive ? 'active' : ''} ${statusClass}`.trim(); @@ -1002,11 +1060,11 @@ export class Receiver extends BaseEquipment { (this.domCache['inputModulation'] as HTMLSelectElement).value = String(this.inputData.modulation ?? activeModem?.modulation ?? ''); (this.domCache['inputFec'] as HTMLSelectElement).value = String(this.inputData.fec ?? activeModem?.fec ?? ''); - (this.domCache['currentValueAntenna']).textContent = String(activeModem.antenna_id); - (this.domCache['currentValueFrequency']).textContent = `${activeModem.frequency} MHz`; - (this.domCache['currentValueBandwidth']).textContent = `${activeModem.bandwidth} MHz`; - (this.domCache['currentValueModulation']).textContent = String(activeModem.modulation); - (this.domCache['currentValueFec']).textContent = String(activeModem.fec); + this.domCache['currentValueAntenna'].textContent = String(activeModem.antenna_id); + this.domCache['currentValueFrequency'].textContent = `${activeModem.frequency} MHz`; + this.domCache['currentValueBandwidth'].textContent = `${activeModem.bandwidth} MHz`; + this.domCache['currentValueModulation'].textContent = String(activeModem.modulation); + this.domCache['currentValueFec'].textContent = String(activeModem.fec); // Update power indicator light this.domCache['rxActivePowerLight'].className = `indicator-light ${activeModem.isPowered ? 'on' : 'off'}`; @@ -1047,7 +1105,8 @@ export class Receiver extends BaseEquipment { } else { // If not in cache, create new media element const signal = visibleSignals[0]; - if (signal.isImage && !signal.isExternal) { // internal image + if (signal.isImage && !signal.isExternal) { + // internal image const img = document.createElement('img'); img.className = 'image-feed'; img.src = `/images/${feedUrl}`; @@ -1061,8 +1120,8 @@ export class Receiver extends BaseEquipment { monitor.classList.add('glitch'); monitor.innerHTML += `
`; } - - } else if (signal.isImage && signal.isExternal) { // external image + } else if (signal.isImage && signal.isExternal) { + // external image const img = document.createElement('img'); img.className = 'external-image-feed'; img.src = feedUrl; @@ -1076,8 +1135,8 @@ export class Receiver extends BaseEquipment { monitor.classList.add('glitch'); monitor.innerHTML += `
`; } - - } else if (signal.isExternal) { // external video + } else if (signal.isExternal) { + // external video const iframe = document.createElement('iframe'); iframe.className = 'external-feed'; iframe.src = feedUrl; @@ -1085,7 +1144,8 @@ export class Receiver extends BaseEquipment { monitor.innerHTML = `
`; monitor.querySelector('.signal-indicator')?.appendChild(iframe); this.mediaCache[feedUrl] = iframe; - } else { // internal video + } else { + // internal video const video = document.createElement('video'); video.className = 'video-feed'; video.src = `/videos/${feedUrl}`; @@ -1107,4 +1167,4 @@ export class Receiver extends BaseEquipment { } } } -} \ No newline at end of file +} diff --git a/src/equipment/rf-front-end/agc-module/agc-module-core.ts b/src/equipment/rf-front-end/agc-module/agc-module-core.ts index 5312d50d..1841f0f2 100644 --- a/src/equipment/rf-front-end/agc-module/agc-module-core.ts +++ b/src/equipment/rf-front-end/agc-module/agc-module-core.ts @@ -1,22 +1,22 @@ -import { SignalOrigin } from "@app/signal-origin"; -import { dB, dBm, IfSignal } from '@app/types'; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule, RFFrontEndModuleState } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { SignalOrigin } from '@app/signal-origin'; +import { dB, dBm, IfSignal } from '@app/types'; /** * AGC module state */ export interface AGCState extends RFFrontEndModuleState { - isPowered: boolean; // Always true (tied to LNB power) - isBypassed: boolean; // Bypass mode - signals pass through unchanged - targetLevel: dBm; // Target output power - currentGain: dB; // Current gain applied (can be negative = attenuation) - inputPower: dBm; // Measured total input power - outputPower: dBm; // Actual output power - attackTime: number; // Attack time constant (ms) - how fast gain reduces - releaseTime: number; // Release time constant (ms) - how fast gain increases - maxGain: dB; // Maximum gain limit - minGain: dB; // Minimum gain limit + isPowered: boolean; // Always true (tied to LNB power) + isBypassed: boolean; // Bypass mode - signals pass through unchanged + targetLevel: dBm; // Target output power + currentGain: dB; // Current gain applied (can be negative = attenuation) + inputPower: dBm; // Measured total input power + outputPower: dBm; // Actual output power + attackTime: number; // Attack time constant (ms) - how fast gain reduces + releaseTime: number; // Release time constant (ms) - how fast gain increases + maxGain: dB; // Maximum gain limit + minGain: dB; // Minimum gain limit } /** @@ -39,16 +39,16 @@ export abstract class AGCModuleCore extends RFFrontEndModule { */ static getDefaultState(): AGCState { return { - isPowered: true, // Always on (tied to LNB) - isBypassed: false, // AGC active by default - targetLevel: -30 as dBm, // Typical IF level for modems - currentGain: 0 as dB, // Start with unity gain - inputPower: -100 as dBm, // Will be calculated - outputPower: -100 as dBm, // Will be calculated - attackTime: 10, // 10ms attack (fast response to overload) - releaseTime: 100, // 100ms release (slower recovery) - maxGain: 30 as dB, // +30 dB max amplification - minGain: -30 as dB, // -30 dB max attenuation + isPowered: true, // Always on (tied to LNB) + isBypassed: false, // AGC active by default + targetLevel: -30 as dBm, // Typical IF level for modems + currentGain: 0 as dB, // Start with unity gain + inputPower: -100 as dBm, // Will be calculated + outputPower: -100 as dBm, // Will be calculated + attackTime: 10, // 10ms attack (fast response to overload) + releaseTime: 100, // 100ms release (slower recovery) + maxGain: 30 as dB, // +30 dB max amplification + minGain: -30 as dB, // -30 dB max attenuation }; } @@ -74,20 +74,16 @@ export abstract class AGCModuleCore extends RFFrontEndModule { const inputs = this.inputSignals; // Calculate total input power (sum of all signals in linear domain) - const totalPowerLinear = inputs.reduce((sum, sig) => { - return sum + Math.pow(10, sig.power / 10); - }, 0); - this.state.inputPower = (totalPowerLinear > 0 - ? 10 * Math.log10(totalPowerLinear) - : -120) as dBm; + const totalPowerLinear = inputs.reduce((sum, sig) => sum + 10 ** (sig.power / 10), 0); + this.state.inputPower = (totalPowerLinear > 0 ? 10 * Math.log10(totalPowerLinear) : -120) as dBm; // Handle bypass mode - pass signals through unchanged if (this.state.isBypassed) { this.state.currentGain = 0 as dB; this.state.outputPower = this.state.inputPower; - this.outputSignals = inputs.map(sig => ({ + this.outputSignals = inputs.map((sig) => ({ ...sig, - origin: SignalOrigin.AGC + origin: SignalOrigin.AGC, })); return; } @@ -104,27 +100,21 @@ export abstract class AGCModuleCore extends RFFrontEndModule { // Calculate alpha for exponential smoothing at ~60 FPS (16.67ms per frame) const alpha = 1 - Math.exp(-16.67 / timeConstant); - this.state.currentGain = (this.state.currentGain + - (targetGain - this.state.currentGain) * alpha) as dB; + this.state.currentGain = (this.state.currentGain + (targetGain - this.state.currentGain) * alpha) as dB; // Clamp to gain limits - this.state.currentGain = Math.max(this.state.minGain, - Math.min(this.state.maxGain, this.state.currentGain)) as dB; + this.state.currentGain = Math.max(this.state.minGain, Math.min(this.state.maxGain, this.state.currentGain)) as dB; // Apply gain uniformly to all signals - this.outputSignals = inputs.map(sig => ({ + this.outputSignals = inputs.map((sig) => ({ ...sig, power: (sig.power + this.state.currentGain) as dBm, - origin: SignalOrigin.AGC + origin: SignalOrigin.AGC, })); // Calculate actual output power - const outputPowerLinear = this.outputSignals.reduce((sum, sig) => { - return sum + Math.pow(10, sig.power / 10); - }, 0); - this.state.outputPower = (outputPowerLinear > 0 - ? 10 * Math.log10(outputPowerLinear) - : -120) as dBm; + const outputPowerLinear = this.outputSignals.reduce((sum, sig) => sum + 10 ** (sig.power / 10), 0); + this.state.outputPower = (outputPowerLinear > 0 ? 10 * Math.log10(outputPowerLinear) : -120) as dBm; } /** diff --git a/src/equipment/rf-front-end/agc-module/agc-module-factory.ts b/src/equipment/rf-front-end/agc-module/agc-module-factory.ts index d0d03cbf..03f6b7a1 100644 --- a/src/equipment/rf-front-end/agc-module/agc-module-factory.ts +++ b/src/equipment/rf-front-end/agc-module/agc-module-factory.ts @@ -16,12 +16,7 @@ export type AGCModuleUIType = 'headless'; * @param uiType - UI type (default 'headless') * @returns AGCModuleCore instance */ -export function createAGC( - state: AGCState, - rfFrontEnd: RFFrontEndCore, - unit: number = 1, - uiType: AGCModuleUIType = 'headless' -): AGCModuleCore { +export function createAGC(state: AGCState, rfFrontEnd: RFFrontEndCore, unit: number = 1, uiType: AGCModuleUIType = 'headless'): AGCModuleCore { switch (uiType) { case 'headless': default: diff --git a/src/equipment/rf-front-end/agc-module/agc-module-ui-headless.ts b/src/equipment/rf-front-end/agc-module/agc-module-ui-headless.ts index b4048992..6fbd89dd 100644 --- a/src/equipment/rf-front-end/agc-module/agc-module-ui-headless.ts +++ b/src/equipment/rf-front-end/agc-module/agc-module-ui-headless.ts @@ -10,11 +10,7 @@ import { AGCModuleCore, AGCState } from './agc-module-core'; */ export class AGCModuleUIHeadless extends AGCModuleCore { constructor(state: AGCState, rfFrontEnd: RFFrontEndCore, unit: number = 1) { - super( - { ...AGCModuleCore.getDefaultState(), ...state }, - rfFrontEnd, - unit - ); + super({ ...AGCModuleCore.getDefaultState(), ...state }, rfFrontEnd, unit); } /** diff --git a/src/equipment/rf-front-end/agc-module/index.ts b/src/equipment/rf-front-end/agc-module/index.ts index a5c694d8..27858453 100644 --- a/src/equipment/rf-front-end/agc-module/index.ts +++ b/src/equipment/rf-front-end/agc-module/index.ts @@ -1,6 +1,5 @@ -export { - AGCModuleCore, - AGCState -} from './agc-module-core'; +export type { AGCState } from './agc-module-core'; +export { AGCModuleCore } from './agc-module-core'; +export type { AGCModuleUIType } from './agc-module-factory'; +export { createAGC } from './agc-module-factory'; export { AGCModuleUIHeadless } from './agc-module-ui-headless'; -export { createAGC, AGCModuleUIType } from './agc-module-factory'; diff --git a/src/equipment/rf-front-end/buc-module/buc-module-core.ts b/src/equipment/rf-front-end/buc-module/buc-module-core.ts index 03013efa..008cd7cc 100644 --- a/src/equipment/rf-front-end/buc-module/buc-module-core.ts +++ b/src/equipment/rf-front-end/buc-module/buc-module-core.ts @@ -1,7 +1,11 @@ -import { SignalOrigin } from "@app/signal-origin"; -import { dB, dBm, Hertz, IfFrequency, IfSignal, MHz, RfFrequency, RfSignal } from '@app/types'; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule, RFFrontEndModuleState } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { SignalOrigin } from '@app/signal-origin'; +import { Rng } from '@app/simulation/rng'; +import { dB, dBm, Hertz, IfFrequency, IfSignal, MHz, RfFrequency, RfSignal } from '@app/types'; + +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('buc').next(); /** * Spurious output from mixer products @@ -76,6 +80,9 @@ export interface BUCState extends RFFrontEndModuleState { * Contains RF physics, state management, signal processing, module coupling */ export abstract class BUCModuleCore extends RFFrontEndModule { + /** Staged cooling fault, degC above the normal thermal target (0 = healthy) */ + private thermalOffsetC_ = 0; + // Signals outputSignals: RfSignal[] = []; @@ -98,9 +105,9 @@ export abstract class BUCModuleCore extends RFFrontEndModule { phaseLockRange: 10000, // ±10 kHz tracking range // Output Filter (C-band uplink) - filterLowHz: 5.925e9 as Hertz, // 5.925 GHz - filterHighHz: 6.425e9 as Hertz, // 6.425 GHz - filterRejectionDb: -60 as dB, // Out-of-band rejection + filterLowHz: 5.925e9 as Hertz, // 5.925 GHz + filterHighHz: 6.425e9 as Hertz, // 6.425 GHz + filterRejectionDb: -60 as dB, // Out-of-band rejection // Gain & Power gain: 0 as dB, @@ -153,14 +160,12 @@ export abstract class BUCModuleCore extends RFFrontEndModule { // Bandpass filter rejects out-of-band signals entirely const maxOutputPower = this.state.saturationPower + 2; // Hard saturation limit this.outputSignals = this.inputSignals - .map(sig => { + .map((sig) => { const rfFreq = this.calculateRfFrequency(sig.frequency); const inBand = this.isInPassband_(rfFreq); - if (!inBand) return null; // Reject out-of-band signals + if (!inBand) return null; // Reject out-of-band signals - const gain = !this.state.isMuted - ? this.state.gain - : -170; + const gain = !this.state.isMuted ? this.state.gain : -170; const linearPower = sig.power + gain; return { ...sig, @@ -205,6 +210,11 @@ export abstract class BUCModuleCore extends RFFrontEndModule { alarms.push(`BUC over-temperature (${this.state.temperature.toFixed(1)} °C)`); } + // Cooling fault staged by a scenario (HardwareFaultManager buc-overtemp) + if (this.thermalOffsetC_ > 0) { + alarms.push('BUC cooling fault - fan/heatsink degraded'); + } + // High current draw alarm if (this.state.currentDraw > 4.5) { alarms.push(`BUC high current draw (${this.state.currentDraw.toFixed(2)} A)`); @@ -227,13 +237,9 @@ export abstract class BUCModuleCore extends RFFrontEndModule { // ═══════════════════════════════════════════════════════════════ get inputSignals(): IfSignal[] { - return this.rfFrontEnd_.transmitters - .flatMap((tx) => tx.state.modems - .filter((modem) => modem.isTransmitting - && !modem.isFaulted - && !modem.isLoopback - && !tx.isModemInIntermittentDropout(modem)) - .map((modem) => modem.ifSignal)); + return this.rfFrontEnd_.transmitters.flatMap((tx) => + tx.state.modems.filter((modem) => modem.isTransmitting && !modem.isFaulted && !modem.isLoopback && !tx.isModemInIntermittentDropout(modem)).map((modem) => modem.ifSignal) + ); } /** @@ -248,13 +254,11 @@ export abstract class BUCModuleCore extends RFFrontEndModule { const loFrequencyHz = this.state.loFrequency * 1e6; // Apply frequency error when not locked to external reference - const effectiveLO = (this.state.isExtRefLocked && this.isExtRefWarmedUp()) - ? loFrequencyHz - : loFrequencyHz + this.state.frequencyError; + const effectiveLO = this.state.isExtRefLocked && this.isExtRefWarmedUp() ? loFrequencyHz : loFrequencyHz + this.state.frequencyError; // Mixer produces both sidebands - const upperSideband = effectiveLO + ifFrequency; // LO + IF - const lowerSideband = effectiveLO - ifFrequency; // LO - IF + const upperSideband = effectiveLO + ifFrequency; // LO + IF + const lowerSideband = effectiveLO - ifFrequency; // LO - IF // Bandpass filter selects the in-band signal const upperInBand = this.isInPassband_(upperSideband); @@ -274,8 +278,7 @@ export abstract class BUCModuleCore extends RFFrontEndModule { * Check if a frequency falls within the output bandpass filter */ private isInPassband_(frequencyHz: number): boolean { - return frequencyHz >= this.state.filterLowHz - && frequencyHz <= this.state.filterHighHz; + return frequencyHz >= this.state.filterLowHz && frequencyHz <= this.state.filterHighHz; } /** @@ -291,9 +294,9 @@ export abstract class BUCModuleCore extends RFFrontEndModule { const upperInBand = this.isInPassband_(loHz + ifFreq); const lowerInBand = this.isInPassband_(loHz - ifFreq); - if (upperInBand) return 'low'; // Low-side injection → USB - if (lowerInBand) return 'high'; // High-side injection → LSB - return 'none'; // Neither in band + if (upperInBand) return 'low'; // Low-side injection → USB + if (lowerInBand) return 'high'; // High-side injection → LSB + return 'none'; // Neither in band } // ═══════════════════════════════════════════════════════════════ @@ -388,9 +391,9 @@ export abstract class BUCModuleCore extends RFFrontEndModule { const loFrequencyHz = this.state.loFrequency * 1e6; // Simulate drift: ±1-100 ppm (parts per million) // Use random walk model for realistic drift behavior - const driftPpm = 10 + Math.random() * 90; // 10-100 ppm - const driftDirection = Math.random() > 0.5 ? 1 : -1; - this.state.frequencyError = driftDirection * (loFrequencyHz * driftPpm / 1e6); + const driftPpm = 10 + random() * 90; // 10-100 ppm + const driftDirection = random() > 0.5 ? 1 : -1; + this.state.frequencyError = driftDirection * ((loFrequencyHz * driftPpm) / 1e6); } /** @@ -408,14 +411,14 @@ export abstract class BUCModuleCore extends RFFrontEndModule { // Typical locked: -100 dBc/Hz @ 10kHz offset // Unlocked: -70 to -80 dBc/Hz (degraded) this.state.phaseNoise = this.state.isExtRefLocked - ? -100 - Math.random() * 5 // -100 to -105 dBc/Hz - : -70 - Math.random() * 10; // -70 to -80 dBc/Hz + ? -100 - random() * 5 // -100 to -105 dBc/Hz + : -70 - random() * 10; // -70 to -80 dBc/Hz // Group delay variation (phase distortion across bandwidth) // Typical: 2-10 ns, increases with temperature and at band edges const baseDelay = 3; // ns const tempVariation = (this.state.temperature - 25) * 0.1; // 0.1 ns/°C - this.state.groupDelay = baseDelay + tempVariation + Math.random() * 2; + this.state.groupDelay = baseDelay + tempVariation + random() * 2; // Calculate spurious mixer products (N×LO ± M×IF) this.state.spuriousOutputs = this.calculateSpuriousProducts_(); @@ -434,28 +437,28 @@ export abstract class BUCModuleCore extends RFFrontEndModule { const loFreqHz = this.state.loFrequency * 1e6; // For each input signal, calculate primary spurious products - this.inputSignals.forEach(signal => { + this.inputSignals.forEach((signal) => { const ifFreqHz = signal.frequency; spurious.push( // 2×LO - IF (2nd harmonic mixing) { frequency: (2 * loFreqHz - ifFreqHz) as Hertz, - level: -30 - Math.random() * 10, // -30 to -40 dBc + level: -30 - random() * 10, // -30 to -40 dBc loHarmonic: 2, ifHarmonic: -1, }, // 2×LO + IF (2nd harmonic mixing) { frequency: (2 * loFreqHz + ifFreqHz) as Hertz, - level: -35 - Math.random() * 10, // -35 to -45 dBc + level: -35 - random() * 10, // -35 to -45 dBc loHarmonic: 2, ifHarmonic: 1, }, // 3×LO - IF (3rd harmonic) { frequency: (3 * loFreqHz - ifFreqHz) as Hertz, - level: -40 - Math.random() * 15, // -40 to -55 dBc + level: -40 - random() * 15, // -40 to -55 dBc loHarmonic: 3, ifHarmonic: -1, } @@ -465,6 +468,21 @@ export abstract class BUCModuleCore extends RFFrontEndModule { return spurious; } + /** + * Extra degC the BUC settles above its normal thermal target - a degraded fan + * or heatsink staged by a scenario (HardwareFaultManager `buc-overtemp`). + * With the default 40 degC a driven BUC climbs past the 70 degC alarm and a + * muted one settles at 65 degC, so muting is the right first move and + * powering off (slower cooling, `buc-temperature-normal` needs power) is not. + */ + setThermalOffset(deltaC: number): void { + this.thermalOffsetC_ = Math.max(0, deltaC); + } + + get thermalOffsetC(): number { + return this.thermalOffsetC_; + } + /** * Update thermal and operational state */ @@ -473,22 +491,21 @@ export abstract class BUCModuleCore extends RFFrontEndModule { // Cooling down gradually toward ambient (25°C) const ambientTemp = 25; const coolRate = 0.00001; // Slow cooling per update - this.state.temperature = this.state.temperature + - (ambientTemp - this.state.temperature) * coolRate; + this.state.temperature += (ambientTemp - this.state.temperature) * coolRate; this.state.currentDraw = 0; return; } - // Calculate target temperature based on output power + // Calculate target temperature based on output power (plus any staged + // cooling fault, which lifts the whole curve so the fix is less drive) const ambientTemp = 25; // °C - const powerDissipation = Math.max(0, this.state.outputPower - (-10)); + const powerDissipation = Math.max(0, this.state.outputPower - -10); const thermalRise = powerDissipation * 0.8; // °C per dBm above reference - const targetTemp = ambientTemp + thermalRise; + const targetTemp = ambientTemp + thermalRise + this.thermalOffsetC_; // Simulate gradual heating (thermal inertia) const heatRate = 0.00005; // Slow heating per update - this.state.temperature = this.state.temperature + - (targetTemp - this.state.temperature) * heatRate; + this.state.temperature += (targetTemp - this.state.temperature) * heatRate; // Current draw trends gradually toward target value const idleCurrent = 0.5; @@ -496,8 +513,7 @@ export abstract class BUCModuleCore extends RFFrontEndModule { const outputCurrent = Math.max(0, (this.state.outputPower + 10) / 20) * 1.5; const targetCurrent = idleCurrent + powerCurrent + outputCurrent; const currentRate = 0.1; // Slow current change per update - this.state.currentDraw = this.state.currentDraw + - (targetCurrent - this.state.currentDraw) * currentRate; + this.state.currentDraw += (targetCurrent - this.state.currentDraw) * currentRate; } // ═══════════════════════════════════════════════════════════════ @@ -600,7 +616,7 @@ export abstract class BUCModuleCore extends RFFrontEndModule { currentDraw: number; powerDissipation: number; } { - const powerOut = Math.pow(10, this.state.outputPower / 10); + const powerOut = 10 ** (this.state.outputPower / 10); const powerDissipation = this.state.currentDraw * 28 - powerOut; // Assuming 28V supply return { diff --git a/src/equipment/rf-front-end/buc-module/buc-module-factory.ts b/src/equipment/rf-front-end/buc-module/buc-module-factory.ts index afe05137..834bd6ce 100644 --- a/src/equipment/rf-front-end/buc-module/buc-module-factory.ts +++ b/src/equipment/rf-front-end/buc-module/buc-module-factory.ts @@ -8,13 +8,7 @@ export type BUCModuleUIType = 'standard' | 'basic' | 'headless'; * Factory function to create BUC module instances * Enables switching between UI implementations */ -export function createBUC( - state: BUCState, - rfFrontEnd: RFFrontEndCore, - unit: number = 1, - parentId: string = '', - uiType: BUCModuleUIType = 'standard' -): BUCModuleCore { +export function createBUC(state: BUCState, rfFrontEnd: RFFrontEndCore, unit: number = 1, parentId: string = '', uiType: BUCModuleUIType = 'standard'): BUCModuleCore { switch (uiType) { case 'standard': return new BUCModuleUIStandard(state, rfFrontEnd, unit, parentId); diff --git a/src/equipment/rf-front-end/buc-module/buc-module-ui-standard.ts b/src/equipment/rf-front-end/buc-module/buc-module-ui-standard.ts index c5252d23..ff94520e 100644 --- a/src/equipment/rf-front-end/buc-module/buc-module-ui-standard.ts +++ b/src/equipment/rf-front-end/buc-module/buc-module-ui-standard.ts @@ -1,8 +1,8 @@ import { HelpButton } from '@app/components/help-btn/help-btn'; import { RotaryKnob } from '@app/components/rotary-knob/rotary-knob'; import { ToggleSwitch } from '@app/components/toggle-switch/toggle-switch'; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { BUCModuleCore, BUCState } from './buc-module-core'; import './buc-module.css'; @@ -22,30 +22,15 @@ export class BUCModuleUIStandard extends BUCModuleCore { const tempId = `rf-fe-buc-temp-${unit}`; // Initialize components with temp IDs - const muteSwitch = ToggleSwitch.create( - `${tempId}-mute`, - state.isMuted, - false - ); + const muteSwitch = ToggleSwitch.create(`${tempId}-mute`, state.isMuted, false); // Initialize LO knob (callback will be wired through base class event system) - const loKnob = RotaryKnob.create( - `${tempId}-lo-knob`, - state.loFrequency, - 5850, - 6425, - 10, - (value: number) => { - // Direct state update - will be synced through event callback - state.loFrequency = value as any; - } - ); + const loKnob = RotaryKnob.create(`${tempId}-lo-knob`, state.loFrequency, 5850, 6425, 10, (value: number) => { + // Direct state update - will be synced through event callback + state.loFrequency = value as any; + }); - const loopbackSwitch = ToggleSwitch.create( - `${tempId}-loopback`, - state.isLoopback, - false - ); + const loopbackSwitch = ToggleSwitch.create(`${tempId}-loopback`, state.isLoopback, false); // Call parent constructor super(state, rfFrontEnd, unit); @@ -61,7 +46,7 @@ export class BUCModuleUIStandard extends BUCModuleCore { this.helpBtn_ = HelpButton.create( `buc-help-${rfFrontEnd.state.uuid}`, - "Block Upconverter", + 'Block Upconverter', null, 'https://docs.signalrange.space/equipment/block-upconverter?content-only=true&dark=true' ); @@ -222,7 +207,7 @@ export class BUCModuleUIStandard extends BUCModuleCore { muteSwitch: this.muteSwitch_, loKnob: this.loKnob_, loopbackSwitch: this.loopbackSwitch_, - helpBtn: this.helpBtn_ + helpBtn: this.helpBtn_, }; } @@ -236,7 +221,7 @@ export class BUCModuleUIStandard extends BUCModuleCore { temperature: () => this.state.temperature.toFixed(1), currentDraw: () => this.state.currentDraw.toFixed(2), frequencyError: () => (this.state.frequencyError / 1000).toFixed(1), - outputPower: () => this.state.outputPower.toFixed(1) + outputPower: () => this.state.outputPower.toFixed(1), }; } @@ -247,7 +232,7 @@ export class BUCModuleUIStandard extends BUCModuleCore { getLEDs() { return { lock: () => this.getLockLedStatus(), - loopback: () => this.getLoopbackLedStatus() + loopback: () => this.getLoopbackLedStatus(), }; } diff --git a/src/equipment/rf-front-end/buc-module/index.ts b/src/equipment/rf-front-end/buc-module/index.ts index 3f8abbb7..8eba2d5d 100644 --- a/src/equipment/rf-front-end/buc-module/index.ts +++ b/src/equipment/rf-front-end/buc-module/index.ts @@ -1,10 +1,10 @@ // Core business logic -export { BUCModuleCore } from './buc-module-core'; -export type { BUCState, SpuriousOutput } from './buc-module-core'; -// UI implementations -export { BUCModuleUIStandard } from './buc-module-ui-standard'; +export type { BUCState, SpuriousOutput } from './buc-module-core'; +export { BUCModuleCore } from './buc-module-core'; +export type { BUCModuleUIType } from './buc-module-factory'; // Factory export { createBUC } from './buc-module-factory'; -export type { BUCModuleUIType } from './buc-module-factory'; +// UI implementations +export { BUCModuleUIStandard } from './buc-module-ui-standard'; diff --git a/src/equipment/rf-front-end/coupler-module/coupler-module.ts b/src/equipment/rf-front-end/coupler-module/coupler-module.ts index 3c38d084..4f0b05d7 100644 --- a/src/equipment/rf-front-end/coupler-module/coupler-module.ts +++ b/src/equipment/rf-front-end/coupler-module/coupler-module.ts @@ -1,17 +1,21 @@ -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { SignalPathManager } from "@app/simulation/signal-path-manager"; -import { IfFrequency, RfFrequency } from "@app/types"; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { Rng } from '@app/simulation/rng'; +import { SignalPathManager } from '@app/simulation/signal-path-manager'; +import { IfFrequency, RfFrequency } from '@app/types'; import './coupler-module.css'; -import { TapPoint } from "./tap-points"; +import { TapPoint } from './tap-points'; + +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('coupler').next(); /** * Spectrum Analyzer coupler module state */ export interface CouplerState { - isPowered: boolean; // Required by base interface, always true for passive coupler + isPowered: boolean; // Required by base interface, always true for passive coupler isEngineeringMode: boolean; tapPointA: TapPoint; tapPointB: TapPoint; @@ -33,7 +37,7 @@ export class CouplerModule extends RFFrontEndModule { */ static getDefaultState(): CouplerState { return { - isPowered: true, // Always true for passive coupler + isPowered: true, // Always true for passive coupler isEngineeringMode: false, tapPointA: TapPoint.TX_IF, tapPointB: TapPoint.RX_IF, @@ -42,7 +46,7 @@ export class CouplerModule extends RFFrontEndModule { couplingFactorA: -30, // dB couplingFactorB: -20, // dB isEnabledA: false, - isEnabledB: true, // RX enabled by default + isEnabledB: true, // RX enabled by default isActiveA: false, isActiveB: true, }; @@ -61,7 +65,7 @@ export class CouplerModule extends RFFrontEndModule { 'POST OMT/PRE ANT TX RF', 'PRE OMT/POST ANT RX RF', 'POST OMT/PRE LNA RX RF', - 'POST LNA RX RF' + 'POST LNA RX RF', ]; this.html_ = html` @@ -83,13 +87,13 @@ export class CouplerModule extends RFFrontEndModule {
@@ -128,7 +132,7 @@ export class CouplerModule extends RFFrontEndModule { tapPointA: () => this.state.tapPointA, tapPointB: () => this.state.tapPointB, couplingFactorA: () => this.state.couplingFactorA.toFixed(1), - couplingFactorB: () => this.state.couplingFactorB.toFixed(1) + couplingFactorB: () => this.state.couplingFactorB.toFixed(1), }; } @@ -138,8 +142,8 @@ export class CouplerModule extends RFFrontEndModule { */ getLEDs() { return { - activeA: () => this.state.isActiveA ? 'led-green' : 'led-off', - activeB: () => this.state.isActiveB ? 'led-green' : 'led-off' + activeA: () => (this.state.isActiveA ? 'led-green' : 'led-off'), + activeB: () => (this.state.isActiveB ? 'led-green' : 'led-off'), }; } @@ -235,9 +239,14 @@ export class CouplerModule extends RFFrontEndModule { if (this.state.isEngineeringMode) { // All 8 tap points available in both selectors const allTapPoints = [ - TapPoint.TX_IF, TapPoint.RX_IF, - TapPoint.TX_RF_POST_BUC, TapPoint.TX_RF_POST_HPA, TapPoint.TX_RF_POST_OMT, - TapPoint.RX_RF_PRE_OMT, TapPoint.RX_RF_POST_OMT, TapPoint.RX_RF_POST_LNA + TapPoint.TX_IF, + TapPoint.RX_IF, + TapPoint.TX_RF_POST_BUC, + TapPoint.TX_RF_POST_HPA, + TapPoint.TX_RF_POST_OMT, + TapPoint.RX_RF_PRE_OMT, + TapPoint.RX_RF_POST_OMT, + TapPoint.RX_RF_POST_LNA, ]; this.state.availableTapPointsA = allTapPoints; this.state.availableTapPointsB = allTapPoints; @@ -251,24 +260,11 @@ export class CouplerModule extends RFFrontEndModule { * Determine if a tap point is active based on signal flow direction */ private isTapPointActive_(tapPoint: TapPoint): boolean { - const txTapPoints: TapPoint[] = [ - TapPoint.TX_IF, - TapPoint.TX_RF_POST_BUC, - TapPoint.TX_RF_POST_HPA, - TapPoint.TX_RF_POST_OMT - ]; + const txTapPoints: TapPoint[] = [TapPoint.TX_IF, TapPoint.TX_RF_POST_BUC, TapPoint.TX_RF_POST_HPA, TapPoint.TX_RF_POST_OMT]; - const rxTapPoints: TapPoint[] = [ - TapPoint.RX_IF, - TapPoint.RX_RF_PRE_OMT, - TapPoint.RX_RF_POST_OMT, - TapPoint.RX_RF_POST_LNA - ]; + const rxTapPoints: TapPoint[] = [TapPoint.RX_IF, TapPoint.RX_RF_PRE_OMT, TapPoint.RX_RF_POST_OMT, TapPoint.RX_RF_POST_LNA]; - if ( - txTapPoints.includes(tapPoint) || - rxTapPoints.includes(tapPoint) - ) { + if (txTapPoints.includes(tapPoint) || rxTapPoints.includes(tapPoint)) { return true; } @@ -369,8 +365,8 @@ export class CouplerModule extends RFFrontEndModule { private getCouplerOutput_(_tapPoint: TapPoint, couplingFactor: number): { frequency: RfFrequency | IfFrequency; power: number } { // Return a random number for now return { - frequency: Math.random() * 1000 as RfFrequency | IfFrequency, - power: - Math.abs(couplingFactor) // Coupled power is negative of coupling factor + frequency: (random() * 1000) as RfFrequency | IfFrequency, + power: -Math.abs(couplingFactor), // Coupled power is negative of coupling factor }; } } diff --git a/src/equipment/rf-front-end/coupler-module/tap-points.ts b/src/equipment/rf-front-end/coupler-module/tap-points.ts index a81870cf..98b6e8ee 100644 --- a/src/equipment/rf-front-end/coupler-module/tap-points.ts +++ b/src/equipment/rf-front-end/coupler-module/tap-points.ts @@ -10,5 +10,5 @@ export enum TapPoint { TX_RF_POST_OMT = 'TX RF POST OMT', RX_RF_PRE_OMT = 'RX RF PRE OMT', RX_RF_POST_OMT = 'RX RF POST OMT', - RX_RF_POST_LNA = 'RX RF POST LNA' + RX_RF_POST_LNA = 'RX RF POST LNA', } diff --git a/src/equipment/rf-front-end/filter-module/filter-module-core.ts b/src/equipment/rf-front-end/filter-module/filter-module-core.ts index 16193c1d..419e02bd 100644 --- a/src/equipment/rf-front-end/filter-module/filter-module-core.ts +++ b/src/equipment/rf-front-end/filter-module/filter-module-core.ts @@ -1,7 +1,7 @@ -import { SignalOrigin } from "@app/signal-origin"; -import { dBm, Hertz, IfSignal, MHz } from '@app/types'; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { SignalOrigin } from '@app/signal-origin'; +import { dBm, Hertz, IfSignal, MHz } from '@app/types'; /** * Filter bandwidth configuration @@ -107,13 +107,9 @@ export abstract class IfFilterBankModuleCore extends RFFrontEndModule tx.state.modems - .filter((modem) => modem.isTransmitting - && !modem.isFaulted - && modem.isLoopback - && !tx.isModemInIntermittentDropout(modem)) - .map((modem) => modem.ifSignal)); + const txLoopbackSignals = this.rfFrontEnd_.transmitters.flatMap((tx) => + tx.state.modems.filter((modem) => modem.isTransmitting && !modem.isFaulted && modem.isLoopback && !tx.isModemInIntermittentDropout(modem)).map((modem) => modem.ifSignal) + ); return [...lnbSignals, ...txLoopbackSignals]; } diff --git a/src/equipment/rf-front-end/filter-module/filter-module-ui-standard.ts b/src/equipment/rf-front-end/filter-module/filter-module-ui-standard.ts index 17de7291..3708df62 100644 --- a/src/equipment/rf-front-end/filter-module/filter-module-ui-standard.ts +++ b/src/equipment/rf-front-end/filter-module/filter-module-ui-standard.ts @@ -1,7 +1,7 @@ import { HelpButton } from '@app/components/help-btn/help-btn'; -import { RotaryKnob } from "@app/components/rotary-knob/rotary-knob"; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; +import { RotaryKnob } from '@app/components/rotary-knob/rotary-knob'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { FILTER_BANDWIDTH_CONFIGS, IfFilterBankModuleCore, IfFilterBankState } from './filter-module-core'; import './filter-module.css'; @@ -43,7 +43,7 @@ export class IfFilterBankModuleUIStandard extends IfFilterBankModuleCore { this.helpBtn_ = HelpButton.create( `filter-help-${rfFrontEnd.state.uuid}`, - "IF Filter Bank", + 'IF Filter Bank', null, 'https://docs.signalrange.space/equipment/if-filter-bank?content-only=true&dark=true' ); @@ -154,7 +154,7 @@ export class IfFilterBankModuleUIStandard extends IfFilterBankModuleCore { } return { bandwidthKnob: this.bandwidthKnob_, - helpBtn: this.helpBtn_ + helpBtn: this.helpBtn_, }; } @@ -165,7 +165,7 @@ export class IfFilterBankModuleUIStandard extends IfFilterBankModuleCore { getDisplays() { return { insertionLoss: () => this.state.insertionLoss.toFixed(1), - noiseFloor: () => this.state.noiseFloor.toFixed(0) + noiseFloor: () => this.state.noiseFloor.toFixed(0), }; } @@ -175,7 +175,7 @@ export class IfFilterBankModuleUIStandard extends IfFilterBankModuleCore { */ getLEDs() { return { - insertionLossOpacity: () => this.state.insertionLoss / 3 + insertionLossOpacity: () => this.state.insertionLoss / 3, }; } diff --git a/src/equipment/rf-front-end/filter-module/index.ts b/src/equipment/rf-front-end/filter-module/index.ts index bf90fafc..4f46a4aa 100644 --- a/src/equipment/rf-front-end/filter-module/index.ts +++ b/src/equipment/rf-front-end/filter-module/index.ts @@ -1,10 +1,10 @@ // Core business logic -export { FILTER_BANDWIDTH_CONFIGS, IfFilterBankModuleCore } from './filter-module-core'; -export type { FilterBandwidthConfig, IfFilterBankState } from './filter-module-core'; -// UI implementations -export { IfFilterBankModuleUIStandard } from './filter-module-ui-standard'; +export type { FilterBandwidthConfig, IfFilterBankState } from './filter-module-core'; +export { FILTER_BANDWIDTH_CONFIGS, IfFilterBankModuleCore } from './filter-module-core'; +export type { IfFilterBankModuleUIType } from './filter-module-factory'; // Factory export { createIfFilterBank } from './filter-module-factory'; -export type { IfFilterBankModuleUIType } from './filter-module-factory'; +// UI implementations +export { IfFilterBankModuleUIStandard } from './filter-module-ui-standard'; diff --git a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-core.ts b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-core.ts index db3369a0..ac244e6a 100644 --- a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-core.ts +++ b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-core.ts @@ -1,9 +1,14 @@ -import { SimulationManager } from '@app/simulation/simulation-manager'; -import { clamp } from 'ootk'; import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { Rng } from '@app/simulation/rng'; +import { SimClock } from '@app/simulation/sim-clock'; +import { SimulationManager } from '@app/simulation/simulation-manager'; +import { clamp } from 'ootk'; import { defaultGpsdoState, GPSDOState } from './gpsdo-state'; +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('gpsdo').next(); + /** * GPSDO Module Core - Business Logic Layer * Contains oscillator physics, timing algorithms, state management @@ -14,6 +19,8 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { protected warmupInterval_: number | null = null; protected stabilityInterval_: number | null = null; protected holdoverInterval_: number | null = null; + /** Scenario-staged GNSS outage (signal absent regardless of the switch) */ + private gnssOutage_ = false; constructor(state: GPSDOState, rfFrontEnd: RFFrontEndCore, unit: number) { super({ ...defaultGpsdoState, ...state }, rfFrontEnd, 'rf-fe-gpsdo', unit); @@ -45,9 +52,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { * Update lock status based on power, warmup, and GNSS availability */ private updateLockStatus_(): void { - const canLock = this.state.isPowered && - this.state.isGnssSwitchUp && - this.state.warmupTimeRemaining === 0; + const canLock = this.state.isPowered && this.state.isGnssSwitchUp && this.state.warmupTimeRemaining === 0; if (canLock) { if (!this.state.isLocked && this.state.gnssSignalPresent) { @@ -73,27 +78,28 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { if (this.state.isInHoldover) { // In holdover: maintain specs with OCXO, but degrade slowly - this.state.phaseNoise = -120 + Math.random() * 5; // -120 to -125 dBc/Hz - this.state.frequencyAccuracy = 0.5 + Math.random() * 4.5 + this.state.holdoverError * 0.05; // degrade with error - this.state.allanDeviation = 0.5 + Math.random() * 4.5 + this.state.holdoverError * 0.05; + this.state.phaseNoise = -120 + random() * 5; // -120 to -125 dBc/Hz + this.state.frequencyAccuracy = 0.5 + random() * 4.5 + this.state.holdoverError * 0.05; // degrade with error + this.state.allanDeviation = 0.5 + random() * 4.5 + this.state.holdoverError * 0.05; this.state.utcAccuracy = 0; // No GPS timing return; } // Phase noise: < -125 dBc/Hz at 10 Hz when locked - this.state.phaseNoise = this.state.gnssSignalPresent && !this.state.isInHoldover - ? -125 - Math.random() * 5 // -125 to -130 dBc/Hz when GPS locked - : -100 - Math.random() * 10; // -100 to -110 dBc/Hz in holdover + this.state.phaseNoise = + this.state.gnssSignalPresent && !this.state.isInHoldover + ? -125 - random() * 5 // -125 to -130 dBc/Hz when GPS locked + : -100 - random() * 10; // -100 to -110 dBc/Hz in holdover // Frequency accuracy: < 5×10⁻¹¹ at 1s when GPS locked if (this.state.gnssSignalPresent && !this.state.isInHoldover) { - this.state.frequencyAccuracy = 0.5 + Math.random() * 4.5; // 0.5-5 ×10⁻¹¹ - this.state.allanDeviation = 0.5 + Math.random() * 4.5; + this.state.frequencyAccuracy = 0.5 + random() * 4.5; // 0.5-5 ×10⁻¹¹ + this.state.allanDeviation = 0.5 + random() * 4.5; } // UTC accuracy: < 100 ns when GPS locked if (this.state.gnssSignalPresent) { - this.state.utcAccuracy = 20 + Math.random() * 80; // 20-100 ns + this.state.utcAccuracy = 20 + random() * 80; // 20-100 ns } else { this.state.utcAccuracy = 0; // No GPS timing } @@ -108,17 +114,15 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { const ambientTemp = 25; const coolRateInSeconds = 0.0001; // Per second // Convert to ms - const coolRate = 1 - Math.pow(1 - coolRateInSeconds, 1 / 60); - this.state.temperature = this.state.temperature + - (ambientTemp - this.state.temperature) * coolRate; + const coolRate = 1 - (1 - coolRateInSeconds) ** (1 / 60); + this.state.temperature += (ambientTemp - this.state.temperature) * coolRate; return; } // OCXO oven-controlled to ~70°C const targetTemp = 70; const heatRate = 0.00005; - this.state.temperature = this.state.temperature + - (targetTemp - this.state.temperature) * heatRate; + this.state.temperature += (targetTemp - this.state.temperature) * heatRate; } /** @@ -158,7 +162,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { protected startWarmupTimer_(): void { if (this.warmupInterval_) return; - this.warmupInterval_ = window.setInterval(() => { + this.warmupInterval_ = SimClock.setInterval(() => { if (!this.state.isPowered) { this.stopWarmupTimer_(); return; @@ -194,7 +198,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { */ protected stopWarmupTimer_(): void { if (this.warmupInterval_) { - clearInterval(this.warmupInterval_); + SimClock.clearTimer(this.warmupInterval_); this.warmupInterval_ = null; } } @@ -203,11 +207,11 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { * Improve specs gradually during warmup */ private improveSpecsDuringWarmup_(): void { - const warmupProgress = 1 - (this.state.warmupTimeRemaining / (SimulationManager.getInstance().isDeveloperMode ? 20 : 600)); + const warmupProgress = 1 - this.state.warmupTimeRemaining / (SimulationManager.getInstance().isDeveloperMode ? 20 : 600); // Accuracy improves exponentially - this.state.frequencyAccuracy = 1000 * Math.pow(2 / 1000, warmupProgress); - this.state.allanDeviation = 100 * Math.pow(2 / 100, warmupProgress); + this.state.frequencyAccuracy = 1000 * (2 / 1000) ** warmupProgress; + this.state.allanDeviation = 100 * (2 / 100) ** warmupProgress; this.state.phaseNoise = -80 + (-127 + 80) * warmupProgress; } @@ -217,7 +221,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { protected startStabilityMonitor_(): void { if (this.stabilityInterval_) return; - this.stabilityInterval_ = window.setInterval(() => { + this.stabilityInterval_ = SimClock.setInterval(() => { if (!this.state.isPowered || !this.state.isLocked) { return; } @@ -232,8 +236,8 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { this.addMetricVariations_(); // Satellite count should increase/decrease slightly, staying between 4-12 - if (this.state.gnssSignalPresent && Math.random() < 0.2) { - const satChange = Math.floor(Math.random() * 3) - 1; // -1, 0, or +1 + if (this.state.gnssSignalPresent && random() < 0.2) { + const satChange = Math.floor(random() * 3) - 1; // -1, 0, or +1 this.state.satelliteCount = clamp(this.state.satelliteCount + satChange, 4, 12); } @@ -253,7 +257,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { */ protected stopStabilityMonitor_(): void { if (this.stabilityInterval_) { - clearInterval(this.stabilityInterval_); + SimClock.clearTimer(this.stabilityInterval_); this.stabilityInterval_ = null; } } @@ -265,9 +269,9 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { if (!this.state.gnssSignalPresent || this.state.isInHoldover) return; // Very small variations around target values - this.state.frequencyAccuracy = 2 + (Math.random() - 0.5) * 0.5; - this.state.allanDeviation = 2 + (Math.random() - 0.5) * 0.5; - this.state.phaseNoise = -127 + (Math.random() - 0.5) * 2; + this.state.frequencyAccuracy = 2 + (random() - 0.5) * 0.5; + this.state.allanDeviation = 2 + (random() - 0.5) * 0.5; + this.state.phaseNoise = -127 + (random() - 0.5) * 2; } /** @@ -277,7 +281,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { // Only start if not already running if (this.holdoverInterval_) return; - this.holdoverInterval_ = window.setInterval(() => { + this.holdoverInterval_ = SimClock.setInterval(() => { if (!this.state.isInHoldover || !this.state.isPowered) { this.stopHoldoverMonitor_(); return; @@ -292,7 +296,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { this.state.holdoverError = hourlyDrift * elapsedHours; // Frequency accuracy degrades in holdover at aging rate - this.state.frequencyAccuracy += this.state.agingRate * 0.05 / (365 * 86400); // ppm/year → per second + this.state.frequencyAccuracy += (this.state.agingRate * 0.05) / (365 * 86400); // ppm/year → per second // If holdover error exceeds spec if (this.state.holdoverError > 40) { @@ -316,7 +320,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { */ protected stopHoldoverMonitor_(): void { if (this.holdoverInterval_) { - clearInterval(this.holdoverInterval_); + SimClock.clearTimer(this.holdoverInterval_); this.holdoverInterval_ = null; } } @@ -375,9 +379,7 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { * Check if reference is providing stable output */ isOutputStable(): boolean { - return this.state.isPowered && - this.state.isLocked && - this.state.warmupTimeRemaining === 0; + return this.state.isPowered && this.state.isLocked && this.state.warmupTimeRemaining === 0; } /** @@ -436,6 +438,42 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { } } + /** + * Drop or restore the GNSS signal itself, switch position unchanged + * (HardwareFaultManager `gpsdo-gnss-loss`). Losing the signal while locked + * puts the reference into holdover on its oscillator with the satellite count + * at zero - the tell that distinguishes an outage from a switch left down. + * When the signal returns with the switch up the receiver re-locks and leaves + * holdover, as the switch-up path does. + */ + setGnssSignalPresent(present: boolean): void { + this.gnssOutage_ = !present; + + if (!present) { + this.state.gnssSignalPresent = false; + this.state.satelliteCount = 0; + this.state.isGnssAcquiringLock = false; + if (this.state.isLocked && this.state.isPowered) { + this.state.isInHoldover = true; + this.startHoldoverMonitor_(); + } + return; + } + + if (this.state.isGnssSwitchUp && this.state.isPowered) { + this.state.gnssSignalPresent = true; + this.state.satelliteCount = 4 + Math.floor(random() * 8); // 4-12 sats + this.state.isInHoldover = false; + this.state.holdoverError = 0; + this.updateLockStatus_(); + } + } + + /** True while a scenario-staged GNSS outage is in effect. */ + get isGnssOutage(): boolean { + return this.gnssOutage_; + } + handleGnssToggle(isGnssSwitchUp: boolean, callback: (state: GPSDOState) => void): void { // Change the GNSS switch state this.state.isGnssSwitchUp = isGnssSwitchUp; @@ -443,11 +481,18 @@ export abstract class GPSDOModuleCore extends RFFrontEndModule { if (isGnssSwitchUp && this.state.isPowered) { this.state.isGnssAcquiringLock = true; - setTimeout(() => { + SimClock.setTimeout(() => { + this.state.isGnssAcquiringLock = false; + // During a staged outage there is no signal to acquire: the switch is + // up, the count stays at zero, holdover continues. + if (this.gnssOutage_) { + this.state.satelliteCount = 0; + callback(this.state); + return; + } // GNSS acquired - exit holdover this.state.gnssSignalPresent = true; - this.state.isGnssAcquiringLock = false; - this.state.satelliteCount = 4 + Math.floor(Math.random() * 8); // 4-12 sats + this.state.satelliteCount = 4 + Math.floor(random() * 8); // 4-12 sats this.state.isInHoldover = false; this.state.holdoverError = 0; this.updateLockStatus_(); diff --git a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-factory.ts b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-factory.ts index 938fa236..82f7ed59 100644 --- a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-factory.ts +++ b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-factory.ts @@ -9,13 +9,7 @@ export type GPSDOModuleUIType = 'standard' | 'basic' | 'headless'; * Factory function to create GPSDO module instances * Enables switching between UI implementations */ -export function createGPSDO( - state: GPSDOState, - rfFrontEnd: RFFrontEndCore, - unit: number = 1, - parentId: string = '', - uiType: GPSDOModuleUIType = 'standard' -): GPSDOModuleCore { +export function createGPSDO(state: GPSDOState, rfFrontEnd: RFFrontEndCore, unit: number = 1, parentId: string = '', uiType: GPSDOModuleUIType = 'standard'): GPSDOModuleCore { switch (uiType) { case 'standard': return new GPSDOModuleUIStandard(state, rfFrontEnd, unit, parentId); diff --git a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-ui-standard.ts b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-ui-standard.ts index f2bbfb57..13a7e622 100644 --- a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-ui-standard.ts +++ b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module-ui-standard.ts @@ -1,12 +1,12 @@ import { HelpButton } from '@app/components/help-btn/help-btn'; import { PowerSwitch } from '@app/components/power-switch/power-switch'; import { ToggleSwitch } from '@app/components/toggle-switch/toggle-switch'; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { EventBus } from '@app/events/event-bus'; import { Events } from '@app/events/events'; import { GPSDOState } from '.'; -import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { GPSDOModuleCore } from './gpsdo-module-core'; import './gpsdo-module.css'; @@ -25,22 +25,13 @@ export class GPSDOModuleUIStandard extends GPSDOModuleCore { // Call parent constructor super(state, rfFrontEnd, unit); - this.powerSwitch_ = PowerSwitch.create( - `${this.uniqueId}-power`, - this.state.isPowered, - false, - true, - ); + this.powerSwitch_ = PowerSwitch.create(`${this.uniqueId}-power`, this.state.isPowered, false, true); - this.gnssSwitch_ = ToggleSwitch.create( - `${this.uniqueId}-gnss`, - this.state.isGnssSwitchUp, - false - ); + this.gnssSwitch_ = ToggleSwitch.create(`${this.uniqueId}-gnss`, this.state.isGnssSwitchUp, false); this.helpBtn_ = HelpButton.create( `gpsdo-help-${rfFrontEnd.state.uuid}`, - "GPS Disciplined Oscillator", + 'GPS Disciplined Oscillator', null, 'https://docs.signalrange.space/equipment/gps-disciplined-oscillator?content-only=true&dark=true' ); @@ -208,10 +199,20 @@ export class GPSDOModuleUIStandard extends GPSDOModuleCore { const outputsElement = qs('.gpsdo-outputs', container); const holdoverElement = qs('.gpsdo-holdover', container); - if (!lockLedElement || !gnssLedElement || !warmLedElement || - !freqAccuracyElement || !stabilityElement || !phaseNoiseElement || - !satsElement || !utcElement || !tempElement || !warmupLedElement || - !outputsElement || !holdoverElement) { + if ( + !lockLedElement || + !gnssLedElement || + !warmLedElement || + !freqAccuracyElement || + !stabilityElement || + !phaseNoiseElement || + !satsElement || + !utcElement || + !tempElement || + !warmupLedElement || + !outputsElement || + !holdoverElement + ) { throw new Error('GPSDOModule: Cannot initialize DOM cache - one or more elements not found'); } @@ -243,7 +244,7 @@ export class GPSDOModuleUIStandard extends GPSDOModuleCore { return { powerSwitch: this.powerSwitch_, gnssSwitch: this.gnssSwitch_, - helpBtn: this.helpBtn_ + helpBtn: this.helpBtn_, }; } @@ -261,7 +262,7 @@ export class GPSDOModuleUIStandard extends GPSDOModuleCore { temperature: () => this.state.temperature.toFixed(1), warmupTime: () => this.formatWarmupTime_(), outputs: () => `${this.state.active10MHzOutputs}/${this.state.max10MHzOutputs}`, - holdoverError: () => this.state.holdoverError.toFixed(1) + holdoverError: () => this.state.holdoverError.toFixed(1), }; } @@ -273,7 +274,7 @@ export class GPSDOModuleUIStandard extends GPSDOModuleCore { return { lock: () => this.getLockLedStatus_(), gnss: () => this.getGnssLedStatus_(), - warm: () => this.getWarmupLedStatus_() + warm: () => this.getWarmupLedStatus_(), }; } @@ -340,7 +341,7 @@ export class GPSDOModuleUIStandard extends GPSDOModuleCore { this.domCache_.warmup.textContent = this.formatWarmupTime_(); this.domCache_.outputs.textContent = `${this.state.active10MHzOutputs}/${this.state.max10MHzOutputs}`; - this.domCache_.holdover.textContent = (this.state.holdoverError).toFixed(3); + this.domCache_.holdover.textContent = this.state.holdoverError.toFixed(3); } // Sync UI components diff --git a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module.css b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module.css index 77cc45cc..91f73b2d 100644 --- a/src/equipment/rf-front-end/gpsdo-module/gpsdo-module.css +++ b/src/equipment/rf-front-end/gpsdo-module/gpsdo-module.css @@ -47,9 +47,9 @@ /* Grey out all status displays when powered off */ .status-displays-off .digital-display { - background: linear-gradient(180deg, #222 0%, #333 100%) !important; - color: #444 !important; - border-color: #222 !important; + background: linear-gradient(180deg, var(--mc-equip-panel) 0%, var(--mc-equip-border) 100%) !important; + color: var(--mc-equip-border-light) !important; + border-color: var(--mc-equip-panel) !important; /* Keep box-shadow and text-shadow, but neutralize their colors to avoid layout shift */ box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.8), diff --git a/src/equipment/rf-front-end/gpsdo-module/index.ts b/src/equipment/rf-front-end/gpsdo-module/index.ts index a11f3ce5..715b124d 100644 --- a/src/equipment/rf-front-end/gpsdo-module/index.ts +++ b/src/equipment/rf-front-end/gpsdo-module/index.ts @@ -1,10 +1,8 @@ // Core business logic export { GPSDOModuleCore } from './gpsdo-module-core'; -export type { GPSDOState } from './gpsdo-state'; - -// UI implementations -export { GPSDOModuleUIStandard } from './gpsdo-module-ui-standard'; - +export type { GPSDOModuleUIType } from './gpsdo-module-factory'; // Factory export { createGPSDO } from './gpsdo-module-factory'; -export type { GPSDOModuleUIType } from './gpsdo-module-factory'; +// UI implementations +export { GPSDOModuleUIStandard } from './gpsdo-module-ui-standard'; +export type { GPSDOState } from './gpsdo-state'; diff --git a/src/equipment/rf-front-end/hpa-module/hpa-module-core.ts b/src/equipment/rf-front-end/hpa-module/hpa-module-core.ts index 84283179..de99f9d6 100644 --- a/src/equipment/rf-front-end/hpa-module/hpa-module-core.ts +++ b/src/equipment/rf-front-end/hpa-module/hpa-module-core.ts @@ -1,7 +1,7 @@ -import { SignalOrigin } from "@app/signal-origin"; -import type { dB, dBm, dBW, RfSignal } from '@app/types'; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { SignalOrigin } from '@app/signal-origin'; +import type { dB, dBm, dBW, RfSignal } from '@app/types'; /** * High Power Amplifier module state @@ -19,6 +19,13 @@ export interface HPAState { isHpaEnabled: boolean; /** is the HPA switch enabled */ isHpaSwitchEnabled: boolean; + /** + * Maximum output power (dBm). Optional config override for small amplifiers + * (Campaign 3 backyard ~5 W brick = 37 dBm); absent -> legacy 63 dBm (2 kW). + */ + maxOutputPower?: dBm; + /** Output power at 1 dB compression (dBm). Absent -> legacy 59 dBm. */ + p1db?: dBm; } /** @@ -27,9 +34,13 @@ export interface HPAState { * No UI dependencies */ export abstract class HPAModuleCore extends RFFrontEndModule { - // HPA characteristics - readonly p1db = 59 as dBm; // dBm (800W) output power at 1dB compression point - protected readonly maxOutputPower_ = 63 as dBm; // dBm (2000W) maximum output power + // HPA characteristics (config-overridable via HPAState for small amplifiers) + get p1db(): dBm { + return (this.state.p1db ?? 59) as dBm; // dBm (800W) output power at 1dB compression point + } + protected get maxOutputPower_(): dBm { + return (this.state.maxOutputPower ?? 63) as dBm; // dBm (2000W) maximum output power + } protected readonly minBackOffDb_ = 0; protected readonly maxBackOffDb_ = 30; private readonly thermalEfficiency_ = 0.85; @@ -95,7 +106,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { } // Calculate output power based on actual input signals (same as processSignals_) - const outputPowers = inputs.map(sig => { + const outputPowers = inputs.map((sig) => { const gain = this.calculateGain_(sig.power); return sig.power + gain - this.state.backOff; }); @@ -109,11 +120,11 @@ export abstract class HPAModuleCore extends RFFrontEndModule { private updateTemperature_(): void { if (this.state.isPowered) { // Calculate dissipated power based on efficiency, outputPower is in dBm - const powerWatts = Math.pow(10, (this.state.outputPower - 30) / 10); // Convert dBm to Watts + const powerWatts = 10 ** ((this.state.outputPower - 30) / 10); // Convert dBm to Watts const dissipatedPower = powerWatts * (1 - this.thermalEfficiency_); // Simple thermal model: ambient + thermal rise (0.5°C per Watt dissipated) - this.state.temperature = 25 + (dissipatedPower * 0.5); + this.state.temperature = 25 + dissipatedPower * 0.5; } else { this.state.temperature = 25; // Ambient temperature } @@ -126,7 +137,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { if (this.state.isPowered) { // IMD improves (becomes more negative) as back-off increases // Typical relationship: IMD degrades ~2 dB for every dB reduction in back-off - this.state.imdLevel = -30 - (this.state.backOff * 2); // dBc + this.state.imdLevel = -30 - this.state.backOff * 2; // dBc // Update overdrive status this.state.isOverdriven = this.state.backOff < 3; @@ -147,7 +158,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { } // Apply gain and compression to input signals - this.outputSignals = this.inputSignals.map(sig => { + this.outputSignals = this.inputSignals.map((sig) => { // Apply HPA gain const gain = this.calculateGain_(sig.power); return { @@ -158,7 +169,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { }); // Update state.gain to be the max gain applied to any input signal - const gains = this.inputSignals.map(sig => this.calculateGain_(sig.power)); + const gains = this.inputSignals.map((sig) => this.calculateGain_(sig.power)); this.state.gain = (gains.length > 0 ? Math.max(...gains) : 0) as dB; } @@ -185,7 +196,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { // Simple model: reduce gain by 1 dB for every dB input above (P1dB - backOff - 3) const compressionThreshold = targetOutputDbm - 3; if (inputPowerDbm > compressionThreshold) { - linearGain -= (inputPowerDbm - compressionThreshold); + linearGain -= inputPowerDbm - compressionThreshold; } // Ensure gain is not negative @@ -203,7 +214,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { // Power sequencing check const bucPowered = this.rfFrontEnd_.state.buc.isPowered; - if (this.state.isPowered && (!bucPowered)) { + if (this.state.isPowered && !bucPowered) { // Disable HPA if power conditions not met this.state.isPowered = false; } @@ -343,7 +354,7 @@ export abstract class HPAModuleCore extends RFFrontEndModule { renderPowerMeter_(powerDbW: dBW): string { // Convert dBW to percentage (1W = 0 dBW, 10W = 10 dBW for scale) - const percentage = Math.max(0, Math.min(100, (powerDbW / (this.maxOutputPower_ - 30) as dBW) * 100)); + const percentage = Math.max(0, Math.min(100, ((powerDbW / (this.maxOutputPower_ - 30)) as dBW) * 100)); const segments = []; for (let i = 0; i < 5; i++) { @@ -352,9 +363,11 @@ export abstract class HPAModuleCore extends RFFrontEndModule { let colorClass = 'led-off'; if (isLit) { - if (i < 3) colorClass = 'led-green'; // 0-60%: green - else if (i < 4) colorClass = 'led-yellow'; // 60-80%: yellow - else colorClass = 'led-red'; // 80-100%: red + if (i < 3) + colorClass = 'led-green'; // 0-60%: green + else if (i < 4) + colorClass = 'led-yellow'; // 60-80%: yellow + else colorClass = 'led-red'; // 80-100%: red } segments.push(`
`); diff --git a/src/equipment/rf-front-end/hpa-module/hpa-module-factory.ts b/src/equipment/rf-front-end/hpa-module/hpa-module-factory.ts index 3be47088..87257b32 100644 --- a/src/equipment/rf-front-end/hpa-module/hpa-module-factory.ts +++ b/src/equipment/rf-front-end/hpa-module/hpa-module-factory.ts @@ -8,13 +8,7 @@ export type HPAModuleUIType = 'standard' | 'basic' | 'headless'; * Factory function to create HPA module instances * Enables switching between UI implementations */ -export function createHPA( - state: HPAState, - rfFrontEnd: RFFrontEndCore, - unit: number = 1, - parentId: string = '', - uiType: HPAModuleUIType = 'standard' -): HPAModuleCore { +export function createHPA(state: HPAState, rfFrontEnd: RFFrontEndCore, unit: number = 1, parentId: string = '', uiType: HPAModuleUIType = 'standard'): HPAModuleCore { switch (uiType) { case 'standard': return new HPAModuleUIStandard(state, rfFrontEnd, unit, parentId); diff --git a/src/equipment/rf-front-end/hpa-module/hpa-module-ui-standard.ts b/src/equipment/rf-front-end/hpa-module/hpa-module-ui-standard.ts index bda3e1e0..f02412de 100644 --- a/src/equipment/rf-front-end/hpa-module/hpa-module-ui-standard.ts +++ b/src/equipment/rf-front-end/hpa-module/hpa-module-ui-standard.ts @@ -2,10 +2,10 @@ import { HelpButton } from '@app/components/help-btn/help-btn'; import { PowerSwitch } from '@app/components/power-switch/power-switch'; import { RotaryKnob } from '@app/components/rotary-knob/rotary-knob'; import { SecureToggleSwitch } from '@app/components/secure-toggle-switch/secure-toggle-switch'; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import type { dBW } from '@app/types'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; +import type { dBW } from '@app/types'; import { HPAModuleCore, HPAState } from './hpa-module-core'; import './hpa-module.css'; @@ -22,17 +22,10 @@ export class HPAModuleUIStandard extends HPAModuleCore { // Create UI components BEFORE calling super const tempId = `rf-fe-hpa-temp-${unit}`; - const backOffKnob = RotaryKnob.create( - `${tempId}-backoff-knob`, - state.backOff, - 0, - 30, - 0.5, - (value: number) => { - // Direct state update - will be synced through event callback - state.backOff = value; - } - ); + const backOffKnob = RotaryKnob.create(`${tempId}-backoff-knob`, state.backOff, 0, 30, 0.5, (value: number) => { + // Direct state update - will be synced through event callback + state.backOff = value; + }); // Call parent constructor super(state, rfFrontEnd, unit); @@ -41,24 +34,14 @@ export class HPAModuleUIStandard extends HPAModuleCore { this.backOffKnob = backOffKnob; // Create power switch with HPA-specific settings - this.powerSwitch_ = PowerSwitch.create( - `${this.uniqueId}-power`, - this.state.isPowered, - true, - false, - ); + this.powerSwitch_ = PowerSwitch.create(`${this.uniqueId}-power`, this.state.isPowered, true, false); // HPA switch needs special handling - create after super() call - this.hpaSwitch_ = SecureToggleSwitch.create( - `hpa-switch-${rfFrontEnd.state.uuid}`, - this.toggleHpa_.bind(this), - state.isHpaSwitchEnabled, - false - ); + this.hpaSwitch_ = SecureToggleSwitch.create(`hpa-switch-${rfFrontEnd.state.uuid}`, this.toggleHpa_.bind(this), state.isHpaSwitchEnabled, false); this.helpBtn_ = HelpButton.create( `hpa-help-${rfFrontEnd.state.uuid}`, - "High Power Amplifier", + 'High Power Amplifier', null, 'https://docs.signalrange.space/equipment/high-power-amplifier?content-only=true&dark=true' ); @@ -182,7 +165,7 @@ export class HPAModuleUIStandard extends HPAModuleCore { powerSwitch: this.powerSwitch_, backOffKnob: this.backOffKnob, hpaSwitch: this.hpaSwitch_, - helpBtn: this.helpBtn_ + helpBtn: this.helpBtn_, }; } @@ -193,7 +176,7 @@ export class HPAModuleUIStandard extends HPAModuleCore { getDisplays() { return { outputPower: () => this.state.outputPower.toFixed(1), - imdLevel: () => this.state.imdLevel.toFixed(3) + imdLevel: () => this.state.imdLevel.toFixed(3), }; } @@ -203,7 +186,7 @@ export class HPAModuleUIStandard extends HPAModuleCore { */ getLEDs() { return { - imd: () => this.state.isOverdriven ? 'led-orange' : 'led-off' + imd: () => (this.state.isOverdriven ? 'led-orange' : 'led-off'), }; } @@ -213,7 +196,7 @@ export class HPAModuleUIStandard extends HPAModuleCore { */ getPowerMeter() { return { - render: () => this.renderPowerMeter_((this.state.outputPower - 30) as dBW) + render: () => this.renderPowerMeter_((this.state.outputPower - 30) as dBW), }; } diff --git a/src/equipment/rf-front-end/hpa-module/hpa-module.css b/src/equipment/rf-front-end/hpa-module/hpa-module.css index 72a863f8..8aced97c 100644 --- a/src/equipment/rf-front-end/hpa-module/hpa-module.css +++ b/src/equipment/rf-front-end/hpa-module/hpa-module.css @@ -50,13 +50,13 @@ padding: 8px; background: rgba(0, 0, 0, 0.3); border-radius: 4px; - border: 1px solid #444; + border: 1px solid var(--mc-equip-border-light); width: 60px; } .meter-label { font-size: 10px; - color: #888; + color: var(--mc-equip-text-muted); text-transform: uppercase; letter-spacing: 0.5px; } @@ -73,8 +73,8 @@ width: 8px; height: 24px; border-radius: 2px; - background: #222; - border: 1px solid #333; + background: var(--mc-equip-panel); + border: 1px solid var(--mc-equip-border); transition: all 0.2s ease; } @@ -94,7 +94,7 @@ } .led-segment.led-off { - background: #222; + background: var(--mc-equip-panel); box-shadow: none; } } \ No newline at end of file diff --git a/src/equipment/rf-front-end/hpa-module/index.ts b/src/equipment/rf-front-end/hpa-module/index.ts index 3e90ffdc..b6a3f0e7 100644 --- a/src/equipment/rf-front-end/hpa-module/index.ts +++ b/src/equipment/rf-front-end/hpa-module/index.ts @@ -1,10 +1,10 @@ // Core business logic -export { HPAModuleCore } from './hpa-module-core'; -export type { HPAState } from './hpa-module-core'; -// UI implementations -export { HPAModuleUIStandard } from './hpa-module-ui-standard'; +export type { HPAState } from './hpa-module-core'; +export { HPAModuleCore } from './hpa-module-core'; +export type { HPAModuleUIType } from './hpa-module-factory'; // Factory export { createHPA } from './hpa-module-factory'; -export type { HPAModuleUIType } from './hpa-module-factory'; +// UI implementations +export { HPAModuleUIStandard } from './hpa-module-ui-standard'; diff --git a/src/equipment/rf-front-end/lnb-module/index.ts b/src/equipment/rf-front-end/lnb-module/index.ts index 07b5ecbb..30024caf 100644 --- a/src/equipment/rf-front-end/lnb-module/index.ts +++ b/src/equipment/rf-front-end/lnb-module/index.ts @@ -1,11 +1,10 @@ // Core business logic -export { LNBModuleCore } from './lnb-module-core'; -export type { LNBState } from './lnb-module-core'; -// UI implementations -export { LNBModuleUIStandard } from './lnb-module-ui-standard'; +export type { LNBState } from './lnb-module-core'; +export { LNBModuleCore } from './lnb-module-core'; +export type { LNBModuleUIType } from './lnb-module-factory'; // Factory export { createLNB } from './lnb-module-factory'; -export type { LNBModuleUIType } from './lnb-module-factory'; - +// UI implementations +export { LNBModuleUIStandard } from './lnb-module-ui-standard'; diff --git a/src/equipment/rf-front-end/lnb-module/lnb-module-core.ts b/src/equipment/rf-front-end/lnb-module/lnb-module-core.ts index 8a6b1da0..00ad2c45 100644 --- a/src/equipment/rf-front-end/lnb-module/lnb-module-core.ts +++ b/src/equipment/rf-front-end/lnb-module/lnb-module-core.ts @@ -1,7 +1,12 @@ -import { SignalOrigin } from "@app/signal-origin"; -import { dB, Hertz, IfFrequency, IfSignal, MHz, RfFrequency, RfSignal } from '@app/types'; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule, RFFrontEndModuleState } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { SignalOrigin } from '@app/signal-origin'; +import { Rng } from '@app/simulation/rng'; +import { SimClock } from '@app/simulation/sim-clock'; +import { dB, Hertz, IfFrequency, IfSignal, MHz, RfFrequency, RfSignal } from '@app/types'; + +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('lnb').next(); /** * Low Noise Block converter module state @@ -25,8 +30,19 @@ export interface LNBState extends RFFrontEndModuleState { * Clears automatically when LNB is power cycled (OFF then ON). */ hasRefLockFault?: boolean; + /** + * Direct-sampling mode (SDR front ends): bypasses the mixer so RF passes + * through at its original frequency, with a wide SDR passband instead of the + * 950-2150 MHz L-band IF filter. Opt-in: when omitted/false the legacy + * block-downconversion path is unchanged. + */ + isDirectSampling?: boolean; } +/** Direct-sampling (SDR) passband limits, modeled on common RTL-SDR tuners */ +export const DIRECT_SAMPLING_PASSBAND_LOW_HZ = 24e6; +export const DIRECT_SAMPLING_PASSBAND_HIGH_HZ = 1766e6; + /** * LNB Module Core - Business Logic Layer * Contains RF physics, signal processing, state management @@ -65,7 +81,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { // Initialize power-on timestamp if already powered if (this.state.isPowered) { - this.powerOnTimestamp_ = Date.now(); + this.powerOnTimestamp_ = SimClock.runMs(); } } @@ -89,7 +105,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { this.checkAlarms_(); // Calculate post-LNA signals (apply gain if powered) - this.postLNASignals = this.rxSignalsIn.map(sig => { + this.postLNASignals = this.rxSignalsIn.map((sig) => { const gain = this.state.isPowered ? this.state.gain : -300; return { ...sig, @@ -98,21 +114,26 @@ export abstract class LNBModuleCore extends RFFrontEndModule { } as RfSignal; }); - // Calculate IF signals after LNB based on LO frequency - this.ifSignals = this.postLNASignals.map(sig => { + // Calculate IF signals after LNB based on LO frequency. + // Direct sampling (SDR): RF passes through unmixed with a wide tuner + // passband; legacy path applies the 950-2150 MHz L-band IF filter. + const passbandLow = this.state.isDirectSampling ? DIRECT_SAMPLING_PASSBAND_LOW_HZ : 950e6; + const passbandHigh = this.state.isDirectSampling ? DIRECT_SAMPLING_PASSBAND_HIGH_HZ : 2150e6; + + this.ifSignals = this.postLNASignals.map((sig) => { const ifFreq = this.calculateIfFrequency(sig.frequency); - // If frequency is outside of 950e6 or 2150e6, drop signal 40 dB to simulate the bandpass filters - let filteredPower = (ifFreq < 950e6 || ifFreq > 2150e6) ? sig.power - 40 : sig.power; + // If frequency is outside the passband, drop signal 40 dB to simulate the bandpass filters + let filteredPower = ifFreq < passbandLow || ifFreq > passbandHigh ? sig.power - 40 : sig.power; // If it is on the edge and the bandwidth causes it to partially roll off, apply partial attenuation const halfBw = sig.bandwidth / 2; - if (ifFreq - halfBw < 950e6) { - const overlapHz = 950e6 - (ifFreq - halfBw); + if (ifFreq - halfBw < passbandLow) { + const overlapHz = passbandLow - (ifFreq - halfBw); const overlapFraction = overlapHz / sig.bandwidth; filteredPower -= 40 * overlapFraction; - } else if (ifFreq + halfBw > 2150e6) { - const overlapHz = (ifFreq + halfBw) - 2150e6; + } else if (ifFreq + halfBw > passbandHigh) { + const overlapHz = ifFreq + halfBw - passbandHigh; const overlapFraction = overlapHz / sig.bandwidth; filteredPower -= 40 * overlapFraction; } @@ -128,9 +149,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { get rxSignalsIn(): RfSignal[] { const omtSignals = this.rfFrontEnd_.omtModule.rxSignalsOut; - const bucLoopback = this.rfFrontEnd_.bucModule.state.isLoopback - ? this.rfFrontEnd_.bucModule.outputSignals - : []; + const bucLoopback = this.rfFrontEnd_.bucModule.state.isLoopback ? this.rfFrontEnd_.bucModule.outputSignals : []; return [...omtSignals, ...bucLoopback]; } @@ -152,9 +171,9 @@ export abstract class LNBModuleCore extends RFFrontEndModule { return; } - const nfLnaLinear = Math.pow(10, this.state.lnaNoiseFigure / 10); - const nfMixerLinear = Math.pow(10, this.state.mixerNoiseFigure / 10); - const gainLnaLinear = this.state.gain > 0 ? Math.pow(10, this.state.gain / 10) : 1; + const nfLnaLinear = 10 ** (this.state.lnaNoiseFigure / 10); + const nfMixerLinear = 10 ** (this.state.mixerNoiseFigure / 10); + const gainLnaLinear = this.state.gain > 0 ? 10 ** (this.state.gain / 10) : 1; // Friis formula for cascaded stages const nfTotal = nfLnaLinear + (nfMixerLinear - 1) / gainLnaLinear; @@ -166,7 +185,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { let targetNoiseTemp = nominalNoiseTemp; if (this.powerOnTimestamp_ !== null) { - const timeElapsedMs = Date.now() - this.powerOnTimestamp_; + const timeElapsedMs = SimClock.runMs() - this.powerOnTimestamp_; const timeElapsedSec = timeElapsedMs / 1000; const stabilizationTime = this.state.noiseTemperatureStabilizationTime; @@ -180,8 +199,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { const initialNoiseTemp = nominalNoiseTemp * 2; // Target is between initial and nominal based on warmup progress - targetNoiseTemp = nominalNoiseTemp + - (initialNoiseTemp - nominalNoiseTemp) * stabilizationFactor; + targetNoiseTemp = nominalNoiseTemp + (initialNoiseTemp - nominalNoiseTemp) * stabilizationFactor; } } @@ -191,8 +209,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { const tempDelta = Math.abs(targetNoiseTemp - this.state.noiseTemperature); // Fast response (0.1) for large changes, slow (0.005) for small changes const smoothingFactor = tempDelta > 100 ? 0.1 : 0.005; - this.state.noiseTemperature = this.state.noiseTemperature + - (targetNoiseTemp - this.state.noiseTemperature) * smoothingFactor; + this.state.noiseTemperature += (targetNoiseTemp - this.state.noiseTemperature) * smoothingFactor; } /** @@ -215,7 +232,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { return; } - const timeElapsedMs = Date.now() - this.powerOnTimestamp_; + const timeElapsedMs = SimClock.runMs() - this.powerOnTimestamp_; const timeElapsedSec = timeElapsedMs / 1000; const stabilizationTime = this.state.thermalStabilizationTime; @@ -257,7 +274,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { const tempDriftPpm = tempDeviation * tempCoefficientPpm; // Add aging drift component: 1-3 ppm - const agingDriftPpm = 1 + Math.random() * 2; + const agingDriftPpm = 1 + random() * 2; // Total drift in ppm const totalDriftPpm = tempDriftPpm + agingDriftPpm; @@ -268,7 +285,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { // Drift is negative when cold (frequency drops), positive when hot const driftDirection = this.state.temperature < nominalTemp ? -1 : 1; - this.state.frequencyError = driftDirection * (loFrequencyHz * totalDriftPpm / 1e6); + this.state.frequencyError = driftDirection * ((loFrequencyHz * totalDriftPpm) / 1e6); } /** @@ -348,11 +365,16 @@ export abstract class LNBModuleCore extends RFFrontEndModule { * @returns IF output frequency in Hz */ calculateIfFrequency(rfFrequency: RfFrequency): IfFrequency { + // Direct sampling (SDR): no mixer, RF frequency passes through unchanged + if (this.state.isDirectSampling) { + return rfFrequency as number as IfFrequency; + } + // Apply frequency error to LO (error is 0 when locked and warmed up) const effectiveLO = this.state.loFrequency * 1e6 + this.state.frequencyError; // LNB should be high side injection: IF = LO - RF, TODO: rare models use low side - return effectiveLO - rfFrequency as IfFrequency; + return (effectiveLO - rfFrequency) as IfFrequency; } /** @@ -390,7 +412,7 @@ export abstract class LNBModuleCore extends RFFrontEndModule { // Track power-on time for noise temperature stabilization if (this.state.isPowered) { - this.powerOnTimestamp_ = Date.now(); + this.powerOnTimestamp_ = SimClock.runMs(); // Clear sticky fault on power-on (simulates power cycle clearing transient faults) if (!wasPowered && this.state.hasRefLockFault) { diff --git a/src/equipment/rf-front-end/lnb-module/lnb-module-factory.ts b/src/equipment/rf-front-end/lnb-module/lnb-module-factory.ts index 8d05d385..dd541bd5 100644 --- a/src/equipment/rf-front-end/lnb-module/lnb-module-factory.ts +++ b/src/equipment/rf-front-end/lnb-module/lnb-module-factory.ts @@ -8,13 +8,7 @@ export type LNBModuleUIType = 'standard' | 'basic' | 'headless'; * Factory function to create LNB module instances * Enables switching between UI implementations */ -export function createLNB( - state: LNBState, - rfFrontEnd: RFFrontEndCore, - unit: number = 1, - parentId: string = '', - uiType: LNBModuleUIType = 'standard' -): LNBModuleCore { +export function createLNB(state: LNBState, rfFrontEnd: RFFrontEndCore, unit: number = 1, parentId: string = '', uiType: LNBModuleUIType = 'standard'): LNBModuleCore { switch (uiType) { case 'standard': return new LNBModuleUIStandard(state, rfFrontEnd, unit, parentId); diff --git a/src/equipment/rf-front-end/lnb-module/lnb-module-ui-standard.ts b/src/equipment/rf-front-end/lnb-module/lnb-module-ui-standard.ts index 0572ccf6..61c010c2 100644 --- a/src/equipment/rf-front-end/lnb-module/lnb-module-ui-standard.ts +++ b/src/equipment/rf-front-end/lnb-module/lnb-module-ui-standard.ts @@ -1,9 +1,9 @@ -import { HelpButton } from "@app/components/help-btn/help-btn"; -import { RotaryKnob } from "@app/components/rotary-knob/rotary-knob"; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { IfFrequency } from "@app/types"; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { HelpButton } from '@app/components/help-btn/help-btn'; +import { RotaryKnob } from '@app/components/rotary-knob/rotary-knob'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; +import { IfFrequency } from '@app/types'; import { LNBModuleCore, LNBState } from './lnb-module-core'; import './lnb-module.css'; @@ -23,17 +23,10 @@ export class LNBModuleUIStandard extends LNBModuleCore { super(state, rfFrontEnd, unit); // Store components - this.loKnob_ = RotaryKnob.create( - `${this.uniqueId}-lo-knob`, - this.state.loFrequency, - 5100, - 6075, - 10, - (value: number) => { - // Direct state update - will be synced through event callback - this.state.loFrequency = value as IfFrequency; - } - ); + this.loKnob_ = RotaryKnob.create(`${this.uniqueId}-lo-knob`, this.state.loFrequency, 5100, 6075, 10, (value: number) => { + // Direct state update - will be synced through event callback + this.state.loFrequency = value as IfFrequency; + }); // Create common UI components using base class methods this.createPowerSwitch(); @@ -41,7 +34,7 @@ export class LNBModuleUIStandard extends LNBModuleCore { this.helpBtn_ = HelpButton.create( `lnb-help-${rfFrontEnd.state.uuid}`, - "Low Noise Block", + 'Low Noise Block', null, 'https://docs.signalrange.space/equipment/low-noise-block-downconverter?content-only=true&dark=true' ); @@ -198,7 +191,7 @@ export class LNBModuleUIStandard extends LNBModuleCore { powerSwitch: this.powerSwitch_, gainKnob: this.gainKnob_, loKnob: this.loKnob_, - helpBtn: this.helpBtn_ + helpBtn: this.helpBtn_, }; } @@ -211,7 +204,7 @@ export class LNBModuleUIStandard extends LNBModuleCore { loFrequency: () => this.state.loFrequency.toString(), noiseTemperature: () => this.state.noiseTemperature.toFixed(1), temperature: () => this.state.temperature.toFixed(1), - frequencyError: () => (this.state.frequencyError / 1e6).toFixed(3) + frequencyError: () => (this.state.frequencyError / 1e6).toFixed(3), }; } @@ -222,7 +215,7 @@ export class LNBModuleUIStandard extends LNBModuleCore { getLEDs() { return { lock: () => this.getLockLedStatus(), - noiseTempBrightness: () => this.getNoiseTempBrightnessLevel__() + noiseTempBrightness: () => this.getNoiseTempBrightnessLevel__(), }; } diff --git a/src/equipment/rf-front-end/notch-filter-module/index.ts b/src/equipment/rf-front-end/notch-filter-module/index.ts index aa154729..2513de7d 100644 --- a/src/equipment/rf-front-end/notch-filter-module/index.ts +++ b/src/equipment/rf-front-end/notch-filter-module/index.ts @@ -1,8 +1,5 @@ -export { - NotchFilterModuleCore, - NotchFilterState, - NotchConfig, - DEFAULT_NOTCH -} from './notch-filter-module-core'; +export type { NotchConfig, NotchFilterState } from './notch-filter-module-core'; +export { DEFAULT_NOTCH, NotchFilterModuleCore } from './notch-filter-module-core'; +export type { NotchFilterModuleUIType } from './notch-filter-module-factory'; +export { createNotchFilter } from './notch-filter-module-factory'; export { NotchFilterModuleUIHeadless } from './notch-filter-module-ui-headless'; -export { createNotchFilter, NotchFilterModuleUIType } from './notch-filter-module-factory'; diff --git a/src/equipment/rf-front-end/notch-filter-module/notch-filter-module-core.ts b/src/equipment/rf-front-end/notch-filter-module/notch-filter-module-core.ts index a0629251..35bab6ed 100644 --- a/src/equipment/rf-front-end/notch-filter-module/notch-filter-module-core.ts +++ b/src/equipment/rf-front-end/notch-filter-module/notch-filter-module-core.ts @@ -1,7 +1,7 @@ -import { SignalOrigin } from "@app/signal-origin"; -import { dB, dBm, IfSignal, MHz } from '@app/types'; -import { RFFrontEndCore } from "@app/equipment/rf-front-end/rf-front-end-core"; +import { RFFrontEndCore } from '@app/equipment/rf-front-end/rf-front-end-core'; import { RFFrontEndModule, RFFrontEndModuleState } from '@app/equipment/rf-front-end/rf-front-end-module'; +import { SignalOrigin } from '@app/signal-origin'; +import { dB, dBm, IfSignal, MHz } from '@app/types'; /** * Single notch configuration @@ -33,7 +33,7 @@ export const DEFAULT_NOTCH: NotchConfig = { centerFrequency: 1500 as MHz, bandwidth: 1 as MHz, depth: 20 as dB, - enabled: false + enabled: false, }; /** @@ -54,8 +54,8 @@ export abstract class NotchFilterModuleCore extends RFFrontEndModule ({ + this.outputSignals = this.inputSignals.map((sig) => ({ ...sig, - origin: SignalOrigin.NOTCH_FILTER + origin: SignalOrigin.NOTCH_FILTER, })); return; } - this.outputSignals = this.inputSignals.map(sig => { + this.outputSignals = this.inputSignals.map((sig) => { let power = sig.power; // Check each enabled notch for (const notch of this.state.notches) { if (!notch.enabled) continue; - const attenuation = this.calculateNotchAttenuation_( - sig.frequency, - sig.bandwidth, - notch - ); + const attenuation = this.calculateNotchAttenuation_(sig.frequency, sig.bandwidth, notch); power = (power - attenuation) as dBm; } @@ -102,7 +98,7 @@ export abstract class NotchFilterModuleCore extends RFFrontEndModule Rng.stream('omt').next(); /** * Polarization types for OMT/Duplexer @@ -43,7 +47,7 @@ export class OMTModule extends RFFrontEndModule { effectiveRxPol: 'V', crossPolIsolation: 28.5 as dB, isFaulted: false, - insertionLoss: 0.5 as dB + insertionLoss: 0.5 as dB, }; } @@ -53,7 +57,7 @@ export class OMTModule extends RFFrontEndModule { // Create UI components this.helpBtn_ = HelpButton.create( `omt-help-${this.rfFrontEnd_.state.uuid}`, - "OMT / Duplexer", + 'OMT / Duplexer', null, 'https://docs.signalrange.space/equipment/orthomode-transducer?content-only=true&dark=true' ); @@ -83,7 +87,7 @@ export class OMTModule extends RFFrontEndModule { */ getComponents() { return { - helpBtn: this.helpBtn_ + helpBtn: this.helpBtn_, }; } @@ -95,7 +99,7 @@ export class OMTModule extends RFFrontEndModule { return { txPolarization: () => this.state.txPolarization || 'None', rxPolarization: () => this.state.rxPolarization || 'None', - crossPolIsolation: () => this.state.crossPolIsolation.toFixed(1) + crossPolIsolation: () => this.state.crossPolIsolation.toFixed(1), }; } @@ -105,7 +109,7 @@ export class OMTModule extends RFFrontEndModule { */ getLEDs() { return { - fault: () => this.state.isFaulted ? 'led-red' : 'led-off' + fault: () => (this.state.isFaulted ? 'led-red' : 'led-off'), }; } @@ -126,8 +130,13 @@ export class OMTModule extends RFFrontEndModule { this.updateCrossPolIsolation_(); - this.rxSignalsOut = this.rxSignalsIn.map(sig => { - if (sig.polarization !== this.state.effectiveRxPol) { + // Circular-feed mode (rxPolarization LHCP/RHCP, Campaign 3+): the OMT is a + // pass-through — handedness discrimination is modeled once, at the antenna + // feed (circularHandedness), so applying isolation here would double-count. + const isCircularMode = this.state.rxPolarization === 'LHCP' || this.state.rxPolarization === 'RHCP'; + + this.rxSignalsOut = this.rxSignalsIn.map((sig) => { + if (!isCircularMode && sig.polarization !== this.state.effectiveRxPol) { // Apply cross-pol isolation loss const isolatedPower = sig.power - this.state.crossPolIsolation; return { @@ -144,13 +153,11 @@ export class OMTModule extends RFFrontEndModule { // Set TX signal polarization based on OMT setting // Any changes to the TX signals' polarization by the antenna module // will happen inside the antenna module itself - this.txSignalsOut = this.txSignalsIn.map((sig: RfSignal) => { - return { - ...sig, - polarization: this.state.txPolarization, - origin: SignalOrigin.OMT_TX, - }; - }); + this.txSignalsOut = this.txSignalsIn.map((sig: RfSignal) => ({ + ...sig, + polarization: this.state.txPolarization, + origin: SignalOrigin.OMT_TX, + })); } get txSignalsIn(): RfSignal[] { @@ -160,13 +167,13 @@ export class OMTModule extends RFFrontEndModule { get rxSignalsIn(): RfSignal[] { if (this.rfFrontEnd_.antenna?.state.isLoopback) { // In loopback mode, RX signals come from the TX path - return this.txSignalsOut.map(sig => ({ + return this.txSignalsOut.map((sig) => ({ ...sig, polarization: sig.polarization === 'H' ? 'V' : 'H', // Reverse polarization for RX })); } - const rxSignals = this.rfFrontEnd_.antenna?.state.rxSignalsIn.map(sig => ({ + const rxSignals = this.rfFrontEnd_.antenna?.state.rxSignalsIn.map((sig) => ({ ...sig, // Add small loss through OMT to the gain calculation gainInPath: (sig.gainInPath - 0.5) as dBi, @@ -187,14 +194,14 @@ export class OMTModule extends RFFrontEndModule { if (signal) { if (signal.polarization === this.state.effectiveRxPol) { // Aligned polarization, normal isolation - this.state.crossPolIsolation = 30 + Math.random() * 5; // 30-35 dB + this.state.crossPolIsolation = 30 + random() * 5; // 30-35 dB } else { // Misaligned polarization, degraded isolation - this.state.crossPolIsolation = 15 + Math.random() * 10; // 15-25 dB + this.state.crossPolIsolation = 15 + random() * 10; // 15-25 dB } } else { // No signal, normal isolation - this.state.crossPolIsolation = 30 + Math.random() * 5; // 30-35 dB + this.state.crossPolIsolation = 30 + random() * 5; // 30-35 dB } // Update fault status @@ -208,6 +215,15 @@ export class OMTModule extends RFFrontEndModule { * @param skew Antenna skew in degrees */ private updateEffectivePolarization_(skew: number | null): void { + // Circular-feed mode: effective polarization is the configured handedness; + // the linear skew logic below does not apply. Opt-in — legacy configs are + // always H/V so this branch never runs for existing campaigns. + if (this.state.rxPolarization === 'LHCP' || this.state.rxPolarization === 'RHCP') { + this.state.effectiveTxPol = this.state.txPolarization; + this.state.effectiveRxPol = this.state.rxPolarization; + return; + } + if (skew === null) { this.state.effectiveTxPol = null; this.state.effectiveRxPol = null; diff --git a/src/equipment/rf-front-end/rf-front-end-core.ts b/src/equipment/rf-front-end/rf-front-end-core.ts index bfb50a7c..bcd0bd47 100644 --- a/src/equipment/rf-front-end/rf-front-end-core.ts +++ b/src/equipment/rf-front-end/rf-front-end-core.ts @@ -1,21 +1,22 @@ -import { EventBus } from "@app/events/event-bus"; -import { Events, HpaNoiseAmplificationData } from "@app/events/events"; -import { SignalPathManager } from '@app/simulation/signal-path-manager'; -import { dBm, Hertz, IfFrequency, RfFrequency } from "@app/types"; -import { AntennaCore } from "@app/equipment/antenna"; -import { AlarmStatus, BaseEquipment } from "@app/equipment/base-equipment"; -import { Transmitter } from '@app/equipment/transmitter/transmitter'; +import { AntennaCore } from '@app/equipment/antenna'; +import { AlarmStatus, BaseEquipment } from '@app/equipment/base-equipment'; import { AGCModuleCore, AGCState } from '@app/equipment/rf-front-end/agc-module/agc-module-core'; import { BUCModuleCore, BUCState } from '@app/equipment/rf-front-end/buc-module/buc-module-core'; import { CouplerModule, CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; -import { TapPoint } from "@app/equipment/rf-front-end/coupler-module/tap-points"; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; import { IfFilterBankModuleCore, IfFilterBankState } from '@app/equipment/rf-front-end/filter-module/filter-module-core'; import { GPSDOModuleCore } from '@app/equipment/rf-front-end/gpsdo-module/gpsdo-module-core'; -import { defaultGpsdoState, GPSDOState } from "@app/equipment/rf-front-end/gpsdo-module/gpsdo-state"; +import { defaultGpsdoState, GPSDOState } from '@app/equipment/rf-front-end/gpsdo-module/gpsdo-state'; import { HPAModuleCore, HPAState } from '@app/equipment/rf-front-end/hpa-module/hpa-module-core'; import { LNBModuleCore, LNBState } from '@app/equipment/rf-front-end/lnb-module/lnb-module-core'; import { NotchFilterModuleCore, NotchFilterState } from '@app/equipment/rf-front-end/notch-filter-module/notch-filter-module-core'; import { OMTModule, OMTState } from '@app/equipment/rf-front-end/omt-module/omt-module'; +import { Transmitter } from '@app/equipment/transmitter/transmitter'; +import { EventBus } from '@app/events/event-bus'; +import { Events, HpaNoiseAmplificationData } from '@app/events/events'; +import { SignalPathManager } from '@app/simulation/signal-path-manager'; +import { SimClock } from '@app/simulation/sim-clock'; +import { dBm, Hertz, IfFrequency, RfFrequency } from '@app/types'; /** * Complete RF Front-End state @@ -88,7 +89,7 @@ export abstract class RFFrontEndCore extends BaseEquipment { lnb: LNBModuleCore.getDefaultState(), coupler: CouplerModule.getDefaultState(), gpsdo: defaultGpsdoState, - ...state + ...state, }; // Subclass creates appropriate module types @@ -121,7 +122,7 @@ export abstract class RFFrontEndCore extends BaseEquipment { lnb: this.lnbModule?.state ?? this.state_.lnb, coupler: this.couplerModule?.state ?? this.state_.coupler, gpsdo: this.gpsdoModule?.state ?? this.state_.gpsdo, - } + }; } /** @@ -139,9 +140,9 @@ export abstract class RFFrontEndCore extends BaseEquipment { this.bucModule.update(); this.hpaModule.update(); this.lnbModule.update(); - this.filterModule.update(); // After LNB - this.notchFilterModule.update(); // After IF Filter - this.agcModule.update(); // After Notch Filter (last in RX chain) + this.filterModule.update(); // After LNB + this.notchFilterModule.update(); // After IF Filter + this.agcModule.update(); // After Notch Filter (last in RX chain) this.couplerModule.update(); this.gpsdoModule.update(); @@ -175,7 +176,7 @@ export abstract class RFFrontEndCore extends BaseEquipment { groundStationId: this.state_.uuid, bucMuted, bucOff, - detectedAt: Date.now(), + detectedAt: SimClock.nowMs(), }; EventBus.getInstance().emit(Events.HPA_NOISE_AMPLIFICATION, eventData); @@ -285,10 +286,10 @@ export abstract class RFFrontEndCore extends BaseEquipment { // HPA temperature calculation based on output power if (this.state.hpa.isPowered) { - const powerWatts = Math.pow(10, this.state.hpa.outputPower / 10); + const powerWatts = 10 ** (this.state.hpa.outputPower / 10); const efficiency = 0.5; // 50% typical for SSPA const dissipatedPower = powerWatts * (1 - efficiency); - this.state.hpa.temperature = 25 + (dissipatedPower * 10); // Rough thermal model + this.state.hpa.temperature = 25 + dissipatedPower * 10; // Rough thermal model } else { this.state.hpa.temperature = 25; // Ambient } @@ -299,7 +300,7 @@ export abstract class RFFrontEndCore extends BaseEquipment { // BUC output power calculation if (this.state.buc.isPowered && !this.state.buc.isMuted) { const inputPower = -10 as dBm; // dBm typical IF input - this.state.buc.outputPower = inputPower + this.state.buc.gain as dBm; + this.state.buc.outputPower = (inputPower + this.state.buc.gain) as dBm; } else { this.state.buc.outputPower = -120 as dBm; // Effectively off } @@ -307,10 +308,10 @@ export abstract class RFFrontEndCore extends BaseEquipment { // HPA output power and IMD calculation if (this.state.hpa.isPowered) { const p1db = 50 as dBm; // dBm (100W) typical P1dB - this.state.hpa.outputPower = (p1db - this.state.hpa.backOff) / 10 as dBm; + this.state.hpa.outputPower = ((p1db - this.state.hpa.backOff) / 10) as dBm; // IMD increases as back-off decreases - this.state.hpa.imdLevel = -30 - (this.state.hpa.backOff * 2); // dBc + this.state.hpa.imdLevel = -30 - this.state.hpa.backOff * 2; // dBc } else { this.state.hpa.outputPower = -90 as dBm; // dBm (effectively off) this.state.hpa.imdLevel = -60; // dBc (very clean when off) @@ -331,14 +332,10 @@ export abstract class RFFrontEndCore extends BaseEquipment { this.state.hpa.isOverdriven = this.state.hpa.backOff < 3; // Collect alarm messages from all modules - let moduleAlarms = [] + let moduleAlarms = []; if (rfcase === 1) { - moduleAlarms = [ - ...this.omtModule.getAlarms(), - ...this.bucModule.getAlarms(), - ...this.hpaModule.getAlarms(), - ]; + moduleAlarms = [...this.omtModule.getAlarms(), ...this.bucModule.getAlarms(), ...this.hpaModule.getAlarms()]; } else if (rfcase === 2) { moduleAlarms = [ ...this.agcModule.getAlarms(), @@ -355,9 +352,7 @@ export abstract class RFFrontEndCore extends BaseEquipment { let severity: AlarmStatus['severity'] = 'warning'; // Upgrade to error for critical conditions - if (alarm.toLowerCase().includes('over-temperature') || - alarm.toLowerCase().includes('high current') || - alarm.toLowerCase().includes('not operational')) { + if (alarm.toLowerCase().includes('over-temperature') || alarm.toLowerCase().includes('high current') || alarm.toLowerCase().includes('not operational')) { severity = 'error'; } @@ -382,8 +377,8 @@ export abstract class RFFrontEndCore extends BaseEquipment { private updateSystemNoiseFigure_(): number { // Friis formula for cascaded noise figure // F_total = F1 + (F2-1)/G1 + (F3-1)/(G1*G2) + ...\n // For RX: Filter → LNB - const filterNfLinear = Math.pow(10, (this.state.filter.insertionLoss / 10)); - const lnbNfLinear = Math.pow(10, (this.state.lnb.lnaNoiseFigure / 10)); + const filterNfLinear = 10 ** (this.state.filter.insertionLoss / 10); + const lnbNfLinear = 10 ** (this.state.lnb.lnaNoiseFigure / 10); // const lnbGainLinear = Math.pow(10, (this.state.lnb.gain / 10)); const totalNfLinear = filterNfLinear + (lnbNfLinear - 1) / filterNfLinear; @@ -426,8 +421,7 @@ export abstract class RFFrontEndCore extends BaseEquipment { // Helper functions const formatFreq = (f: number) => (f / 1e6).toFixed(2) + ' MHz'; - const formatPower = (p: number | undefined) => - p !== undefined ? p.toFixed(2) + ' dBm' : 'N/A'; + const formatPower = (p: number | undefined) => (p !== undefined ? p.toFixed(2) + ' dBm' : 'N/A'); // Process each signal antennaSignals.forEach((sig, i) => { @@ -438,43 +432,43 @@ export abstract class RFFrontEndCore extends BaseEquipment { Stage: 'Antenna In', Frequency: formatFreq(sig.frequency), Power: formatPower(sig.power), - NoiseFloor: 'N/A (external)' + NoiseFloor: 'N/A (external)', }, { Stage: 'OMT Out', Frequency: formatFreq(omtSignals[i]?.frequency), Power: formatPower(omtSignals[i]?.power), - NoiseFloor: 'N/A' + NoiseFloor: 'N/A', }, { Stage: 'Post-LNA (RF)', Frequency: formatFreq(postLnaSignals[i]?.frequency), Power: formatPower(postLnaSignals[i]?.power), - NoiseFloor: lnbNoiseFloor.toFixed(2) + ' dBm' + NoiseFloor: lnbNoiseFloor.toFixed(2) + ' dBm', }, { Stage: 'LNB Out (IF)', Frequency: formatFreq(ifSignals[i]?.frequency), Power: formatPower(ifSignals[i]?.power), - NoiseFloor: lnbNoiseFloor.toFixed(2) + ' dBm' + NoiseFloor: lnbNoiseFloor.toFixed(2) + ' dBm', }, { Stage: 'IF Filter Out', Frequency: formatFreq(postFilterSignals[i]?.frequency), Power: formatPower(postFilterSignals[i]?.power), - NoiseFloor: filterNoiseFloor.toFixed(2) + ' dBm' + NoiseFloor: filterNoiseFloor.toFixed(2) + ' dBm', }, { Stage: 'Notch Filter Out', Frequency: formatFreq(postNotchSignals[i]?.frequency), Power: formatPower(postNotchSignals[i]?.power), - NoiseFloor: filterNoiseFloor.toFixed(2) + ' dBm' + NoiseFloor: filterNoiseFloor.toFixed(2) + ' dBm', }, { Stage: 'AGC Out', Frequency: formatFreq(postAgcSignals[i]?.frequency), Power: formatPower(postAgcSignals[i]?.power), - NoiseFloor: (filterNoiseFloor + this.agcModule.state.currentGain).toFixed(2) + ' dBm' + NoiseFloor: (filterNoiseFloor + this.agcModule.state.currentGain).toFixed(2) + ' dBm', }, ]); diff --git a/src/equipment/rf-front-end/rf-front-end-factory.ts b/src/equipment/rf-front-end/rf-front-end-factory.ts index 309b6d5d..07d5e6be 100644 --- a/src/equipment/rf-front-end/rf-front-end-factory.ts +++ b/src/equipment/rf-front-end/rf-front-end-factory.ts @@ -19,7 +19,7 @@ export function createRFFrontEnd( state?: Partial, uiType: RFFrontEndUIType = 'standard', teamId: number = 1, - serverId: number = 1, + serverId: number = 1 ): RFFrontEndCore { switch (uiType) { case 'standard': diff --git a/src/equipment/rf-front-end/rf-front-end-module.ts b/src/equipment/rf-front-end/rf-front-end-module.ts index d68eb42a..7e99e438 100644 --- a/src/equipment/rf-front-end/rf-front-end-module.ts +++ b/src/equipment/rf-front-end/rf-front-end-module.ts @@ -1,11 +1,16 @@ import { HelpButton } from '@app/components/help-btn/help-btn'; import { PowerSwitch } from '@app/components/power-switch/power-switch'; import { RotaryKnob } from '@app/components/rotary-knob/rotary-knob'; -import { qs } from "@app/engine/utils/query-selector"; +import { qs } from '@app/engine/utils/query-selector'; import { EventBus } from '@app/events/event-bus'; import { Events } from '@app/events/events'; +import { Rng } from '@app/simulation/rng'; +import { SimClock } from '@app/simulation/sim-clock'; import { RFFrontEndCore } from './rf-front-end-core'; +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('rf-front-end').next(); + /** * Base state interface that all RF modules must implement */ @@ -154,12 +159,7 @@ export abstract class RFFrontEndModule { * Create power switch component */ protected createPowerSwitch(): void { - this.powerSwitch_ = PowerSwitch.create( - `power-${this.uniqueId}`, - this.state.isPowered, - true, - true, - ); + this.powerSwitch_ = PowerSwitch.create(`power-${this.uniqueId}`, this.state.isPowered, true, true); } /** @@ -174,18 +174,11 @@ export abstract class RFFrontEndModule { return; } - this.gainKnob_ = RotaryKnob.create( - `${this.uniqueId}-gain-knob`, - this.state.gain, - min, - max, - step, - (value: number) => { - if (this.state.gain !== undefined) { - this.state.gain = value; - } + this.gainKnob_ = RotaryKnob.create(`${this.uniqueId}-gain-knob`, this.state.gain, min, max, step, (value: number) => { + if (this.state.gain !== undefined) { + this.state.gain = value; } - ); + }); } /** @@ -193,10 +186,7 @@ export abstract class RFFrontEndModule { * @param cb Callback function when power state changes * @param onPowerOn Optional callback when powered on (for lock acquisition) */ - protected addPowerSwitchListener( - cb: (state: TState) => void, - onPowerOn?: () => void - ): void { + protected addPowerSwitchListener(cb: (state: TState) => void, onPowerOn?: () => void): void { if (!this.powerSwitch_) { console.warn(`${this.uniqueId}: Cannot add power switch listener - not initialized`); return; @@ -223,17 +213,13 @@ export abstract class RFFrontEndModule { * @param maxDelay Maximum delay in milliseconds * @param cb Optional callback after lock is acquired */ - protected simulateLockAcquisition( - minDelay: number = 2000, - maxDelay: number = 5000, - cb?: () => void - ): void { + protected simulateLockAcquisition(minDelay: number = 2000, maxDelay: number = 5000, cb?: () => void): void { if (this.state.isExtRefLocked === undefined) { return; } - const delay = minDelay + Math.random() * (maxDelay - minDelay); - setTimeout(() => { + const delay = minDelay + random() * (maxDelay - minDelay); + SimClock.setTimeout(() => { if (this.state.isExtRefLocked !== undefined) { this.state.isExtRefLocked = true; } diff --git a/src/equipment/rf-front-end/rf-front-end-ui-standard.ts b/src/equipment/rf-front-end/rf-front-end-ui-standard.ts index e9a0d0af..75e79905 100644 --- a/src/equipment/rf-front-end/rf-front-end-ui-standard.ts +++ b/src/equipment/rf-front-end/rf-front-end-ui-standard.ts @@ -1,17 +1,14 @@ -import { EventBus } from '@app/events/event-bus'; -import { Events } from '@app/events/events'; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; import { AGCModuleCore, AGCState } from '@app/equipment/rf-front-end/agc-module/agc-module-core'; import { createAGC } from '@app/equipment/rf-front-end/agc-module/agc-module-factory'; import { BUCModuleCore, BUCState } from '@app/equipment/rf-front-end/buc-module/buc-module-core'; import { createBUC } from '@app/equipment/rf-front-end/buc-module/buc-module-factory'; -import { CouplerModule, CouplerState } from "@app/equipment/rf-front-end/coupler-module/coupler-module"; +import { CouplerModule, CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; import { createCoupler } from '@app/equipment/rf-front-end/coupler-module/coupler-module-factory'; import { IfFilterBankModuleCore, IfFilterBankState } from '@app/equipment/rf-front-end/filter-module/filter-module-core'; import { createIfFilterBank } from '@app/equipment/rf-front-end/filter-module/filter-module-factory'; import { IfFilterBankModuleUIStandard } from '@app/equipment/rf-front-end/filter-module/filter-module-ui-standard'; -import { GPSDOModuleCore } from './gpsdo-module'; import { createGPSDO } from '@app/equipment/rf-front-end/gpsdo-module/gpsdo-module-factory'; import { GPSDOModuleUIStandard } from '@app/equipment/rf-front-end/gpsdo-module/gpsdo-module-ui-standard'; import { GPSDOState } from '@app/equipment/rf-front-end/gpsdo-module/gpsdo-state'; @@ -23,8 +20,11 @@ import { createLNB } from '@app/equipment/rf-front-end/lnb-module/lnb-module-fac import { LNBModuleUIStandard } from '@app/equipment/rf-front-end/lnb-module/lnb-module-ui-standard'; import { NotchFilterModuleCore, NotchFilterState } from '@app/equipment/rf-front-end/notch-filter-module/notch-filter-module-core'; import { createNotchFilter } from '@app/equipment/rf-front-end/notch-filter-module/notch-filter-module-factory'; -import { OMTModule, OMTState } from "@app/equipment/rf-front-end/omt-module/omt-module"; -import { createOMT } from "@app/equipment/rf-front-end/omt-module/omt-module-factory"; +import { OMTModule, OMTState } from '@app/equipment/rf-front-end/omt-module/omt-module'; +import { createOMT } from '@app/equipment/rf-front-end/omt-module/omt-module-factory'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { GPSDOModuleCore } from './gpsdo-module'; import { RFFrontEndCore, RFFrontEndState } from './rf-front-end-core'; import './rf-front-end.css'; @@ -70,8 +70,8 @@ export class RFFrontEndUIStandard extends RFFrontEndCore { this.omtModule = createOMT(this.state.omt, this); this.bucModule = createBUC(this.state.buc, this); this.hpaModule = createHPA(this.state.hpa, this) as HPAModuleUIStandard; - this.agcModule = createAGC(this.state.agc, this); // Headless - UI in adapter - this.notchFilterModule = createNotchFilter(this.state.notchFilter, this); // Headless - UI in adapter + this.agcModule = createAGC(this.state.agc, this); // Headless - UI in adapter + this.notchFilterModule = createNotchFilter(this.state.notchFilter, this); // Headless - UI in adapter this.filterModule = createIfFilterBank(this.state.filter, this) as IfFilterBankModuleUIStandard; this.lnbModule = createLNB(this.state.lnb, this) as LNBModuleUIStandard; this.couplerModule = createCoupler(this.state.coupler, this); @@ -259,7 +259,7 @@ export class RFFrontEndUIStandard extends RFFrontEndCore { if (!container) return; // Input change handlers - container.querySelectorAll('input, select').forEach(element => { + container.querySelectorAll('input, select').forEach((element) => { element.addEventListener('change', this.handleInputChange.bind(this)); element.addEventListener('input', this.handleInputChange.bind(this)); }); diff --git a/src/equipment/rf-front-end/rf-front-end.css b/src/equipment/rf-front-end/rf-front-end.css index 4eb29659..69ca0e67 100644 --- a/src/equipment/rf-front-end/rf-front-end.css +++ b/src/equipment/rf-front-end/rf-front-end.css @@ -23,8 +23,8 @@ .rf-fe-module { display: flex; flex-direction: column; - background: linear-gradient(180deg, #2a2a2a 0%, #1f1f1f 100%); - border: 1px solid #444; + background: linear-gradient(180deg, var(--mc-equip-panel-raised) 0%, var(--mc-equip-shell) 100%); + border: 1px solid var(--mc-equip-border-light); border-radius: 8px; padding: 12px; box-shadow: @@ -55,7 +55,7 @@ text-transform: uppercase; margin-bottom: 10px; padding-bottom: 6px; - border-bottom: 1px solid #444; + border-bottom: 1px solid var(--mc-equip-border-light); text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8); display: flex; flex-wrap: nowrap; @@ -87,7 +87,7 @@ } .control-group label { - color: #aaa; + color: var(--mc-equip-text-dim); font-size: 10px; font-weight: bold; text-transform: uppercase; @@ -132,9 +132,9 @@ .input-coupler-tap { width: 100%; padding: 6px 8px; - background-color: #1a1a1a; - color: #fff; - border: 1px solid #444; + background-color: var(--mc-equip-bg); + color: var(--mc-equip-text); + border: 1px solid var(--mc-equip-border-light); border-radius: 4px; font-size: 13px; font-family: 'Roboto', sans-serif; @@ -156,7 +156,7 @@ } .indicator-label { - color: #888; + color: var(--mc-equip-text-muted); font-size: 9px; font-weight: bold; text-transform: uppercase; @@ -168,7 +168,7 @@ width: 12px; height: 12px; border-radius: 50%; - border: 1px solid #222; + border: 1px solid var(--mc-equip-panel); box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.8), 0 1px 2px rgba(0, 0, 0, 0.3); @@ -177,7 +177,7 @@ /* LED Colors */ .led-off { - background: radial-gradient(circle at 40% 40%, #333 0%, #1a1a1a 60%); + background: radial-gradient(circle at 40% 40%, var(--mc-equip-border) 0%, var(--mc-equip-bg) 60%); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.9); } @@ -265,13 +265,13 @@ flex-shrink: 0; min-width: 52px; padding: 2px 6px; - background: linear-gradient(180deg, #3a3a3a 0%, #2a2a2a 100%); + background: linear-gradient(180deg, var(--mc-equip-border-alt) 0%, var(--mc-equip-panel-raised) 100%); color: #d0d0d0; font-size: 9px; font-weight: bold; text-transform: uppercase; letter-spacing: 0.5px; - border: 1px solid #555; + border: 1px solid var(--mc-equip-control); border-radius: 2px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), @@ -295,7 +295,7 @@ } .meter-label { - color: #aaa; + color: var(--mc-equip-text-dim); font-size: 10px; font-weight: bold; text-transform: uppercase; @@ -308,8 +308,8 @@ gap: 3px; align-items: center; padding: 6px; - background-color: #0a0a0a; - border: 1px solid #222; + background-color: var(--mc-equip-bg-deep); + border: 1px solid var(--mc-equip-panel); border-radius: 4px; height: 80px; } @@ -324,8 +324,8 @@ width: 26px; height: 12px; border-radius: 3px; - background: #1a1a1a; - border: 1px solid #333; + background: var(--mc-equip-bg); + border: 1px solid var(--mc-equip-border); transition: background 0.3s, box-shadow 0.3s; } @@ -345,16 +345,16 @@ } .led-segment.led-off { - background: #2a2a2a; + background: var(--mc-equip-panel-raised); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.5); } /* Mute Button */ .btn-mute { padding: 6px 12px; - background: linear-gradient(180deg, #444 0%, #333 100%); - color: #aaa; - border: 1px solid #555; + background: linear-gradient(180deg, var(--mc-equip-border-light) 0%, var(--mc-equip-border) 100%); + color: var(--mc-equip-text-dim); + border: 1px solid var(--mc-equip-control); border-radius: 4px; font-size: 11px; font-weight: bold; @@ -365,13 +365,13 @@ } .btn-mute:hover { - background: linear-gradient(180deg, #555 0%, #444 100%); - border-color: #666; + background: linear-gradient(180deg, var(--mc-equip-control) 0%, var(--mc-equip-border-light) 100%); + border-color: var(--mc-equip-control-light); } .btn-mute.active { background: linear-gradient(180deg, #f44336 0%, #d32f2f 100%); - color: #fff; + color: var(--mc-equip-text); border-color: #f44336; box-shadow: 0 0 8px rgba(244, 67, 54, 0.4); } @@ -385,7 +385,7 @@ } .display-label { - color: #888; + color: var(--mc-equip-text-muted); font-size: 9px; font-weight: bold; text-transform: uppercase; @@ -436,17 +436,17 @@ } .rf-fe-modules::-webkit-scrollbar-track { - background: #1a1a1a; + background: var(--mc-equip-bg); border-radius: 3px; } .rf-fe-modules::-webkit-scrollbar-thumb { - background: #444; + background: var(--mc-equip-border-light); border-radius: 3px; } .rf-fe-modules::-webkit-scrollbar-thumb:hover { - background: #555; + background: var(--mc-equip-control); } /* Responsive adjustments */ @@ -485,18 +485,18 @@ } .equipment-case[data-powered="false"] .led { - background: radial-gradient(circle at 40% 40%, #333 0%, #1a1a1a 60%); + background: radial-gradient(circle at 40% 40%, var(--mc-equip-border) 0%, var(--mc-equip-bg) 60%); box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.9); } .equipment-case[data-powered="false"] .digital-display { - background: #0a0a0a; + background: var(--mc-equip-bg-deep); color: #001a1a; box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.9); text-shadow: none; } .equipment-case[data-powered="false"] .bottom-status-bar { - color: #333; + color: var(--mc-equip-border); text-shadow: none; } \ No newline at end of file diff --git a/src/equipment/satellite/orbital-satellite.ts b/src/equipment/satellite/orbital-satellite.ts new file mode 100644 index 00000000..a204554f --- /dev/null +++ b/src/equipment/satellite/orbital-satellite.ts @@ -0,0 +1,401 @@ +/** + * @file OrbitalSatellite - SGP4-propagated satellite (Campaign 2+) + * @description Extends the legacy fixed-telemetry Satellite with real orbital + * mechanics from ootk. Position (ECI + ground-station-relative az/el/range) is + * propagated from a TLE against the simulated scenario clock, so LEO passes, + * slant-range path loss, and Doppler shift are physically realistic. + * + * Campaign 1 scenarios never instantiate this class, so all legacy behavior + * (fixed GEO az/el, figure-8 geosync, constant GEO slant range) is preserved. + * + * Observers. The satellite keeps one canonical observer (the station named in + * its config) behind `az`/`el`/`rangeKm`/`dopplerFactor`/`txSignal`, so every + * consumer written against those fields keeps working. A multi-station + * scenario additionally asks for the satellite as seen from ANY station through + * `geometryFor(observer)` / `txSignalsFor(observer)` (phase 16, E1): two sites + * see one LEO at different az/el/range/Doppler, and a Shetland antenna must + * track and hear the bird from Shetland, not from Galway. + * + * Prediction vs truth (phase 16, S7). The satellite carries two element sets: + * the orbit it is really in (`ootkSatellite`, everything the RF chain sees) + * and the set the ground station has on file (`predictionSatellite`, what + * program-track points at and the pass planner predicts from). They are the + * same object until a manoeuvre (`maneuverTo`) moves the spacecraft off the + * station's set; loading the updated ephemeris (`reloadTle`) puts the station + * back on the truth. Between the two the pedestal reads LOCKED on a prediction + * while the beacon is somewhere else, which is what a stale element set feels + * like at a 4 m Ku beamwidth. + */ + +import { getSimulatedNowMs } from '@app/simulation/sim-time'; +import { Hertz, RfFrequency, RfSignal } from '@app/types'; +import { Degrees, EciVec3, GroundObject, Kilometers, KilometersPerSecond, LlaVec3, Satellite as OotkSatellite, TleLine1, TleLine2, Vec3 } from 'ootk'; +import { Satellite, SatelliteState } from './satellite'; + +/** Geodetic location of the ground station observing this satellite. */ +export interface OrbitalObserver { + name?: string; + lat: Degrees; + lon: Degrees; + /** Altitude above the WGS-84 ellipsoid in km */ + alt: Kilometers; +} + +/** Configuration for an SGP4-propagated satellite. */ +export interface OrbitalSatelliteConfig { + /** TLE line 1 */ + tle1: TleLine1; + /** TLE line 2 */ + tle2: TleLine2; + /** Ground station the relative az/el/range telemetry is computed against */ + observer: OrbitalObserver; + /** + * Apply Doppler shift to downlink signal frequencies based on range rate. + * Default: true. Uplink Doppler is not modeled. + */ + isDopplerEnabled?: boolean; + /** Elevation below which the satellite's signals are not receivable. Default: 0 deg */ + minElevation?: Degrees; +} + +/** The satellite as seen from one observer at the last propagation time. */ +export interface ObserverGeometry { + az: Degrees; + el: Degrees; + rangeKm: Kilometers; + /** observed = transmitted * factor; 1 when Doppler is disabled */ + dopplerFactor: number; + /** Above the satellite's minimum receivable elevation for this observer */ + isAboveHorizon: boolean; +} + +/** + * Build an observer from a ground station's configured location. Station + * elevation is authored in metres; ootk wants kilometres. + */ +export function observerFromLocation(location: { latitude: number; longitude: number; elevation?: number }, name?: string): OrbitalObserver { + return { + name, + lat: location.latitude as Degrees, + lon: location.longitude as Degrees, + alt: ((location.elevation ?? 0) / 1000) as Kilometers, + }; +} + +function observerKey_(observer: OrbitalObserver): string { + return `${(observer.lat as number).toFixed(5)},${(observer.lon as number).toFixed(5)},${(observer.alt as number).toFixed(3)}`; +} + +const groundObjects_ = new Map(); + +/** ootk GroundObject for an observer, shared across satellites (pure geometry). */ +export function groundObjectFor(observer: OrbitalObserver): GroundObject { + const key = observerKey_(observer); + let ground = groundObjects_.get(key); + if (!ground) { + ground = new GroundObject({ + name: observer.name ?? 'Ground Station', + lat: observer.lat, + lon: observer.lon, + alt: observer.alt, + }); + groundObjects_.set(key, ground); + } + return ground; +} + +/** + * A satellite whose position comes from real SGP4 propagation of a TLE. + * + * On every throttled position update the satellite: + * - propagates to the current simulated time (scenario clock), + * - refreshes `az`/`el` (all existing consumers keep working unchanged), + * - refreshes `rangeKm` so the antenna computes true slant-range FSPL, + * - caches ECI/LLA state for dashboards and mission planning, + * - computes the Doppler factor applied to downlink signals. + * + * Below `minElevation` the satellite transmits nothing (LOS behavior). + */ +export class OrbitalSatellite extends Satellite { + private ootkSat_: OotkSatellite; + /** The station's element set: program-track and pass prediction read this. */ + private predictionSat_: OotkSatellite; + private readonly observer_: GroundObject; + private readonly observerConfig_: OrbitalObserver; + private readonly isDopplerEnabled_: boolean; + private readonly minElevation_: Degrees; + + /** Simulated time the canonical telemetry was last propagated to */ + private propagatedAtMs_ = 0; + /** + * Downlink signals before the canonical horizon gate and Doppler shift are + * applied, so per-observer views can apply their own. + */ + private rawTxSignal_: RfSignal[] = []; + /** Per-observer geometry, valid for one propagation time */ + private readonly observerCache_ = new Map(); + /** Per-observer predicted pointing from the station's element set, valid for one propagation time */ + private readonly predictionCache_ = new Map(); + + /** Current ECI position (km), null until first successful propagation */ + eciPosition: EciVec3 | null = null; + /** Current ECI velocity (km/s), null until first successful propagation */ + eciVelocity: Vec3 | null = null; + /** Current geodetic position, null until first successful propagation */ + lla: LlaVec3 | null = null; + /** Current Doppler factor (observed = transmitted * factor), 1 when unavailable */ + dopplerFactor: number = 1; + + constructor(name: string, norad: number, rxSignal: RfSignal[], beaconSignal: RfSignal[], orbitalConfig: OrbitalSatelliteConfig, satelliteState: Partial = {}) { + super(name, norad, rxSignal, beaconSignal, { + az: 0 as Degrees, + el: 0 as Degrees, + frequencyOffset: 2.225e9 as Hertz, + ...satelliteState, + orbitType: 'leo', + }); + + this.ootkSat_ = new OotkSatellite({ + name, + tle1: orbitalConfig.tle1, + tle2: orbitalConfig.tle2, + }); + this.predictionSat_ = this.ootkSat_; + this.observerConfig_ = orbitalConfig.observer; + this.observer_ = new GroundObject({ + name: orbitalConfig.observer.name ?? 'Ground Station', + lat: orbitalConfig.observer.lat, + lon: orbitalConfig.observer.lon, + alt: orbitalConfig.observer.alt, + }); + this.isDopplerEnabled_ = orbitalConfig.isDopplerEnabled ?? true; + this.minElevation_ = orbitalConfig.minElevation ?? (0 as Degrees); + this.rawTxSignal_ = this.txSignal; + + // Seed telemetry at the TLE epoch so az/el/range are sane before the + // scenario clock starts driving updates. + this.propagateTo_(this.ootkSat_.toTle().epoch.toDateTime().getTime()); + } + + /** The underlying ootk satellite: the orbit the spacecraft is really in. */ + get ootkSatellite(): OotkSatellite { + return this.ootkSat_; + } + + /** + * The element set the ground station has on file. Pass prediction and + * program-track pointing come from here; it lags the truth between a + * manoeuvre and the ephemeris load. + */ + get predictionSatellite(): OotkSatellite { + return this.predictionSat_; + } + + /** True while the station's element set no longer describes the orbit. */ + get isPredictionStale(): boolean { + return this.predictionSat_ !== this.ootkSat_; + } + + /** + * Load an element set onto BOTH the spacecraft and the station's file and + * immediately re-seed telemetry (Campaign 2 M4 space-domain events): the + * operator loads the updated ephemeris and the station's model matches the + * orbit again. Also how a scenario resets a shared instance to its authored + * set. Backward-compatible: unused unless a scenario drives a space event. + */ + reloadTle(tle1: TleLine1, tle2: TleLine2): void { + this.ootkSat_ = new OotkSatellite({ name: this.ootkSat_.name, tle1, tle2 }); + this.predictionSat_ = this.ootkSat_; + this.observerCache_.clear(); + this.predictionCache_.clear(); + this.propagateTo_(getSimulatedNowMs()); + } + + /** + * The spacecraft manoeuvres: the orbit changes, the station's element set + * does not. Program-track keeps pointing where the old set says the bird is + * until `reloadTle` brings the station back onto the truth. + */ + maneuverTo(tle1: TleLine1, tle2: TleLine2): void { + this.ootkSat_ = new OotkSatellite({ name: this.ootkSat_.name, tle1, tle2 }); + this.observerCache_.clear(); + this.propagateTo_(getSimulatedNowMs()); + } + + /** The ground station observer used for relative telemetry. */ + get groundObserver(): GroundObject { + return this.observer_; + } + + /** The canonical observer as configured (lat/lon/alt). */ + get canonicalObserver(): OrbitalObserver { + return this.observerConfig_; + } + + /** Elevation below which this satellite's signals are not receivable. */ + get minElevation(): Degrees { + return this.minElevation_; + } + + /** True when the satellite is above the minimum receivable elevation. */ + get isAboveHorizon(): boolean { + return this.el > this.minElevation_; + } + + /** `isAboveHorizon` from any station. */ + isAboveHorizonFor(observer: OrbitalObserver): boolean { + return this.geometryFor(observer).isAboveHorizon; + } + + /** + * The satellite as seen from `observer` at the last propagation time. Cached + * per observer per propagation, so every antenna at one site shares a + * computation and all consumers within a tick agree. + */ + geometryFor(observer: OrbitalObserver): ObserverGeometry { + const key = observerKey_(observer); + const cached = this.observerCache_.get(key); + if (cached?.atMs === this.propagatedAtMs_) { + return cached.geometry; + } + + const ground = groundObjectFor(observer); + const date = new Date(this.propagatedAtMs_); + const rae = this.ootkSat_.rae(ground, date); + const geometry: ObserverGeometry = rae + ? { + az: (((rae.az % 360) + 360) % 360) as Degrees, + el: rae.el, + rangeKm: rae.rng, + dopplerFactor: this.isDopplerEnabled_ ? (this.ootkSat_.dopplerFactor(ground, date) ?? 1) : 1, + isAboveHorizon: rae.el > this.minElevation_, + } + : // Propagation failed: fall back to canonical telemetry rather than a hole in the sky + { az: this.az, el: this.el, rangeKm: (this.rangeKm ?? 0) as Kilometers, dopplerFactor: this.dopplerFactor, isAboveHorizon: this.isAboveHorizon }; + + this.observerCache_.set(key, { atMs: this.propagatedAtMs_, geometry }); + return geometry; + } + + /** + * Program-track pointing from `observer`: where the station's element set + * puts the bird, plus the authored ephemeris error. Equals true geometry + * plus the error until a manoeuvre leaves the station's set stale. + */ + predictedFor(observer: OrbitalObserver): { az: Degrees; el: Degrees } { + const key = observerKey_(observer); + const cached = this.predictionCache_.get(key); + if (cached?.atMs === this.propagatedAtMs_) { + return { az: cached.az, el: cached.el }; + } + + const base = this.isPredictionStale ? this.predictionGeometry_(observer) : this.geometryFor(observer); + const az = ((base.az as number) + (this.ephemerisErrorAz as number)) as Degrees; + const el = ((base.el as number) + (this.ephemerisErrorEl as number)) as Degrees; + this.predictionCache_.set(key, { atMs: this.propagatedAtMs_, az, el }); + return { az, el }; + } + + /** Canonical predicted pointing, from the station's element set. */ + override get predictedAz(): Degrees { + return this.predictedFor(this.observerConfig_).az; + } + + override get predictedEl(): Degrees { + return this.predictedFor(this.observerConfig_).el; + } + + /** Az/el of the station's (stale) element set at the last propagation time. */ + private predictionGeometry_(observer: OrbitalObserver): { az: Degrees; el: Degrees } { + const rae = this.predictionSat_.rae(groundObjectFor(observer), new Date(this.propagatedAtMs_)); + if (!rae) { + const g = this.geometryFor(observer); + return { az: g.az, el: g.el }; + } + return { az: (((rae.az % 360) + 360) % 360) as Degrees, el: rae.el }; + } + + /** + * Downlink signals as received at `observer`: nothing below that station's + * horizon, and Doppler for that station's range rate. The canonical + * `txSignal` array is the same view from the configured observer. + */ + txSignalsFor(observer: OrbitalObserver): RfSignal[] { + const g = this.geometryFor(observer); + if (!g.isAboveHorizon) { + return []; + } + if (!this.isDopplerEnabled_ || g.dopplerFactor === 1) { + return this.rawTxSignal_; + } + return this.rawTxSignal_.map((sig) => ({ + ...sig, + frequency: (sig.frequency * g.dopplerFactor) as RfFrequency, + })); + } + + /** + * Propagate to the current simulated time on every frame the clock moves. + * Replaces the legacy fixed/figure-8 position models. + * + * Not throttled to the base class interval: at 1 Hz a LEO bird moves up to + * a degree between updates, more than a Ku beamwidth, so program-track + * chased a stepping target and the dish read off-beam for a frame or two + * every second (the one-frame C/N collapse the hold and dwell logic had to + * ride through). Continuous geometry keeps pointing, FSPL and Doppler + * consistent from frame to frame. + */ + protected updatePosition_(): void { + const nowMs = getSimulatedNowMs(); + if (nowMs === this.lastPositionUpdateTime_) { + return; // paused clock: nothing moved + } + this.lastPositionUpdateTime_ = nowMs; + this.propagateTo_(nowMs); + } + + /** + * Update signals like the base class, then apply orbital effects: + * suppress all transmissions below the horizon and Doppler-shift downlinks. + */ + update(): void { + super.update(); + this.rawTxSignal_ = this.txSignal; + + if (!this.isAboveHorizon) { + this.txSignal = []; + return; + } + + if (this.isDopplerEnabled_ && this.dopplerFactor !== 1) { + this.txSignal = this.txSignal.map((sig) => ({ + ...sig, + frequency: (sig.frequency * this.dopplerFactor) as RfFrequency, + })); + } + } + + private propagateTo_(timeMs: number): void { + const date = new Date(timeMs); + const rae = this.ootkSat_.rae(this.observer_, date); + + if (!rae) { + // Propagation failed (e.g., time far outside TLE validity) - keep last state + return; + } + + this.propagatedAtMs_ = timeMs; + this.az = (((rae.az % 360) + 360) % 360) as Degrees; + this.el = rae.el; + this.rangeKm = rae.rng; + + const posVel = this.ootkSat_.eci(date); + this.eciPosition = posVel?.position ?? null; + this.eciVelocity = posVel?.velocity ?? null; + this.lla = this.ootkSat_.lla(date); + + if (this.isDopplerEnabled_) { + this.dopplerFactor = this.ootkSat_.dopplerFactor(this.observer_, date) ?? 1; + } + } +} diff --git a/src/equipment/satellite/satellite.ts b/src/equipment/satellite/satellite.ts index 3f6fbc80..9337c012 100644 --- a/src/equipment/satellite/satellite.ts +++ b/src/equipment/satellite/satellite.ts @@ -1,9 +1,12 @@ -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; -import { SignalOrigin } from "@app/signal-origin"; -import { PerlinNoise } from "@app/simulation/perlin-noise"; -import { dBi, dBm, Hertz, RfFrequency, RfSignal } from "@app/types"; -import { Degrees } from "ootk"; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { SignalOrigin } from '@app/signal-origin'; +import { PerlinNoise } from '@app/simulation/perlin-noise'; +import { Rng, type RngStream } from '@app/simulation/rng'; +import { SimClock } from '@app/simulation/sim-clock'; +import { getSimulatedNowMs } from '@app/simulation/sim-time'; +import { dBi, dBm, Hertz, RfFrequency, RfSignal } from '@app/types'; +import { Degrees } from 'ootk'; /** * Configuration for explicitly defining a satellite transponder. @@ -89,8 +92,13 @@ export interface SignalDegradationConfig { interferencePower: dBm; } -/** Satellite orbit type determining position behavior */ -export type OrbitType = 'geostationary' | 'geosynchronous'; +/** + * Satellite orbit type determining position behavior. + * - 'geostationary': fixed az/el (default) + * - 'geosynchronous': parametric figure-8 (analemma) pattern + * - 'leo': real SGP4-propagated orbit; only valid on OrbitalSatellite subclass + */ +export type OrbitType = 'geostationary' | 'geosynchronous' | 'leo'; /** * Configuration for geosynchronous (inclined) orbit figure-8 pattern. @@ -162,12 +170,17 @@ export class Satellite { private readonly frequencyOffset: number; private readonly randomCache_: Map = new Map(); + + /** This satellite's random stream for the current run seed */ + private get rng_(): RngStream { + return Rng.stream(`satellite:${this.noradId}`); + } private readonly boundUpdateHandler_: () => void; private isSubscribedToEventBus_ = false; el: Degrees; az: Degrees; - rotation: Degrees = ((Math.random() * 90) - 45) as Degrees; + rotation: Degrees; name: string; /** Ephemeris error in azimuth (degrees) - simulates TLE inaccuracy */ @@ -190,21 +203,23 @@ export class Satellite { private phase_: number = 0; /** Timestamp of last position update (ms) - for throttling */ - private lastPositionUpdateTime_: number = 0; + protected lastPositionUpdateTime_: number = 0; /** Position update interval (ms) */ - private static readonly POSITION_UPDATE_INTERVAL_MS = 1000; + protected static readonly POSITION_UPDATE_INTERVAL_MS = 1000; + + /** + * Slant range from the ground station to the satellite (km). + * Null for legacy fixed-telemetry satellites, in which case the antenna + * falls back to the nominal GEO slant range for path-loss calculations. + * Populated each position update by OrbitalSatellite. + */ + rangeKm: number | null = null; /** Rate of position change: 0.1 degrees per 30 seconds (peak velocity) */ private static readonly POSITION_RATE_DEG_PER_MS = 0.1 / 30000; - constructor( - name: string, - norad: number, - rxSignal: RfSignal[] = [], - beaconSignal: RfSignal[] = [], - satelliteState: SatelliteState = Satellite.getDefaultState_(), - ) { + constructor(name: string, norad: number, rxSignal: RfSignal[] = [], beaconSignal: RfSignal[] = [], satelliteState: SatelliteState = Satellite.getDefaultState_()) { this.noradId = norad; this.externalSignal = rxSignal; this.rxSignal = []; @@ -213,7 +228,8 @@ export class Satellite { this.health = 1.0; this.az = satelliteState.az; this.el = satelliteState.el; - this.rotation = satelliteState.rotation ?? this.rotation; + // Unauthored skew is a property of the satellite, not of the run: fixed per NORAD id + this.rotation = satelliteState.rotation ?? ((Rng.hashUniform(`satellite-rotation:${norad}`) * 90 - 45) as Degrees); this.ephemerisErrorAz = satelliteState.ephemerisErrorAz ?? (0 as Degrees); this.ephemerisErrorEl = satelliteState.ephemerisErrorEl ?? (0 as Degrees); @@ -228,7 +244,7 @@ export class Satellite { powerVariationRange: 1.0 as dBm, interference: false, interferencePower: -110 as dBm, - ...satelliteState.degradationConfig + ...satelliteState.degradationConfig, }; // Initialize orbit type and configuration @@ -346,14 +362,19 @@ export class Satellite { * Traces a figure-8 (analemma) pattern using parametric equations. * Throttled to 1 second intervals to reduce computation. */ - private updatePosition_(): void { + protected updatePosition_(): void { if (this.orbitType !== 'geosynchronous' || !this.geosyncConfig_) { return; } - // Throttle position updates - const now = Date.now(); + // Throttle position updates (scenario time: the wobble is sky, and pauses with it) + const now = getSimulatedNowMs(); const elapsed = now - this.lastPositionUpdateTime_; + if (elapsed < 0) { + // A new scenario started earlier on the calendar; satellites outlive scenarios + this.lastPositionUpdateTime_ = now; + return; + } if (elapsed < Satellite.POSITION_UPDATE_INTERVAL_MS) { return; } @@ -365,7 +386,7 @@ export class Satellite { this.phase_ += phaseRate * elapsed; // Wrap phase to [0, 2*PI] - this.phase_ = this.phase_ % (2 * Math.PI); + this.phase_ %= 2 * Math.PI; if (this.phase_ < 0) this.phase_ += 2 * Math.PI; // Calculate new position using parametric equations: @@ -402,13 +423,12 @@ export class Satellite { // We need to create random values for each signal to use in degradation effects const allRxSignals = [...this.rxSignal, ...this.externalSignal]; + const rng = this.rng_; for (const signal of allRxSignals) { - // Power Variation - this.randomCache_.set(`${signal.signalId}-powerVariation`, Math.random()); // Rain Variation - this.randomCache_.set(`${signal.signalId}-rain`, Math.random()); - // Scintillation (pre-cached to avoid Math.random() during degradation) - this.randomCache_.set(`${signal.signalId}-scintillation`, Math.random()); + this.randomCache_.set(`${signal.signalId}-rain`, rng.next()); + // Scintillation (pre-cached so degradation makes no draws of its own) + this.randomCache_.set(`${signal.signalId}-scintillation`, rng.next()); } } @@ -449,8 +469,10 @@ export class Satellite { frequency: txFrequency, power: txPower, origin: SignalOrigin.SATELLITE_TX, - // Reverse polarization for downlink - polarization: signal.polarization === 'H' ? 'V' : 'H', + // Reverse linear polarization for downlink; circular polarization is + // set by the transponder's own antenna and passes through unchanged + // (an RHCP uplink must not come back as 'H' - Campaign 3 S8) + polarization: signal.polarization === 'H' ? 'V' : signal.polarization === 'V' ? 'H' : signal.polarization, }; // Apply degradation effects @@ -487,11 +509,8 @@ export class Satellite { * 1. Signal frequency falling within transponder's passband (uplinkLowEdge to uplinkHighEdge) * 2. Signal polarization matching transponder's polarization */ - private findTransponderByUplinkFrequency( - frequency: RfFrequency, - polarization: 'H' | 'V' | 'LHCP' | 'RHCP' | null - ): Transponder | undefined { - return this.transponders.find(tp => { + private findTransponderByUplinkFrequency(frequency: RfFrequency, polarization: 'H' | 'V' | 'LHCP' | 'RHCP' | null): Transponder | undefined { + return this.transponders.find((tp) => { // Check if frequency falls within passband const inPassband = frequency >= tp.uplinkLowEdge && frequency <= tp.uplinkHighEdge; if (!inPassband) return false; @@ -511,7 +530,7 @@ export class Satellite { } // Soft saturation curve (AM/PM conversion effects) - const excessPower = inputPower - saturationPower as dBm; + const excessPower = (inputPower - saturationPower) as dBm; const compressionFactor = 1 / (1 + excessPower / 10); return Math.min(saturationPower + excessPower * compressionFactor, maxPower) as dBm; @@ -529,15 +548,15 @@ export class Satellite { const k = 1.38e-23; const T = 290; // Kelvin - const noisePowerWatts = k * T * bandwidth * Math.pow(10, noiseFigure / 10); + const noisePowerWatts = k * T * bandwidth * 10 ** (noiseFigure / 10); const noisePowerDbm = 10 * Math.log10(noisePowerWatts * 1000); // Combine signal and noise power (in linear scale) - const signalLinear = Math.pow(10, signalPower / 10); - const noiseLinear = Math.pow(10, noisePowerDbm / 10); + const signalLinear = 10 ** (signalPower / 10); + const noiseLinear = 10 ** (noisePowerDbm / 10); const totalLinear = signalLinear + noiseLinear; - return 10 * Math.log10(totalLinear) as dBm; + return (10 * Math.log10(totalLinear)) as dBm; } /** @@ -546,10 +565,7 @@ export class Satellite { */ private applyDegradationEffects(signal: RfSignal): RfSignal { // Early exit if all degradation effects are disabled - if (!this.degradationConfig.powerVariation && - !this.degradationConfig.atmosphericEffects && - !this.degradationConfig.interference && - this.health >= 1.0) { + if (!this.degradationConfig.powerVariation && !this.degradationConfig.atmosphericEffects && !this.degradationConfig.interference && this.health >= 1.0) { return signal; } @@ -573,7 +589,7 @@ export class Satellite { // Health degradation const healthLossDeb = (1 - this.health) * 10; - power = power - healthLossDeb as dBm; + power = (power - healthLossDeb) as dBm; if (this.health < 0.9 || degradedSignal.isDegraded) { degradedSignal.isDegraded = true; } @@ -589,18 +605,22 @@ export class Satellite { * @returns Updated power level in dBm */ private applyPowerVariation_inPlace(signalId: string, currentPower: dBm): dBm { - // Get or create noise generator for this signal - if (!this.noiseGenerators.has(signalId)) { - this.noiseGenerators.set(signalId, PerlinNoise.getInstance(signalId)); + // One generator per (run seed, signal); rebuilt when a scenario load re-seeds + const noiseSeed = `${Rng.getSeed()}:${this.noradId}:${signalId}`; + let noiseGen = this.noiseGenerators.get(signalId); + if (noiseGen?.seed !== noiseSeed) { + noiseGen = new PerlinNoise(noiseSeed); + this.noiseGenerators.set(signalId, noiseGen); } - const noiseGen = this.noiseGenerators.get(signalId); - if (!noiseGen) return currentPower; + // Slow fading on run time (a physical process, not a scheduled one), with a + // per-signal phase so carriers do not fade together + const phaseS = Rng.hashUniform(noiseSeed) * 1000; + const time = SimClock.runMs() / 1000 + phaseS; - const randomPowerFactor = this.randomCache_.get(`${signalId}-powerVariation`) ?? 1; - const time = Date.now() / 1000 + randomPowerFactor * 1000; - - // Perlin noise returns 0-1, convert to -1 to 1 + // KNOWN DEVIATION (fixed in Phase 19.2): Perlin output is zero-mean, so + // `* 2 - 1` biases every relayed downlink by -powerVariationRange. Kept + // until the 19.1 calibration ledger can record the shift. const noiseValue = noiseGen.get(time) * 2 - 1; // Apply variation @@ -638,8 +658,8 @@ export class Satellite { */ private applyInterference_inPlace(currentPower: dBm): dBm { // Calculate C/I (Carrier-to-Interference ratio) - const carrierPowerLinear = Math.pow(10, currentPower / 10); - const interferencePowerLinear = Math.pow(10, this.degradationConfig.interferencePower / 10); + const carrierPowerLinear = 10 ** (currentPower / 10); + const interferencePowerLinear = 10 ** (this.degradationConfig.interferencePower / 10); const totalPowerLinear = carrierPowerLinear + interferencePowerLinear; return (10 * Math.log10(totalPowerLinear)) as dBm; @@ -651,12 +671,12 @@ export class Satellite { private updateHealth(): void { // Gradual health degradation simulation // In a real scenario, this could be based on radiation damage, component failures, etc. - if (Math.random() < 0.0001) { + if (this.rng_.chance(0.0001)) { this.health = Math.max(0.5, this.health - 0.01); } // Gradual recovery - if (this.health < 1.0 && Math.random() < 0.001) { + if (this.health < 1.0 && this.rng_.chance(0.001)) { this.health = Math.min(1.0, this.health + 0.01); } } @@ -669,7 +689,7 @@ export class Satellite { return false; } - return Math.random() < this.degradationConfig.dropoutProbability; + return this.rng_.chance(this.degradationConfig.dropoutProbability); } /** @@ -724,7 +744,7 @@ export class Satellite { * Set transponder active state. */ setTransponderActive(transponderId: string, active: boolean): void { - const transponder = this.transponders.find(tp => tp.id === transponderId); + const transponder = this.transponders.find((tp) => tp.id === transponderId); if (transponder) { transponder.isActive = active; } @@ -736,7 +756,7 @@ export class Satellite { configureDegradation(config: Partial): void { this.degradationConfig = { ...this.degradationConfig, - ...config + ...config, }; } @@ -744,10 +764,10 @@ export class Satellite { * Get carrier-to-noise ratio for a specific signal. */ getCarrierToNoiseRatio(signalId: string): number | null { - const signal = this.txSignal.find(s => s.signalId === signalId); + const signal = this.txSignal.find((s) => s.signalId === signalId); if (!signal) return null; - const transponderIndex = this.rxSignal.findIndex(s => s.signalId === signalId); + const transponderIndex = this.rxSignal.findIndex((s) => s.signalId === signalId); if (transponderIndex < 0) return null; const transponder = this.transponders[transponderIndex]; @@ -755,9 +775,9 @@ export class Satellite { // Calculate noise power const k = 1.38e-23; const T = 290; - const noisePowerWatts = k * T * signal.bandwidth * Math.pow(10, transponder.noiseFigure / 10); + const noisePowerWatts = k * T * signal.bandwidth * 10 ** (transponder.noiseFigure / 10); const noisePowerDbm = 10 * Math.log10(noisePowerWatts * 1000); return signal.power - noisePowerDbm; } -} \ No newline at end of file +} diff --git a/src/equipment/transmitter/transmitter.css b/src/equipment/transmitter/transmitter.css index 5a19a8e9..31403dbc 100644 --- a/src/equipment/transmitter/transmitter.css +++ b/src/equipment/transmitter/transmitter.css @@ -24,11 +24,11 @@ align-items: center; margin-bottom: 16px; padding-bottom: 12px; - border-bottom: 1px solid #444; + border-bottom: 1px solid var(--mc-equip-border-light); } .transmitter-title { - color: #fff; + color: var(--mc-equip-text); font-size: 18px; font-weight: bold; } @@ -42,12 +42,12 @@ .status-active { background-color: #f44336; - color: #fff; + color: var(--mc-equip-text); } .status-standby { - background-color: #757575; - color: #fff; + background-color: var(--mc-equip-unlit); + color: var(--mc-equip-text); } /* Controls Container */ @@ -68,9 +68,9 @@ .btn-modem { padding: 12px; - background-color: #555; - color: #fff; - border: 2px solid #777; + background-color: var(--mc-equip-control); + color: var(--mc-equip-text); + border: 2px solid var(--mc-equip-control-lighter); border-radius: 4px; font-weight: bold; font-size: 16px; @@ -79,7 +79,7 @@ } .btn-modem:hover { - background-color: #666; + background-color: var(--mc-equip-control-light); transform: translateX(2px); } @@ -121,7 +121,7 @@ flex-direction: column; gap: 5px; padding: 12px; - background-color: #1a1a1a; + background-color: var(--mc-equip-bg); border-radius: 8px; width: 255px; } @@ -141,7 +141,7 @@ } .config-row label { - color: #fff; + color: var(--mc-equip-text); font-size: 14px; text-align: right; } @@ -149,9 +149,9 @@ .config-row input, .config-row select { padding: 5px; - background-color: #2a2a2a; - color: #fff; - border: 1px solid #555; + background-color: var(--mc-equip-panel-raised); + color: var(--mc-equip-text); + border: 1px solid var(--mc-equip-control); border-radius: 4px; font-size: 14px; } @@ -177,8 +177,8 @@ .power-bar-container { position: relative; height: 24px; - background-color: #2a2a2a; - border: 1px solid #555; + background-color: var(--mc-equip-panel-raised); + border: 1px solid var(--mc-equip-control); border-radius: 4px; overflow: hidden; } @@ -198,7 +198,7 @@ top: 50%; left: 50%; transform: translate(-50%, -50%); - color: #fff; + color: var(--mc-equip-text); font-size: 12px; font-weight: bold; text-shadow: 0 0 3px rgba(0, 0, 0, 0.8); @@ -210,14 +210,14 @@ gap: 12px; margin-top: auto; padding-top: 12px; - border-top: 1px solid #444; + border-top: 1px solid var(--mc-equip-border-light); } .btn-apply { flex: 1; padding: 12px; - background-color: #555; - color: #fff; + background-color: var(--mc-equip-control); + color: var(--mc-equip-text); border: none; border-radius: 4px; font-weight: bold; @@ -227,7 +227,7 @@ } .btn-apply:hover { - background-color: #666; + background-color: var(--mc-equip-control-light); transform: translateY(-1px); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } @@ -235,8 +235,8 @@ .btn-transmit { flex: 1; padding: 12px; - background-color: #555; - color: #fff; + background-color: var(--mc-equip-control); + color: var(--mc-equip-text); border: 2px solid #f44336; border-radius: 4px; font-weight: bold; @@ -252,6 +252,6 @@ .btn-transmit.active { background-color: #f44336; - color: #fff; + color: var(--mc-equip-text); box-shadow: 0 0 10px rgba(244, 67, 54, 0.5); } \ No newline at end of file diff --git a/src/equipment/transmitter/transmitter.ts b/src/equipment/transmitter/transmitter.ts index 07ba9adf..550538fa 100644 --- a/src/equipment/transmitter/transmitter.ts +++ b/src/equipment/transmitter/transmitter.ts @@ -1,12 +1,13 @@ -import { ToggleSwitch } from "@app/components/toggle-switch/toggle-switch"; -import { EventBus } from "@app/events/event-bus"; -import { SignalOrigin } from "@app/signal-origin"; import { PowerSwitch } from '@app/components/power-switch/power-switch'; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { Events } from "@app/events/events"; -import { dBi, dBm, FECType, Hertz, IfFrequency, IfSignal, ModulationType } from "@app/types"; -import { AlarmStatus, BaseEquipment } from "@app/equipment/base-equipment"; +import { ToggleSwitch } from '@app/components/toggle-switch/toggle-switch'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { AlarmStatus, BaseEquipment } from '@app/equipment/base-equipment'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { SignalOrigin } from '@app/signal-origin'; +import { SimClock } from '@app/simulation/sim-clock'; +import { dBi, dBm, FECType, Hertz, IfFrequency, IfSignal, ModulationType } from '@app/types'; import './transmitter.css'; export interface TransmitterModem { @@ -48,7 +49,7 @@ export class Transmitter extends BaseEquipment { // State state: TransmitterState; private inputData: Partial = { - ifSignal: {} as IfSignal + ifSignal: {} as IfSignal, }; private lastRenderState: TransmitterState | null = null; @@ -56,8 +57,8 @@ export class Transmitter extends BaseEquipment { private readonly powerBudget = 10 as dBm; // dBm (10W) total power budget // Intermittent fault timing constants (milliseconds) - private static readonly FAULT_ON_MS = 4000; // ~4s signal active - private static readonly FAULT_OFF_MS = 1000; // ~1s dropout + private static readonly FAULT_ON_MS = 4000; // ~4s signal active + private static readonly FAULT_OFF_MS = 1000; // ~1s dropout private static readonly FAULT_VARIATION_MS = 500; // +-500ms variation powerSwitch: PowerSwitch; txToggleSwitch: ToggleSwitch; @@ -74,9 +75,7 @@ export class Transmitter extends BaseEquipment { const server_id = state?.server_id ?? serverId; // Merge modem overrides by modem_number (so callers don't have to provide a full ordered array) - const overridesByModemNumber = new Map>( - (state?.modems ?? []).map(m => [m.modem_number, m]) - ); + const overridesByModemNumber = new Map>((state?.modems ?? []).map((m) => [m.modem_number, m])); const modems: TransmitterModem[] = defaults.modems.map((def) => { const override = overridesByModemNumber.get(def.modem_number); @@ -173,7 +172,7 @@ export class Transmitter extends BaseEquipment { * Toggles signal dropout on a ~5 second cycle with variation. */ private updateIntermittentFaultState_(): void { - const now = Date.now(); + const now = SimClock.runMs(); for (const modem of this.state.modems) { if (!modem.intermittentFault) { @@ -183,7 +182,10 @@ export class Transmitter extends BaseEquipment { } // Initialize cycle start if needed - modem.intermittentFaultCycleStart ??= now; + // Modem state outlives a scenario; run time restarts at 0 with each one + if (modem.intermittentFaultCycleStart === undefined || modem.intermittentFaultCycleStart > now) { + modem.intermittentFaultCycleStart = now; + } // Compute current dropout state (also stored for UI display) modem.isIntermittentDropout = this.computeIntermittentDropout_(modem, now); @@ -216,7 +218,7 @@ export class Transmitter extends BaseEquipment { * Called by RF front-end modules to get real-time dropout status. */ public isModemInIntermittentDropout(modem: TransmitterModem): boolean { - const now = Date.now(); + const now = SimClock.runMs(); const result = this.computeIntermittentDropout_(modem, now); // Debug logging (remove after debugging) @@ -229,7 +231,9 @@ export class Transmitter extends BaseEquipment { const onPeriod = Transmitter.FAULT_ON_MS + variation; const cycleLen = onPeriod + Transmitter.FAULT_OFF_MS; const cyclePosition = timeSinceStart % cycleLen; - console.log(`[IntermittentFault] Modem ${modem.modem_number}: dropout=${result}, cyclePos=${cyclePosition.toFixed(0)}/${cycleLen.toFixed(0)}, onPeriod=${onPeriod.toFixed(0)}`); + console.log( + `[IntermittentFault] Modem ${modem.modem_number}: dropout=${result}, cyclePos=${cyclePosition.toFixed(0)}/${cycleLen.toFixed(0)}, onPeriod=${onPeriod.toFixed(0)}` + ); } return result; @@ -265,14 +269,18 @@ export class Transmitter extends BaseEquipment {
- ${this.state.modems.map(modem => html` + ${this.state.modems + .map( + (modem) => html` - `).join('')} + ` + ) + .join('')}
@@ -400,7 +408,7 @@ export class Transmitter extends BaseEquipment { // Cache commonly used DOM nodes for efficient updates this.domCache['parent'] = parentDom; this.domCache['led'] = qs('.led', parentDom); - this.state.modems.forEach(modem => { + this.state.modems.forEach((modem) => { this.domCache[`modemButton${modem.modem_number}`] = qs(`#modem-${modem.modem_number}`, parentDom); }); this.domCache['inputAntenna'] = qs('.input-tx-antenna', parentDom); @@ -432,7 +440,7 @@ export class Transmitter extends BaseEquipment { protected addListeners_(parentDom: HTMLElement): void { // Modem selection buttons const modemButtons = parentDom.querySelectorAll('.btn-modem'); - modemButtons.forEach(btn => { + modemButtons.forEach((btn) => { btn.addEventListener('click', (e) => { const modemNum = Number.parseInt((e.target as HTMLElement).dataset.modem || '1'); this.setActiveModem(modemNum); @@ -441,7 +449,7 @@ export class Transmitter extends BaseEquipment { // Input changes const inputs = parentDom.querySelectorAll('input, select'); - inputs.forEach(input => { + inputs.forEach((input) => { input.addEventListener('change', (e) => this.handleInputChange(e)); }); @@ -467,19 +475,19 @@ export class Transmitter extends BaseEquipment { if (modem.isFaulted) { alarms.push({ message: `Modem ${modem.modem_number} Faulted`, - severity: 'error' + severity: 'error', }); } if (modem.intermittentFault) { alarms.push({ message: `Modem ${modem.modem_number} Intermittent Fault`, - severity: 'warning' + severity: 'warning', }); } if (modem.isLoopback) { alarms.push({ message: `Modem ${modem.modem_number} in Loopback Mode`, - severity: 'info' + severity: 'info', }); } if (modem.isTransmitting) { @@ -487,13 +495,13 @@ export class Transmitter extends BaseEquipment { if (!this.validatePowerConsumption(modemPower, 100)) { alarms.push({ message: `Modem ${modem.modem_number} Power Exceeds Max Transmit Power`, - severity: 'error' + severity: 'error', }); } if (!this.validatePowerConsumption(modemPower, 90)) { alarms.push({ message: `Modem ${modem.modem_number} Power Approaching Max Transmit Power`, - severity: 'warning' + severity: 'warning', }); } } @@ -509,15 +517,18 @@ export class Transmitter extends BaseEquipment { this.activeModem.isFaulted = false; } - setTimeout(() => { - this.activeModem.isPowered = isOn; - this.emit(Events.TX_CONFIG_CHANGED, { - uuid: this.uuid, - modem: this.state.activeModem, - config: this.activeModem - }); - this.syncDomWithState(); - }, isOn ? 4000 : 250); + SimClock.setTimeout( + () => { + this.activeModem.isPowered = isOn; + this.emit(Events.TX_CONFIG_CHANGED, { + uuid: this.uuid, + modem: this.state.activeModem, + config: this.activeModem, + }); + this.syncDomWithState(); + }, + isOn ? 4000 : 250 + ); } protected initialize_(): void { @@ -537,7 +548,7 @@ export class Transmitter extends BaseEquipment { */ get activeModem(): TransmitterModem { - return this.state.modems.find(m => m.modem_number === this.state.activeModem) ?? this.state.modems[0]; + return this.state.modems.find((m) => m.modem_number === this.state.activeModem) ?? this.state.modems[0]; } /** @@ -551,7 +562,7 @@ export class Transmitter extends BaseEquipment { // Emit event for modem change this.emit(Events.TX_ACTIVE_MODEM_CHANGED, { uuid: this.uuid, - activeModem: modemNumber + activeModem: modemNumber, }); } @@ -572,12 +583,12 @@ export class Transmitter extends BaseEquipment { case 'frequency': value = Number.parseFloat(value) || 0; // Convert MHz to Hertz - this.inputData.ifSignal.frequency = value * 1e6 as IfFrequency; + this.inputData.ifSignal.frequency = (value * 1e6) as IfFrequency; break; case 'bandwidth': value = Number.parseFloat(value) || 0; // Convert MHz to Hertz - this.inputData.ifSignal.bandwidth = value * 1e6 as IfFrequency; + this.inputData.ifSignal.bandwidth = (value * 1e6) as IfFrequency; break; case 'antenna_id': this.inputData.antenna_id = Number.parseInt(value); @@ -593,10 +604,9 @@ export class Transmitter extends BaseEquipment { } } - private toggleTransmit(): void { const activeModem = this.activeModem; - const modemIndex = this.state.modems.findIndex(m => m.modem_number === this.state.activeModem); + const modemIndex = this.state.modems.findIndex((m) => m.modem_number === this.state.activeModem); if (activeModem.isPowered === false) { return; @@ -609,7 +619,7 @@ export class Transmitter extends BaseEquipment { this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[this.activeModem.id] + config: this.state.modems[this.activeModem.id], }); this.syncDomWithState(); @@ -619,7 +629,7 @@ export class Transmitter extends BaseEquipment { this.activeModem.isFaultSwitchUp = true; // Wait 3 seconds and then clear fault and reset switch - setTimeout(() => { + SimClock.setTimeout(() => { if (!this.activeModem.isTransmitting) { this.activeModem.isFaulted = false; } @@ -629,14 +639,14 @@ export class Transmitter extends BaseEquipment { this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[this.activeModem.id] + config: this.state.modems[this.activeModem.id], }); }, 250); this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[this.activeModem.id] + config: this.state.modems[this.activeModem.id], }); } @@ -646,7 +656,7 @@ export class Transmitter extends BaseEquipment { this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[this.activeModem.id] + config: this.state.modems[this.activeModem.id], }); this.syncDomWithState(); } @@ -687,10 +697,7 @@ export class Transmitter extends BaseEquipment { if (!activeModem.isPowered) return 0; - const modemPower = this.calculatePowerBudgetLoad_( - activeModem.ifSignal.bandwidth, - activeModem.ifSignal.power - ); + const modemPower = this.calculatePowerBudgetLoad_(activeModem.ifSignal.bandwidth, activeModem.ifSignal.power); return Math.round((100 * modemPower) / this.powerBudget); } @@ -745,7 +752,7 @@ export class Transmitter extends BaseEquipment { */ public handleTransmitToggle(isEnabled: boolean): void { const activeModem = this.activeModem; - const modemIndex = this.state.modems.findIndex(m => m.modem_number === this.state.activeModem); + const modemIndex = this.state.modems.findIndex((m) => m.modem_number === this.state.activeModem); if (!activeModem.isPowered) return; @@ -756,7 +763,7 @@ export class Transmitter extends BaseEquipment { this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[this.activeModem.id] + config: this.state.modems[this.activeModem.id], }); this.syncDomWithState(); @@ -772,7 +779,7 @@ export class Transmitter extends BaseEquipment { this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[this.activeModem.id] + config: this.state.modems[this.activeModem.id], }); this.syncDomWithState(); @@ -785,15 +792,18 @@ export class Transmitter extends BaseEquipment { this.activeModem.isFaulted = false; } - setTimeout(() => { - this.activeModem.isPowered = isEnabled; - this.emit(Events.TX_CONFIG_CHANGED, { - uuid: this.uuid, - modem: this.state.activeModem, - config: this.activeModem - }); - this.syncDomWithState(); - }, isEnabled ? 4000 : 250); + SimClock.setTimeout( + () => { + this.activeModem.isPowered = isEnabled; + this.emit(Events.TX_CONFIG_CHANGED, { + uuid: this.uuid, + modem: this.state.activeModem, + config: this.activeModem, + }); + this.syncDomWithState(); + }, + isEnabled ? 4000 : 250 + ); } /** @@ -803,7 +813,7 @@ export class Transmitter extends BaseEquipment { this.updateTransmissionState(); // Find the correct array index for the active modem - const modemIndex = this.state.modems.findIndex(m => m.modem_number === this.state.activeModem); + const modemIndex = this.state.modems.findIndex((m) => m.modem_number === this.state.activeModem); if (modemIndex === -1) { console.warn('[Transmitter.applyChanges] Could not find modem with modem_number:', this.state.activeModem); return; @@ -822,7 +832,7 @@ export class Transmitter extends BaseEquipment { this.emit(Events.TX_CONFIG_CHANGED, { uuid: this.uuid, modem: this.state.activeModem, - config: this.state.modems[modemIndex] + config: this.state.modems[modemIndex], }); // Reset inputData to match the newly applied state @@ -840,19 +850,19 @@ export class Transmitter extends BaseEquipment { const parentDom = this.domCache['parent']; // Update status - const isTransmitting = this.state.modems.some(m => m.isTransmitting); - const somePower = this.state.modems.some(m => m.isPowered); + const isTransmitting = this.state.modems.some((m) => m.isTransmitting); + const somePower = this.state.modems.some((m) => m.isPowered); if (somePower) { - (this.domCache['led']).className = `led ${isTransmitting ? 'led-red' : 'led-green'}`; + this.domCache['led'].className = `led ${isTransmitting ? 'led-red' : 'led-green'}`; } else { - (this.domCache['led']).className = `led`; + this.domCache['led'].className = `led`; } // Update modem buttons const modemButtons = parentDom.querySelectorAll('.btn-modem'); modemButtons.forEach((btn) => { const modemNum = Number((btn as HTMLElement).dataset['modem']); - const modem = this.state.modems.find(m => m.modem_number === modemNum); + const modem = this.state.modems.find((m) => m.modem_number === modemNum); const isActive = modemNum === this.state.activeModem; const transmittingClass = modem?.isTransmitting ? 'transmitting' : ''; btn.className = `btn-modem ${isActive ? 'active' : ''} ${transmittingClass}`.trim(); @@ -869,7 +879,7 @@ export class Transmitter extends BaseEquipment { } // Convert Hertz to MHz for display - const freqHz = (this.inputData.ifSignal?.frequency) ?? activeModem.ifSignal.frequency ?? 0; + const freqHz = this.inputData.ifSignal?.frequency ?? activeModem.ifSignal.frequency ?? 0; (this.domCache['inputFrequency'] as HTMLInputElement).value = freqHz ? String(freqHz / 1e6) : ''; // Convert Hertz to MHz for display @@ -896,10 +906,11 @@ export class Transmitter extends BaseEquipment { if (this.domCache['powerBar']) { const bar = this.domCache['powerBar']; bar.style.width = `${Math.min(pct, 100)}%`; - if (pct > 100) bar.classList.add('over-budget'); else bar.classList.remove('over-budget'); + if (pct > 100) bar.classList.add('over-budget'); + else bar.classList.remove('over-budget'); } if (this.domCache['powerPercentage']) { - (this.domCache['powerPercentage']).textContent = `${Math.round(pct)}%`; + this.domCache['powerPercentage'].textContent = `${Math.round(pct)}%`; } // Update transmit button active class @@ -917,4 +928,4 @@ export class Transmitter extends BaseEquipment { // Save snapshot this.lastRenderState = structuredClone(this.state); } -} \ No newline at end of file +} diff --git a/src/events/event-bus.ts b/src/events/event-bus.ts index b3033540..14dcd764 100644 --- a/src/events/event-bus.ts +++ b/src/events/event-bus.ts @@ -1,5 +1,5 @@ -import { Logger } from "@app/logging/logger"; -import { EventMap, Events } from "./events"; +import { Logger } from '@app/logging/logger'; +import { EventMap, Events } from './events'; type EventHandlersMap = Partial void>>>; @@ -11,7 +11,7 @@ export class EventBus { private static instance: EventBus; private readonly events: EventHandlersMap = {}; - private constructor() { } + private constructor() {} static getInstance(): EventBus { if (!EventBus.instance) { @@ -36,7 +36,7 @@ export class EventBus { off(event: T, cb: (...args: EventMap[T]) => void) { const callbacks = this.events[event]; if (callbacks) { - this.events[event] = callbacks.filter(fn => fn !== cb); + this.events[event] = callbacks.filter((fn) => fn !== cb); } } @@ -58,8 +58,8 @@ export class EventBus { } /** - * Emit an event - */ + * Emit an event + */ emit(event: T, ...args: EventMap[T]): void { Logger.log(`EventBus: Emitting event '${event}' with args:`, args); const callbacks = this.events[event]; diff --git a/src/events/events.ts b/src/events/events.ts index efbb3560..45d54581 100644 --- a/src/events/events.ts +++ b/src/events/events.ts @@ -1,29 +1,24 @@ -import { GroundStationState } from "@app/assets/ground-station/ground-station-state"; -import { AntennaState } from "@app/equipment/antenna"; -import type { Character } from "@app/modal/character-enum"; -import { RealTimeSpectrumAnalyzerState } from "@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer"; -import { AGCState } from "@app/equipment/rf-front-end/agc-module"; -import { BUCState } from "@app/equipment/rf-front-end/buc-module"; -import { CouplerState } from "@app/equipment/rf-front-end/coupler-module/coupler-module"; -import { IfFilterBankState } from "@app/equipment/rf-front-end/filter-module"; -import { NotchFilterState } from "@app/equipment/rf-front-end/notch-filter-module"; -import { GPSDOState } from "@app/equipment/rf-front-end/gpsdo-module"; -import { HPAState } from "@app/equipment/rf-front-end/hpa-module"; -import { LNBState } from "@app/equipment/rf-front-end/lnb-module"; -import { OMTState } from "@app/equipment/rf-front-end/omt-module/omt-module"; -import { RFFrontEndState } from "@app/equipment/rf-front-end/rf-front-end-core"; -import type { - CryptoKeyExpiredData, - CryptoKeyRotatedData, - CryptoStateChangedData, - CryptoZeroizedData, -} from "@app/equipment/crypto/crypto-types"; -import { Milliseconds } from "ootk"; -import { ReceiverModemState } from "@app/equipment/receiver/receiver"; -import { TransmitterModem } from "@app/equipment/transmitter/transmitter"; -import { ConditionState, Objective, ObjectiveState } from "@app/objectives/objective-types"; -import { OpsLogEntry } from "@app/ops-log/ops-log-types"; -import { RfSignal } from "@app/types"; +import { GroundStationState } from '@app/assets/ground-station/ground-station-state'; +import { AntennaState } from '@app/equipment/antenna'; +import type { CryptoKeyExpiredData, CryptoKeyRotatedData, CryptoStateChangedData, CryptoZeroizedData } from '@app/equipment/crypto/crypto-types'; +import { RealTimeSpectrumAnalyzerState } from '@app/equipment/real-time-spectrum-analyzer/real-time-spectrum-analyzer'; +import { ReceiverModemState } from '@app/equipment/receiver/receiver'; +import { AGCState } from '@app/equipment/rf-front-end/agc-module'; +import { BUCState } from '@app/equipment/rf-front-end/buc-module'; +import { CouplerState } from '@app/equipment/rf-front-end/coupler-module/coupler-module'; +import { IfFilterBankState } from '@app/equipment/rf-front-end/filter-module'; +import { GPSDOState } from '@app/equipment/rf-front-end/gpsdo-module'; +import { HPAState } from '@app/equipment/rf-front-end/hpa-module'; +import { LNBState } from '@app/equipment/rf-front-end/lnb-module'; +import { NotchFilterState } from '@app/equipment/rf-front-end/notch-filter-module'; +import { OMTState } from '@app/equipment/rf-front-end/omt-module/omt-module'; +import { RFFrontEndState } from '@app/equipment/rf-front-end/rf-front-end-core'; +import { TransmitterModem } from '@app/equipment/transmitter/transmitter'; +import type { Character } from '@app/modal/character-enum'; +import { ConditionState, ConditionType, Objective, ObjectiveState } from '@app/objectives/objective-types'; +import { OpsLogEntry } from '@app/ops-log/ops-log-types'; +import { RfSignal } from '@app/types'; +import { Milliseconds } from 'ootk'; // Antenna Event specific interfaces export interface AntennaLoopbackChangedData { @@ -115,6 +110,15 @@ export interface ObjectiveConditionChangedData { conditionState: ConditionState; } +export interface ObjectiveConditionEvaluatedData { + objectiveId: string; + conditionIndex: number; + type: ConditionType; + isSatisfied: boolean; + /** The value the check compared against its target (frequency, mode, power...). Undefined for boolean-only checks. */ + observed: unknown; +} + export interface ObjectivesAllCompletedData { completedObjectives: ObjectiveState[]; totalTime: number; @@ -175,6 +179,66 @@ export interface QuizPassedData { pointsDeducted: number; } +// ── Decision events (for decision conditions, phase 18) ────────────────────── + +/** One evidence latch the decision depends on, as shown to the player */ +export interface DecisionEvidenceStatus { + id: string; + label: string; + ready: boolean; +} + +export interface DecisionShowData { + objectiveId: string; + conditionIndex: number; + prompt: string; + /** Option labels only - rules and consequences never reach the UI */ + options: string[]; + explanation?: string; + pointPenalty: number; + character?: Character; + preserveOptionOrder?: boolean; + evidence: DecisionEvidenceStatus[]; +} + +/** Modal -> manager: the player chose an option */ +export interface DecisionAnsweredData { + objectiveId: string; + conditionIndex: number; + optionIndex: number; +} + +/** Manager -> modal: how the chosen option graded (and whether timers should pause) */ +export interface DecisionGradedData { + objectiveId: string; + conditionIndex: number; + optionIndex: number; + correct: boolean; + evidenced: boolean; + missingEvidence: string[]; + feedback?: string; + attempts: number; + pointsDeducted: number; +} + +/** Modal -> everyone: Continue pressed after a correct answer */ +export interface DecisionResolvedData { + objectiveId: string; + conditionIndex: number; + totalAttempts: number; + totalPointsDeducted: number; +} + +export interface DecisionDismissedData { + objectiveId: string; + conditionIndex: number; +} + +export interface DecisionPendingData { + objectiveId: string; + conditionIndex: number; +} + // Hint Event specific interfaces export interface HintRequestedData { objectiveId: string; @@ -241,6 +305,10 @@ export interface WeatherEventData { startTime: number; duration: number; linkMarginDegradation: number; + /** rain/storm: peak rain rate in mm/h (default from severity: 4 / 12 / 30) */ + rainRateMmPerHour?: number; + /** When set, startTime counts from this objective's activation instead of mission start */ + startAfterObjectiveId?: string; } export interface WeatherMissionFailureData { @@ -288,6 +356,15 @@ export interface HpaNoiseAmplificationData { detectedAt: number; } +export interface ProtectedFreqViolationData { + groundStationId: string; + /** Human-readable protected band that was jammed */ + protectedBandLabel: string; + /** Jam waveform center frequency that overlapped the protected band (Hz) */ + jamFrequencyHz: number; + detectedAt: number; +} + // Simulated Time Event specific interfaces export interface SimulatedTimeTickData { /** Military format datetime string, e.g., "15 MAR 2025 22:05:15" */ @@ -296,6 +373,33 @@ export interface SimulatedTimeTickData { timestampMs: number; } +export interface TimeSkipStartedData { + /** Satellite whose AOS the skip is aiming at */ + satelliteName: string; + /** Scenario-clock timestamp the skip stops at, Unix ms */ + targetMs: number; + /** Total scenario time the skip will cover, ms */ + totalMs: number; +} + +export interface TimeSkipProgressData { + /** Fraction of the skip completed, 0..1 */ + progress: number; + /** Scenario-clock time reached so far, Unix ms */ + simNowMs: number; + /** Scenario time skipped so far, ms */ + skippedMs: number; + /** Total scenario time the skip covers, ms */ + totalMs: number; +} + +export interface TimeSkipEndedData { + /** False when the skip aborted early (scenario paused, objective failed) */ + isCompleted: boolean; + /** Scenario time actually skipped, ms */ + skippedMs: number; +} + export enum Events { // Antenna events ANTENNA_STATE_CHANGED = 'antenna:state:changed', @@ -312,7 +416,7 @@ export enum Events { // Receiver events RX_CONFIG_CHANGED = 'rx:config:changed', - RX_ACTIVE_MODEM_CHANGED = "rx:activeModem:changed", + RX_ACTIVE_MODEM_CHANGED = 'rx:activeModem:changed', RX_SIGNAL_FOUND = 'rx:signal:found', RX_SIGNAL_LOST = 'rx:signal:lost', @@ -321,31 +425,37 @@ export enum Events { // Router events ROUTE_CHANGED = 'route:changed', - STORAGE_ERROR = "STORAGE_ERROR", + STORAGE_ERROR = 'STORAGE_ERROR', + + // Plugin events + /** Every enabled plugin has registered (or failed); fired once per boot */ + PLUGINS_READY = 'plugins:ready', // Game loop events - DOM_READY = "app:dom-ready", + DOM_READY = 'app:dom-ready', /** This event is called every simulation update tick */ - UPDATE = "app:update", + UPDATE = 'app:update', /** This event is used for canvas rendering */ - DRAW = "app:draw", - SYNC = "app:sync", - RF_FE_POWER_CHANGED = "rf-fe:power:changed", - RF_FE_BUC_CHANGED = "rf-fe:buc:changed", - RF_FE_HPA_CHANGED = "rf-fe:hpa:changed", - RF_FE_AGC_CHANGED = "rf-fe:agc:changed", - RF_FE_LNB_CHANGED = "rf-fe:lnb:changed", - RF_FE_ALARM = "rf-fe:alarm", - RF_FE_OMT_CHANGED = "rf-fe:omt:changed", - RF_FE_COUPLER_CHANGED = "rf-fe:coupler:changed", - RF_FE_FILTER_CHANGED = "rf-fe:filter:changed", - RF_FE_NOTCH_FILTER_CHANGED = "rf-fe:notch-filter:changed", - RF_FE_GPSDO_CHANGED = "rf-fe:gpsdo:changed", + DRAW = 'app:draw', + SYNC = 'app:sync', + RF_FE_POWER_CHANGED = 'rf-fe:power:changed', + RF_FE_BUC_CHANGED = 'rf-fe:buc:changed', + RF_FE_HPA_CHANGED = 'rf-fe:hpa:changed', + RF_FE_AGC_CHANGED = 'rf-fe:agc:changed', + RF_FE_LNB_CHANGED = 'rf-fe:lnb:changed', + RF_FE_ALARM = 'rf-fe:alarm', + RF_FE_OMT_CHANGED = 'rf-fe:omt:changed', + RF_FE_COUPLER_CHANGED = 'rf-fe:coupler:changed', + RF_FE_FILTER_CHANGED = 'rf-fe:filter:changed', + RF_FE_NOTCH_FILTER_CHANGED = 'rf-fe:notch-filter:changed', + RF_FE_GPSDO_CHANGED = 'rf-fe:gpsdo:changed', // Objectives events OBJECTIVE_ACTIVATED = 'objective:activated', OBJECTIVE_COMPLETED = 'objective:completed', OBJECTIVE_CONDITION_CHANGED = 'objective:condition:changed', + /** Dev/authoring telemetry: one event per condition evaluation while enabled (see ObjectivesManager.enableConditionTelemetry). */ + OBJECTIVE_CONDITION_EVALUATED = 'objective:condition:evaluated', OBJECTIVES_ALL_COMPLETED = 'objectives:all:completed', OBJECTIVE_FAILED = 'objective:failed', SCENARIO_TIME_EXPIRED = 'scenario:time:expired', @@ -360,6 +470,14 @@ export enum Events { QUIZ_PENDING = 'quiz:pending', QUIZ_PASSED = 'quiz:passed', + // Decision events (for decision conditions) + DECISION_SHOW = 'decision:show', + DECISION_ANSWERED = 'decision:answered', + DECISION_GRADED = 'decision:graded', + DECISION_RESOLVED = 'decision:resolved', + DECISION_DISMISSED = 'decision:dismissed', + DECISION_PENDING = 'decision:pending', + // Hint events (for condition hints with 50% point penalty) HINT_REQUESTED = 'hint:requested', HINT_SHOWN = 'hint:shown', @@ -394,6 +512,7 @@ export enum Events { // RF Safety violations HPA_NOISE_AMPLIFICATION = 'rf:hpa-noise-amplification', + PROTECTED_FREQ_VIOLATION = 'rf:protected-freq-violation', // Ops Log events OPS_LOG_ENTRY_ADDED = 'ops-log:entry:added', @@ -401,6 +520,11 @@ export enum Events { // Simulated Time events SIMULATED_TIME_TICK = 'simulated-time:tick', + // Operator time-skip (fast-forward to the next contact) + TIME_SKIP_STARTED = 'time-skip:started', + TIME_SKIP_PROGRESS = 'time-skip:progress', + TIME_SKIP_ENDED = 'time-skip:ended', + // Scenario lifecycle events SCENARIO_CHANGED = 'scenario:changed', @@ -418,7 +542,7 @@ export interface EventMap { [Events.ANTENNA_STATE_CHANGED]: [Partial]; [Events.GROUND_STATION_STATE_CHANGED]: [Partial]; - [Events.ASSET_SELECTED]: [{ type: 'ground-station' | 'satellite', id: string }]; + [Events.ASSET_SELECTED]: [{ type: 'ground-station' | 'satellite'; id: string }]; [Events.RF_FE_POWER_CHANGED]: [Partial]; [Events.RF_FE_BUC_CHANGED]: [Partial]; @@ -430,10 +554,12 @@ export interface EventMap { [Events.RF_FE_FILTER_CHANGED]: [Partial]; [Events.RF_FE_NOTCH_FILTER_CHANGED]: [Partial]; [Events.RF_FE_GPSDO_CHANGED]: [Partial]; - [Events.RF_FE_ALARM]: [{ - unit: number; - alarms: string[]; - }]; + [Events.RF_FE_ALARM]: [ + { + unit: number; + alarms: string[]; + }, + ]; [Events.TX_CONFIG_CHANGED]: [TxConfigChangedData]; [Events.TX_ACTIVE_MODEM_CHANGED]: [TxActiveModemChangedData]; @@ -451,6 +577,8 @@ export interface EventMap { [Events.STORAGE_ERROR]: [Error]; + [Events.PLUGINS_READY]: [{ loaded: string[]; failed: string[] }]; + [Events.DOM_READY]: []; [Events.UPDATE]: [Milliseconds]; [Events.DRAW]: [Milliseconds]; @@ -459,6 +587,7 @@ export interface EventMap { [Events.OBJECTIVE_ACTIVATED]: [ObjectiveActivatedData]; [Events.OBJECTIVE_COMPLETED]: [ObjectiveCompletedData]; [Events.OBJECTIVE_CONDITION_CHANGED]: [ObjectiveConditionChangedData]; + [Events.OBJECTIVE_CONDITION_EVALUATED]: [ObjectiveConditionEvaluatedData]; [Events.OBJECTIVES_ALL_COMPLETED]: [ObjectivesAllCompletedData]; [Events.OBJECTIVE_FAILED]: [ObjectiveFailedData]; [Events.SCENARIO_TIME_EXPIRED]: [ScenarioTimeExpiredData]; @@ -472,6 +601,13 @@ export interface EventMap { [Events.QUIZ_PENDING]: [QuizPendingData]; [Events.QUIZ_PASSED]: [QuizPassedData]; + [Events.DECISION_SHOW]: [DecisionShowData]; + [Events.DECISION_ANSWERED]: [DecisionAnsweredData]; + [Events.DECISION_GRADED]: [DecisionGradedData]; + [Events.DECISION_RESOLVED]: [DecisionResolvedData]; + [Events.DECISION_DISMISSED]: [DecisionDismissedData]; + [Events.DECISION_PENDING]: [DecisionPendingData]; + [Events.HINT_REQUESTED]: [HintRequestedData]; [Events.HINT_SHOWN]: [HintShownData]; @@ -502,6 +638,7 @@ export interface EventMap { // RF Safety violations [Events.HPA_NOISE_AMPLIFICATION]: [HpaNoiseAmplificationData]; + [Events.PROTECTED_FREQ_VIOLATION]: [ProtectedFreqViolationData]; // Ops Log events [Events.OPS_LOG_ENTRY_ADDED]: [OpsLogEntry]; @@ -509,6 +646,11 @@ export interface EventMap { // Simulated Time events [Events.SIMULATED_TIME_TICK]: [SimulatedTimeTickData]; + // Operator time-skip + [Events.TIME_SKIP_STARTED]: [TimeSkipStartedData]; + [Events.TIME_SKIP_PROGRESS]: [TimeSkipProgressData]; + [Events.TIME_SKIP_ENDED]: [TimeSkipEndedData]; + // Scenario lifecycle events [Events.SCENARIO_CHANGED]: [{ scenarioId: string }]; @@ -520,4 +662,4 @@ export interface EventMap { // Fault injection events [Events.FAULT_CHANGED]: [{ id: string; action: 'injected' | 'cleared' }]; -} \ No newline at end of file +} diff --git a/src/faults/fault-injector.ts b/src/faults/fault-injector.ts index cfb70271..cd384e7c 100644 --- a/src/faults/fault-injector.ts +++ b/src/faults/fault-injector.ts @@ -1,3 +1,4 @@ +import { SimClock } from '@app/simulation/sim-clock'; /** * @file Fault Injector Service * @description Centralized fault injection service for training scenarios. @@ -10,13 +11,7 @@ import { EventBus } from '@app/events/event-bus'; import { Events } from '@app/events/events'; import { RxPayloadState } from '@app/pages/mission-control/tabs/rx-payload-adapter'; import { TxPayloadState } from '@app/pages/mission-control/tabs/tx-payload-adapter'; -import { - FaultDefinition, - FaultInput, - FaultTarget, - FAULT_TEMPLATES, - FaultTemplateKey, -} from './fault-types'; +import { FAULT_TEMPLATES, FaultDefinition, FaultInput, FaultTarget, FaultTemplateKey } from './fault-types'; /** * FaultInjector - Centralized fault injection service @@ -90,11 +85,7 @@ export class FaultInjector { * @param overrides Optional state overrides to merge with template * @returns Generated fault ID */ - injectTemplate( - templateKey: FaultTemplateKey, - groundStationId: string, - overrides?: Partial | Partial - ): string { + injectTemplate(templateKey: FaultTemplateKey, groundStationId: string, overrides?: Partial | Partial): string { const template = FAULT_TEMPLATES[templateKey]; const id = `${templateKey}-${++this.faultCounter_}`; @@ -132,12 +123,12 @@ export class FaultInjector { toDelete.push(id); } }); - toDelete.forEach(id => this.clear(id)); + toDelete.forEach((id) => this.clear(id)); } else { // Clear all faults const ids = Array.from(this.activeFaults_.keys()); this.activeFaults_.clear(); - ids.forEach(id => this.emitFaultChanged_(id, 'cleared')); + ids.forEach((id) => this.emitFaultChanged_(id, 'cleared')); } } @@ -153,7 +144,7 @@ export class FaultInjector { } } }); - toDelete.forEach(id => this.clear(id)); + toDelete.forEach((id) => this.clear(id)); } /** @@ -164,7 +155,7 @@ export class FaultInjector { if (!fault) return false; // Check expiration - if (fault.expiresAt && Date.now() > fault.expiresAt) { + if (fault.expiresAt && SimClock.runMs() > fault.expiresAt) { this.clear(id); return false; } @@ -179,7 +170,7 @@ export class FaultInjector { this.cleanupExpired_(); const faults: FaultDefinition[] = []; - this.activeFaults_.forEach(fault => { + this.activeFaults_.forEach((fault) => { if (groundStationId && fault.groundStationId !== groundStationId) return; if (target && fault.target !== target) return; faults.push({ ...fault }); @@ -195,10 +186,7 @@ export class FaultInjector { * Merges all active faults for the target, respecting priority order. * Higher priority faults override lower priority for conflicting keys. */ - getComputedState( - target: FaultTarget, - groundStationId: string - ): Partial | Partial { + getComputedState(target: FaultTarget, groundStationId: string): Partial | Partial { const faults = this.getActiveFaults(groundStationId, target); // Merge faults in reverse priority order (lowest first) @@ -246,7 +234,7 @@ export class FaultInjector { * Clean up expired faults */ private cleanupExpired_(): void { - const now = Date.now(); + const now = SimClock.runMs(); const toDelete: string[] = []; this.activeFaults_.forEach((fault, id) => { @@ -255,7 +243,7 @@ export class FaultInjector { } }); - toDelete.forEach(id => this.clear(id)); + toDelete.forEach((id) => this.clear(id)); } /** diff --git a/src/faults/hardware-fault-manager.ts b/src/faults/hardware-fault-manager.ts new file mode 100644 index 00000000..40e9b9bd --- /dev/null +++ b/src/faults/hardware-fault-manager.ts @@ -0,0 +1,198 @@ +/** + * @file HardwareFaultManager - Scheduled equipment faults from scenario data + * @description Reads settings.hardwareFaultEvents and, at each event's + * mission-clock time, trips the named piece of equipment on the named station. + * + * Targets: + * - `tx-modem` (default, Campaign 4 origin): the transmit modem faults and + * stops radiating, so a jam drops and the operator fails over to a backup + * string. Faulting a modem is enough to remove it from the RF chain - the BUC + * only pulls modems that are transmitting AND not faulted. + * - `buc-overtemp` (phase 16, E3): the BUC's cooling degrades by `params.deltaC` + * (default 40 degC) so it climbs past the 70 degC alarm while driven; muting + * brings it back under. `params.startTemperatureC` jumps the reading at once + * for a mid-window trip; `duration` ends the excursion. + * - `gpsdo-gnss-loss` (E3): the GNSS signal drops with the switch still up - the + * receiver enters holdover on its oscillator. `duration` is when the signal + * returns and the reference re-locks. + * - `crypto-key-mismatch` (E3): the crypto module's key goes to Mismatch + * (decrypt and auth-tag fail) until the operator re-keys. The crypto module is + * one per app, so `groundStationId` is informational for this target. + * + * Faults fire once. Started only when settings.hardwareFaultEvents is + * non-empty, so legacy campaigns never instantiate it. + */ + +import { CryptoModule } from '@app/equipment/crypto'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { OpsLogManager } from '@app/ops-log/ops-log-manager'; +import { ScenarioManager } from '@app/scenario-manager'; +import { missionNowMs } from '@app/simulation/mission-clock'; +import { SimulationManager } from '@app/simulation/simulation-manager'; + +export type HardwareFaultTarget = 'tx-modem' | 'buc-overtemp' | 'gpsdo-gnss-loss' | 'crypto-key-mismatch'; + +export interface HardwareFaultEventConfig { + id: string; + groundStationId: string; + /** What trips. Default: 'tx-modem' */ + target?: HardwareFaultTarget; + /** Transmitter case index (default 0), tx-modem only */ + transmitterIndex?: number; + /** Modem number (1-4) that faults, tx-modem only */ + modemNumber?: number; + /** RF front-end index (default 0), buc-overtemp / gpsdo-gnss-loss */ + rfFrontEndIndex?: number; + /** Seconds since mission start when the fault trips */ + startTime: number; + /** Seconds the fault lasts before it clears itself; absent = until the operator acts */ + duration?: number; + params?: { + /** buc-overtemp: extra degC the BUC settles above its normal target. Default 40 */ + deltaC?: number; + /** buc-overtemp: set the temperature reading to this at trip time */ + startTemperatureC?: number; + }; + /** Written to the ops log when the fault trips (omit to keep the fault silent) */ + label?: string; +} + +const DEFAULT_OVERTEMP_DELTA_C = 40; + +export class HardwareFaultManager { + private static instance_: HardwareFaultManager | null = null; + + private readonly events_: HardwareFaultEventConfig[]; + private readonly missionStartTime_: number; + /** Event ids whose fault has already been tripped (fire once) */ + private readonly trippedIds_ = new Set(); + /** Event ids whose timed fault has already cleared */ + private readonly clearedIds_ = new Set(); + private readonly boundUpdateHandler_: () => void; + + private constructor() { + this.missionStartTime_ = missionNowMs(); + this.boundUpdateHandler_ = this.update_.bind(this); + this.events_ = (ScenarioManager.getInstance().settings.hardwareFaultEvents as HardwareFaultEventConfig[] | undefined) ?? []; + EventBus.getInstance().on(Events.UPDATE, this.boundUpdateHandler_); + } + + static getInstance(): HardwareFaultManager { + this.instance_ ??= new HardwareFaultManager(); + + return this.instance_; + } + + static isInitialized(): boolean { + return this.instance_ !== null; + } + + static destroy(): void { + if (this.instance_) { + EventBus.getInstance().off(Events.UPDATE, this.instance_.boundUpdateHandler_); + this.instance_ = null; + } + } + + /** Whether the given event's fault has tripped */ + isTripped(eventId: string): boolean { + return this.trippedIds_.has(eventId); + } + + /** Whether the given event tripped and has since cleared (timed faults only) */ + isCleared(eventId: string): boolean { + return this.clearedIds_.has(eventId); + } + + private update_(): void { + if (this.events_.length === 0) { + return; + } + const elapsed = (missionNowMs() - this.missionStartTime_) / 1000; + + for (const event of this.events_) { + if (!this.trippedIds_.has(event.id)) { + if (elapsed >= event.startTime && this.trip_(event)) { + this.trippedIds_.add(event.id); + if (event.label && OpsLogManager.isInitialized()) { + OpsLogManager.getInstance().log(event.label, 'alert', event.groundStationId); + } + } + continue; + } + + if (event.duration !== undefined && !this.clearedIds_.has(event.id) && elapsed >= event.startTime + event.duration) { + this.clear_(event); + this.clearedIds_.add(event.id); + } + } + } + + /** Apply the fault. Returns false when the target equipment does not exist yet. */ + private trip_(event: HardwareFaultEventConfig): boolean { + const sim = SimulationManager.getInstance(); + const gs = sim.groundStations.find((g) => g.state.id === event.groundStationId); + + switch (event.target ?? 'tx-modem') { + case 'tx-modem': { + const tx = gs?.transmitters[event.transmitterIndex ?? 0]; + const modem = tx?.state.modems.find((m) => m.modem_number === event.modemNumber); + if (!modem) { + return false; + } + // Trip the primary string: fault it and drop transmission so the jam stops + modem.isFaulted = true; + modem.isTransmitting = false; + modem.isTransmittingSwitchUp = false; + return true; + } + + case 'buc-overtemp': { + const buc = gs?.rfFrontEnds[event.rfFrontEndIndex ?? 0]?.bucModule; + if (!buc) { + return false; + } + buc.setThermalOffset(event.params?.deltaC ?? DEFAULT_OVERTEMP_DELTA_C); + if (event.params?.startTemperatureC !== undefined) { + buc.state.temperature = event.params.startTemperatureC; + } + return true; + } + + case 'gpsdo-gnss-loss': { + const gpsdo = gs?.rfFrontEnds[event.rfFrontEndIndex ?? 0]?.gpsdoModule; + if (!gpsdo) { + return false; + } + gpsdo.setGnssSignalPresent(false); + return true; + } + + case 'crypto-key-mismatch': { + CryptoModule.getInstance().injectKeyMismatch(); + return true; + } + + default: + return false; + } + } + + /** End a timed fault. tx-modem and crypto faults are cleared by the operator, not by time. */ + private clear_(event: HardwareFaultEventConfig): void { + const gs = SimulationManager.getInstance().groundStations.find((g) => g.state.id === event.groundStationId); + const rfFrontEnd = gs?.rfFrontEnds[event.rfFrontEndIndex ?? 0]; + + switch (event.target ?? 'tx-modem') { + case 'buc-overtemp': + rfFrontEnd?.bucModule.setThermalOffset(0); + break; + case 'gpsdo-gnss-loss': + rfFrontEnd?.gpsdoModule.setGnssSignalPresent(true); + break; + default: + break; + } + } +} diff --git a/src/fonts.css b/src/fonts.css new file mode 100644 index 00000000..badf71c4 --- /dev/null +++ b/src/fonts.css @@ -0,0 +1,126 @@ +/** + * Self-hosted web fonts. + * + * These used to come from fonts.googleapis.com via three tags in + * public/index.html. They are served from /fonts/ now: no third-party request + * at runtime, and the tactical chrome variant can rely on Roboto Condensed + * being present rather than hoping the OS has a condensed face. + * + * Files live in public/fonts/ and are copied to /fonts/ by CopyWebpackPlugin, + * so the URLs below are absolute and stable. They are deliberately NOT imported + * through webpack's asset/resource rule, which would fingerprint the names. + * + * Latin + latin-ext subsets only, matching what the Google request served for + * this UI. Both families are Apache-2.0; the license texts ship alongside the + * files in public/fonts/. + * + * Generated - see the plan for phase 9. Re-generate rather than hand-editing. + */ + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('/fonts/roboto-latin-300-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url('/fonts/roboto-latin-ext-300-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/roboto-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/roboto-latin-ext-400-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/roboto-latin-500-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: url('/fonts/roboto-latin-ext-500-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/roboto-latin-700-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: 'Roboto'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/roboto-latin-ext-700-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto Condensed'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/roboto-condensed-latin-400-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: 'Roboto Condensed'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/roboto-condensed-latin-ext-400-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} + +@font-face { + font-family: 'Roboto Condensed'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/roboto-condensed-latin-700-normal.woff2') format('woff2'); + unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; +} + +@font-face { + font-family: 'Roboto Condensed'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: url('/fonts/roboto-condensed-latin-ext-700-normal.woff2') format('woff2'); + unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; +} diff --git a/src/gnss-threat/gnss-threat-manager.ts b/src/gnss-threat/gnss-threat-manager.ts new file mode 100644 index 00000000..8f5557b7 --- /dev/null +++ b/src/gnss-threat/gnss-threat-manager.ts @@ -0,0 +1,153 @@ +/** + * @file GnssThreatManager - GNSS spoofing / timing attack (nats-eu M8) + * @description Models a GNSS spoof against the station timing reference. The + * diagnostic tell is that the GNSS timing solution walks off (a growing time + * offset) while the satellite count stays healthy - unlike a normal outage where + * satellites drop. The operator must recognize the signature and stop trusting + * GNSS by forcing the GPSDO to holdover (or a manual reference), riding passes on + * the disciplined oscillator until the all-clear. The gpsdo-reference-mode-set + * condition reads the selected reference mode; recovery reuses the existing + * gpsdo-* conditions. + * + * Started only when settings.gnssThreat is present. Subscribes to Events.UPDATE + * to drive the spoof window + offset drift on the mission clock; tests can call + * setSpoofActive()/advance() for determinism. + */ + +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { ScenarioManager } from '@app/scenario-manager'; +import { missionNowMs } from '@app/simulation/mission-clock'; +import type { Milliseconds } from 'ootk'; + +export type GpsdoReferenceMode = 'gnss' | 'holdover' | 'manual'; + +/** settings.gnssThreat */ +export interface GnssThreatConfig { + /** Ground stations whose timing reference is targeted (for display) */ + groundStationIds?: string[]; + /** Elapsed second the spoof begins */ + spoofStartS: number; + /** Elapsed second the spoof ends (omit = runs to scenario end) */ + spoofEndS?: number; + /** Timing-offset drift rate while spoofed, microseconds per second (default 5) */ + offsetDriftUsPerS?: number; +} + +interface GnssThreatState { + spoofActive: boolean; + /** Accumulated timing offset, microseconds (the tell that GNSS is being spoofed) */ + timeOffsetUs: number; + referenceMode: GpsdoReferenceMode; +} + +export class GnssThreatManager { + private static instance_: GnssThreatManager | null = null; + + private readonly config_: GnssThreatConfig; + private readonly state_: GnssThreatState = { spoofActive: false, timeOffsetUs: 0, referenceMode: 'gnss' }; + private readonly missionStartTime_ = missionNowMs(); + private lastElapsedS_ = 0; + /** Elapsed second the timing offset last moved (for gpsdo-time-offset-stable) */ + private lastOffsetChangeElapsedS_ = 0; + private readonly boundUpdateHandler_: (dt: Milliseconds) => void; + + private constructor() { + this.config_ = (ScenarioManager.getInstance().settings.gnssThreat as GnssThreatConfig | undefined) ?? { spoofStartS: 0 }; + this.boundUpdateHandler_ = this.update_.bind(this); + EventBus.getInstance().on(Events.UPDATE, this.boundUpdateHandler_); + } + + static getInstance(): GnssThreatManager { + this.instance_ ??= new GnssThreatManager(); + + return this.instance_; + } + + static isInitialized(): boolean { + return this.instance_ !== null; + } + + static destroy(): void { + if (this.instance_) { + EventBus.getInstance().off(Events.UPDATE, this.instance_.boundUpdateHandler_); + this.instance_ = null; + } + } + + get state(): Readonly { + return this.state_; + } + + getConfig(): GnssThreatConfig { + return this.config_; + } + + /** + * Whether a station's timing reference is inside the spoofer's footprint. + * A spoofer is a local emitter: with groundStationIds set, only those + * stations see the offset walk; a station outside the list reads 0.0 and is + * the operator's cross-check. Omit the list and every station is targeted. + */ + affectsStation(groundStationId: string): boolean { + const ids = this.config_.groundStationIds; + return !ids || ids.length === 0 || ids.includes(groundStationId); + } + + /** The timing offset a station's GPSDO displays (0 outside the footprint). */ + timeOffsetUsFor(groundStationId: string): number { + return this.affectsStation(groundStationId) ? this.state_.timeOffsetUs : 0; + } + + /** Seconds since the timing offset last moved (mission time). */ + get secondsSinceOffsetChange(): number { + return Math.max(0, this.lastElapsedS_ - this.lastOffsetChangeElapsedS_); + } + + /** + * Whether the timing reference is currently being spoofed AND the operator has + * not defended against it (still trusting GNSS). This is the "exposed" state a + * scenario penalizes; once the operator forces holdover/manual it clears. + */ + get isExposedToSpoof(): boolean { + return this.state_.spoofActive && this.state_.referenceMode === 'gnss'; + } + + /** Select the GPSDO reference/discipline mode (the defensive action). */ + setReferenceMode(mode: GpsdoReferenceMode): void { + this.state_.referenceMode = mode; + } + + isReferenceModeSet(mode: GpsdoReferenceMode): boolean { + return this.state_.referenceMode === mode; + } + + /** Force the spoof state (used by the tick and by tests). */ + setSpoofActive(active: boolean): void { + this.state_.spoofActive = active; + } + + /** + * Advance the timing-offset drift by a number of simulated seconds while + * spoofed and still trusting GNSS. Exposed for deterministic tests; the tick + * calls it from the mission clock in-app. + */ + advance(seconds: number): void { + // Tests drive advance() directly, so keep the elapsed clock consistent here too. + this.lastElapsedS_ += seconds; + if (this.isExposedToSpoof && seconds > 0) { + this.state_.timeOffsetUs += (this.config_.offsetDriftUsPerS ?? 5) * seconds; + this.lastOffsetChangeElapsedS_ = this.lastElapsedS_; + } + } + + private update_(): void { + const elapsed = (missionNowMs() - this.missionStartTime_) / 1000; + const inWindow = elapsed >= this.config_.spoofStartS && (this.config_.spoofEndS === undefined || elapsed < this.config_.spoofEndS); + this.state_.spoofActive = inWindow; + + const deltaS = Math.max(0, elapsed - this.lastElapsedS_); + this.lastElapsedS_ = elapsed - deltaS; // advance() re-adds it + this.advance(deltaS); + } +} diff --git a/src/index.css b/src/index.css index f49c2fca..f84fb1d3 100644 --- a/src/index.css +++ b/src/index.css @@ -7,29 +7,33 @@ body { } :root { - /* Legacy variables - kept for backward compatibility */ - --colorTertiary: #4a4a4a !important; - --color-dark-background: #1f1f1f !important; - --color-dark-border: #3a3a3a !important; - --color-dark-text-accent: #dd382f !important; - --color-dark-text-muted: #6b6b6b !important; + /* Legacy variables - kept for backward compatibility. + NOTE: this file loads AFTER tabler-overrides.css, so these declarations + win the cascade. They must reference the --mc-* theme variables (with the + historical literals as fallbacks) or per-campaign themes cannot re-skin + legacy consumers. */ + --colorTertiary: var(--mc-surface-3, #4a4a4a) !important; + --color-dark-background: var(--mc-surface-0, #1f1f1f) !important; + --color-dark-border: var(--mc-border, #3a3a3a) !important; + --color-dark-text-accent: var(--mc-accent-text-dark, #dd382f) !important; + --color-dark-text-muted: var(--mc-text-muted, #6b6b6b) !important; /* International Orange Engineering - Primary Colors */ - --color-primary: #ba160c !important; - --color-text-accent: #ff2827; - --color-primary-light: #e01a0e !important; - --color-primary-dark: #860f08 !important; + --color-primary: var(--mc-accent-red, #ba160c) !important; + --color-text-accent: var(--mc-accent-red-bright, #ff2827); + --color-primary-light: var(--mc-accent-red-light, #e01a0e) !important; + --color-primary-dark: var(--mc-accent-red-dark, #860f08) !important; /* Surface Colors - Dark Theme Layers */ - --color-surface-0: #1f1f1f; + --color-surface-0: var(--mc-surface-0, #1f1f1f); /* Darkest - main background */ - --color-surface-1: #292929; + --color-surface-1: var(--mc-surface-1, #292929); /* Panel background */ - --color-surface-2: #3a3a3a; + --color-surface-2: var(--mc-surface-2, #3a3a3a); /* Hover/active states */ - --color-surface-3: #4a4a4a; + --color-surface-3: var(--mc-surface-3, #4a4a4a); /* Scrollbar thumb */ - --color-surface-4: #5a5a5a; + --color-surface-4: var(--mc-surface-4, #5a5a5a); /* Scrollbar thumb hover */ /* Text Colors */ @@ -41,7 +45,7 @@ body { /* Muted text */ /* Border Colors */ - --color-border: #3a3a3a; + --color-border: var(--mc-border, #3a3a3a); /* Status Colors */ --color-success: #22c55e; diff --git a/src/index.ts b/src/index.ts index f745bd10..1671a770 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,17 @@ +import { Analytics } from './analytics'; import { App } from './app'; +// Ahead of everything else: the @font-face declarations must exist before any +// rule asks for the family. +import './fonts.css'; import '@tabler/core/dist/css/tabler.min.css'; import './tabler-overrides.css'; import './index.css'; // Initialize the application document.addEventListener('DOMContentLoaded', () => { + // Before App.create() so the ROUTE_CHANGED subscription catches the initial + // route emitted during Router.init(). Opt-in: gtag.js loads only after the + // visitor allows it on the consent banner (see src/analytics.ts). + Analytics.init(); App.create(); -}); \ No newline at end of file +}); diff --git a/src/interference/interference-manager.ts b/src/interference/interference-manager.ts index ecc98bf6..86f04dd4 100644 --- a/src/interference/interference-manager.ts +++ b/src/interference/interference-manager.ts @@ -1,11 +1,12 @@ /** * @file InterferenceManager - Scheduled, time-windowed RF interference - * @description Injects interference signals into a satellite's uplink path on - * a configurable duty cycle. Because injection happens at the satellite - * (externalSignal), the transponder relays the interferer to EVERY receiving - * station - modeling uplink interference/jamming, which is the - * discrimination-relevant case (local terrestrial interference would appear - * at one station only). + * @description Two delivery paths, selected per event by `path`: + * - 'transponder' (default): injects at the satellite's uplink + * (externalSignal), so the transponder relays the interferer to EVERY + * receiving station - uplink interference/jamming. + * - 'terrestrial' (Campaign 3+): a ground-based emitter received directly by + * station antennas (bearing + off-axis pattern, no Doppler) - local RFI, + * fake beacons, GPS spoofers. Appears only at stations that can hear it. * * Scenarios configure via `settings.interferenceEvents`. The windowed on/off * pattern is the training signal: deliberate interference has a duty cycle; @@ -16,22 +17,43 @@ import { EventBus } from '@app/events/event-bus'; import { Events } from '@app/events/events'; import { ScenarioManager } from '@app/scenario-manager'; import { SignalOrigin } from '@app/signal-origin'; +import { missionNowMs } from '@app/simulation/mission-clock'; import { SimulationManager } from '@app/simulation/simulation-manager'; import type { dBi, dBm, FECType, Hertz, ModulationType, RfFrequency, RfSignal } from '@app/types'; import type { Milliseconds } from 'ootk'; +/** + * Ground-truth location of a terrestrial interference source (Campaign 5+). + * Never rendered to the player directly - it drives geolocation measurement + * synthesis and objective grading only. + */ +export interface EmitterGroundTruth { + /** WGS-84 latitude, degrees */ + latitude: number; + /** WGS-84 longitude, degrees */ + longitude: number; + /** Altitude above the WGS-84 ellipsoid, km. Default: 0 */ + altitudeKm?: number; +} + export interface InterferenceEventConfig { id: string; - /** NORAD ID of the satellite whose transponder relays the interferer */ - satelliteNoradId: number; + /** + * NORAD ID of the satellite whose transponder relays the interferer. + * Required for the (default) transponder path; ignored for terrestrial. + */ + satelliteNoradId?: number; /** Interferer RF center frequency (uplink, Hz) */ frequency: number; /** Interferer bandwidth (Hz) */ bandwidth: number; - /** Interferer power at the transponder input (dBm) */ + /** + * Interferer power (dBm). Transponder path: power at the transponder + * input. Terrestrial path: the emitter's EIRP. + */ power: number; - /** Uplink polarization - must match the victim transponder to route */ - polarization: 'H' | 'V'; + /** Polarization. Transponder path must match the victim transponder to route */ + polarization: 'H' | 'V' | 'RHCP' | 'LHCP'; /** Seconds since mission start when the event envelope opens */ startTime: number; /** Total envelope duration (s); on/off windows repeat inside it */ @@ -40,6 +62,34 @@ export interface InterferenceEventConfig { periodSeconds: number; /** Transmit-on time per period (s) */ onSeconds: number; + /** + * Opt-in (Campaign 3+): how the interferer reaches the player. + * - 'transponder' (default, and the behavior when absent): injected at the + * satellite's uplink and relayed to every receiving station. + * - 'terrestrial': a ground-based emitter received DIRECTLY by station + * antennas via great-circle bearing and the antenna's off-axis pattern. + * No Doppler is applied - that absence is a diagnostic tell (a "satellite" + * signal that never drifts is transmitting from the ground). Requires + * `emitter`; `satelliteNoradId` is ignored. + */ + path?: 'transponder' | 'terrestrial'; + /** + * Opt-in (Campaign 5+): where on Earth the interferer transmits from. + * For 'transponder' events it only drives geolocation observables; for + * 'terrestrial' events it is REQUIRED - it is the physical signal source. + */ + emitter?: EmitterGroundTruth; +} + +/** A terrestrial event currently on the air (consumed by AntennaCore) */ +export interface ActiveTerrestrialEmission { + signalId: string; + frequencyHz: number; + bandwidthHz: number; + polarization: 'H' | 'V' | 'RHCP' | 'LHCP'; + /** Emitter EIRP, dBm */ + eirpDbm: number; + emitter: EmitterGroundTruth; } export class InterferenceManager { @@ -49,10 +99,12 @@ export class InterferenceManager { private missionStartTime_ = 0; /** Currently-injected signalIds (subset of events) */ private readonly activeSignalIds_ = new Set(); + /** Schedule overrides by event id (see forceEvent) */ + private readonly forced_ = new Map(); private readonly boundUpdateHandler_: (dt: Milliseconds) => void; private constructor() { - this.missionStartTime_ = Date.now(); + this.missionStartTime_ = missionNowMs(); this.boundUpdateHandler_ = this.update_.bind(this); this.events_ = ScenarioManager.getInstance().settings.interferenceEvents ?? []; EventBus.getInstance().on(Events.UPDATE, this.boundUpdateHandler_); @@ -63,6 +115,10 @@ export class InterferenceManager { return this.instance_; } + static isInitialized(): boolean { + return this.instance_ !== null; + } + static destroy(): void { if (this.instance_) { EventBus.getInstance().off(Events.UPDATE, this.instance_.boundUpdateHandler_); @@ -75,31 +131,140 @@ export class InterferenceManager { return this.activeSignalIds_.has(InterferenceManager.signalIdFor(eventId)); } + /** Whether any declared interference event is transmitting right now */ + isAnyEventActive(): boolean { + return this.activeSignalIds_.size > 0; + } + + /** + * Whether an event's envelope contains the current mission time - the + * interferer is "in progress" even in the off phase of its duty cycle. A + * forced event counts while forced on. This, not the instantaneous + * radiating state, is what a decision about interference is graded on: a + * jammer that cycles 60 s on / 30 s off is one incident, not thirty. + */ + isEventInEnvelope(eventId: string): boolean { + const event = this.events_.find((e) => e.id === eventId); + if (!event) return false; + const forced = this.forced_.get(event.id); + if (forced !== undefined) return forced; + const elapsed = (missionNowMs() - this.missionStartTime_) / 1000; + return elapsed >= event.startTime && elapsed < event.startTime + event.duration; + } + + /** + * Whether an event's envelope has closed: it opened at some point and the + * mission clock is now past its end. A forced-on event has not ended. + */ + hasEventEnded(eventId: string): boolean { + const event = this.events_.find((e) => e.id === eventId); + if (!event) return false; + if (this.forced_.get(event.id) === true) return false; + const elapsed = (missionNowMs() - this.missionStartTime_) / 1000; + return elapsed >= event.startTime + event.duration; + } + + isAnyEventInEnvelope(): boolean { + return this.events_.some((event) => this.isEventInEnvelope(event.id)); + } + + /** + * Whether any event reaching the player by `path` is in its envelope. The + * transponder / terrestrial split is the uplink-versus-downlink call: a + * carrier relayed by the bird is heard by every station under it and the + * correlator can fix it; one arriving at the dish directly is a problem + * at the receive site and no satellite pair will ever correlate on it. + */ + isAnyEventInEnvelopeVia(path: 'transponder' | 'terrestrial'): boolean { + return this.events_.some((event) => (event.path ?? 'transponder') === path && this.isEventInEnvelope(event.id)); + } + + /** Every event the scenario declares */ + getEvents(): readonly InterferenceEventConfig[] { + return this.events_; + } + + /** + * Override an event's schedule: `true` keeps it radiating, `false` keeps it + * silent, `null` returns it to its scripted duty cycle. Used by decision + * consequences; the next update applies it. + */ + forceEvent(eventId: string, active: boolean | null): void { + if (active === null) { + this.forced_.delete(eventId); + } else { + this.forced_.set(eventId, active); + } + } + + isEventForced(eventId: string): boolean { + return this.forced_.has(eventId); + } + + /** Event config by id (undefined when the scenario doesn't declare it) */ + getEvent(eventId: string): InterferenceEventConfig | undefined { + return this.events_.find((event) => event.id === eventId); + } + + /** All events carrying geolocatable emitter ground truth (Campaign 5+) */ + getEventsWithEmitters(): InterferenceEventConfig[] { + return this.events_.filter((event) => event.emitter !== undefined); + } + + /** + * Terrestrial events currently on the air (Campaign 3+). AntennaCore sums + * these into each station's received spectrum using bearing, distance, and + * its own off-axis pattern - so a directional antenna can DF the emitter. + */ + getActiveTerrestrialEmissions(): ActiveTerrestrialEmission[] { + return this.events_ + .filter((event) => (event.path ?? 'transponder') === 'terrestrial' && event.emitter !== undefined && this.activeSignalIds_.has(InterferenceManager.signalIdFor(event.id))) + .map((event) => ({ + signalId: InterferenceManager.signalIdFor(event.id), + frequencyHz: event.frequency, + bandwidthHz: event.bandwidth, + polarization: event.polarization, + eirpDbm: event.power, + emitter: event.emitter!, + })); + } + static signalIdFor(eventId: string): string { return `INTERFERER-${eventId}`; } private update_(): void { - const elapsed = (Date.now() - this.missionStartTime_) / 1000; + const elapsed = (missionNowMs() - this.missionStartTime_) / 1000; const sim = SimulationManager.getInstance(); for (const event of this.events_) { const signalId = InterferenceManager.signalIdFor(event.id); const inEnvelope = elapsed >= event.startTime && elapsed < event.startTime + event.duration; const phase = (elapsed - event.startTime) % event.periodSeconds; - const shouldTransmit = inEnvelope && phase < event.onSeconds; + const shouldTransmit = this.forced_.get(event.id) ?? (inEnvelope && phase < event.onSeconds); const isInjected = this.activeSignalIds_.has(signalId); if (shouldTransmit === isInjected) continue; - const satellite = sim.satellites.find(s => s.noradId === event.satelliteNoradId); + // Terrestrial events never touch a satellite - the active set alone + // drives reception (AntennaCore polls getActiveTerrestrialEmissions) + if ((event.path ?? 'transponder') === 'terrestrial') { + if (shouldTransmit) { + this.activeSignalIds_.add(signalId); + } else { + this.activeSignalIds_.delete(signalId); + } + continue; + } + + const satellite = sim.satellites.find((s) => s.noradId === event.satelliteNoradId); if (!satellite) continue; if (shouldTransmit) { const signal: RfSignal = { signalId, serverId: 1, - noradId: event.satelliteNoradId, + noradId: satellite.noradId, frequency: event.frequency as RfFrequency, polarization: event.polarization, power: event.power as dBm, @@ -115,7 +280,7 @@ export class InterferenceManager { satellite.externalSignal.push(signal); this.activeSignalIds_.add(signalId); } else { - satellite.externalSignal = satellite.externalSignal.filter(s => s.signalId !== signalId); + satellite.externalSignal = satellite.externalSignal.filter((s) => s.signalId !== signalId); this.activeSignalIds_.delete(signalId); } } diff --git a/src/link-budget/link-budget-manager.ts b/src/link-budget/link-budget-manager.ts new file mode 100644 index 00000000..cd725814 --- /dev/null +++ b/src/link-budget/link-budget-manager.ts @@ -0,0 +1,142 @@ +/** + * @file LinkBudgetManager - Link-budget / EIRP planning console (nats-eu M1) + * @description Commissioning-era mechanic: before a pass the operator computes + * the predicted carrier-to-noise for the link from pass geometry and station + * parameters (a real Friis budget), confirms it matches the acceptance test + * card, then commits the link and verifies the achieved margin clears the demod + * threshold. The console holds the worksheet result and the committed margin; + * the link-budget-computed / link-margin-met objective conditions read them. + * + * Started only when settings.linkBudget is present, so other campaigns never + * instantiate it. All state is driven through the public API, which both the + * (future) console UI and unit tests exercise - no simulation coupling. + */ + +import { ScenarioManager } from '@app/scenario-manager'; + +/** Boltzmann constant, J/K */ +const BOLTZMANN_J_PER_K = 1.380649e-23; + +/** settings.linkBudget */ +export interface LinkBudgetConfig { + /** Human label for the planned link (e.g. "MERIDIAN-SAR-1 downlink, max-el pass") */ + label?: string; + /** Ground-truth C/N (dB) the correctly-filled worksheet must yield */ + expectedCNRDb: number; + /** Tolerance (dB) for accepting the operator's computed C/N as correct (default 1.0) */ + toleranceDb?: number; + /** Demod C/N threshold (dB) the margin is measured against */ + thresholdCNRDb: number; + /** Required link margin (dB) above threshold for the link to be accepted (default 3) */ + requiredMarginDb?: number; +} + +/** Operator-entered worksheet fields for the Friis budget */ +export interface LinkBudgetInputs { + /** Downlink EIRP at the satellite, dBm */ + eirpDbm: number; + /** Free-space path loss over the slant range, dB */ + fsplDb: number; + /** Receive antenna gain, dBi */ + rxGainDbi: number; + /** System noise temperature, K */ + systemNoiseTempK: number; + /** Occupied bandwidth, Hz */ + bandwidthHz: number; + /** Additional implementation / pointing / atmospheric losses, dB (default 0) */ + miscLossDb?: number; +} + +interface LinkBudgetState { + /** Operator's most recent computed C/N, dB (null before first compute) */ + computedCNRDb: number | null; + /** Committed link margin over threshold, dB (null before commit) */ + appliedMarginDb: number | null; +} + +export class LinkBudgetManager { + private static instance_: LinkBudgetManager | null = null; + + private readonly config_: LinkBudgetConfig | null; + private readonly state_: LinkBudgetState = { computedCNRDb: null, appliedMarginDb: null }; + + private constructor() { + this.config_ = (ScenarioManager.getInstance().settings.linkBudget as LinkBudgetConfig | undefined) ?? null; + } + + static getInstance(): LinkBudgetManager { + this.instance_ ??= new LinkBudgetManager(); + + return this.instance_; + } + + static isInitialized(): boolean { + return this.instance_ !== null; + } + + static destroy(): void { + this.instance_ = null; + } + + get state(): Readonly { + return this.state_; + } + + getConfig(): LinkBudgetConfig | null { + return this.config_; + } + + /** + * Pure Friis carrier-to-noise, dB. Received power (dBm) less thermal noise + * power in the occupied bandwidth (dBm). Extracted so it is unit-testable and + * shared by the console UI. + */ + static computeCNRDb(inputs: LinkBudgetInputs): number { + const rxPowerDbm = inputs.eirpDbm - inputs.fsplDb + inputs.rxGainDbi - (inputs.miscLossDb ?? 0); + // Thermal noise power in W -> dBm: 10log10(kTB) + 30 + const noiseW = BOLTZMANN_J_PER_K * inputs.systemNoiseTempK * inputs.bandwidthHz; + const noiseDbm = 10 * Math.log10(noiseW) + 30; + + return rxPowerDbm - noiseDbm; + } + + /** Run the planning computation from a filled worksheet; stores + returns C/N (dB). */ + computeCNR(inputs: LinkBudgetInputs): number { + const cnr = LinkBudgetManager.computeCNRDb(inputs); + this.state_.computedCNRDb = cnr; + + return cnr; + } + + /** + * Commit the link once equipment is configured. `achievedCNRDb` is the C/N the + * configured chain actually delivers (from the live receiver in-app; supplied + * directly in tests). Stores the margin over the demod threshold. + */ + commitLink(achievedCNRDb: number): void { + if (!this.config_) { + return; + } + this.state_.appliedMarginDb = achievedCNRDb - this.config_.thresholdCNRDb; + } + + /** Whether the operator's computed C/N matches the acceptance truth within tolerance. */ + isBudgetComputedCorrectly(): boolean { + if (!this.config_ || this.state_.computedCNRDb === null) { + return false; + } + const tol = this.config_.toleranceDb ?? 1.0; + + return Math.abs(this.state_.computedCNRDb - this.config_.expectedCNRDb) <= tol; + } + + /** Whether the committed margin meets the required threshold (or an override minMarginDb). */ + isMarginMet(minMarginDbOverride?: number): boolean { + if (!this.config_ || this.state_.appliedMarginDb === null) { + return false; + } + const required = minMarginDbOverride ?? this.config_.requiredMarginDb ?? 3; + + return this.state_.appliedMarginDb >= required; + } +} diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 00000000..1ca3f5f6 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,28 @@ +{ + "app": { + "name": "SignalRange" + }, + "loadout": { + "button": "LOADOUT", + "buttonTitle": "Change station equipment (restarts the sandbox)", + "title": "Station Loadout", + "intro": "Choose the antenna each station starts with. Applying a loadout restarts the sandbox and clears its saved state.", + "station": "Station", + "antenna": "Antenna", + "scenarioDefault": "scenario default", + "sourceBuiltin": "built-in", + "sourcePlugin": "plugin: {{name}}", + "apply": "Apply and Restart", + "reset": "Reset to Scenario Defaults", + "cancel": "Cancel", + "noStations": "This scenario has no ground stations to configure." + }, + "plugins": {}, + "devMenu": { + "pluginsSection": "Plugins", + "noPlugins": "No plugins installed.", + "pluginFailed": "failed to load", + "pluginDisabled": "disabled", + "reloadHint": "Toggling a plugin takes effect after a reload." + } +} diff --git a/src/locales/i18n.ts b/src/locales/i18n.ts new file mode 100644 index 00000000..39e58b83 --- /dev/null +++ b/src/locales/i18n.ts @@ -0,0 +1,138 @@ +/** + * Minimal localisation layer. + * + * Only English ships today, but every user-facing string introduced from + * phase 17 onward goes through `t7e()` so that adding a second language is a + * matter of adding a bundle, not touching components. Plugins contribute their + * own strings under `plugins..*` via `addLocaleBundle()`, which the + * PluginManager calls before a plugin's `register()` runs. + * + * The API shape (t7e, SUPPORTED_LOCALES) mirrors keeptrack-space so the plugin + * template reads the same in both projects. + */ +import en from './en.json'; + +type Join = K extends string ? (P extends string ? `${K}.${P}` : never) : never; +type Leaves = T extends object ? { [K in keyof T]: T[K] extends object ? Join> : K }[keyof T] : never; + +/** + * A dotted key into en.json, with autocomplete, or any other string. The open + * half exists because plugins add keys at runtime that the host cannot know. + */ +export type LocaleKey = Leaves | (string & {}); + +export interface LocaleInfo { + /** BCP-47 base code */ + code: string; + /** Endonym for language pickers */ + nativeName: string; +} + +/** Bundled UI languages. Single source of truth for any language picker. */ +export const SUPPORTED_LOCALES: LocaleInfo[] = [{ code: 'en', nativeName: 'English' }]; + +export const DEFAULT_LOCALE = 'en'; + +type Bundle = Record; + +const cloneBundle = (bundle: Bundle): Bundle => JSON.parse(JSON.stringify(bundle)) as Bundle; + +let bundles_: Record = { [DEFAULT_LOCALE]: cloneBundle(en as Bundle) }; +let language_ = DEFAULT_LOCALE; +const reportedMissing_ = new Set(); + +export function currentLanguage(): string { + return language_; +} + +/** Switch the active language. Returns false (and keeps the current one) when the code is not bundled. */ +export function setLanguage(code: string): boolean { + if (!SUPPORTED_LOCALES.some((l) => l.code === code)) { + return false; + } + language_ = code; + + return true; +} + +function lookup_(bundle: Bundle | undefined, key: string): unknown { + if (!bundle) { + return undefined; + } + + let node: unknown = bundle; + + for (const part of key.split('.')) { + if (node === null || typeof node !== 'object' || !(part in (node as Bundle))) { + return undefined; + } + node = (node as Bundle)[part]; + } + + return node; +} + +function interpolate_(template: string, params: Record): string { + return template.replace(/\{\{\s*(\w+)\s*\}\}/gu, (match, name: string) => (name in params ? String(params[name]) : match)); +} + +/** True when `key` resolves to a string in the given (default: active) language. */ +export function hasLocaleKey(key: string, lang: string = language_): boolean { + return typeof lookup_(bundles_[lang], key) === 'string'; +} + +/** + * Translate a key. Falls back to English, then to the key itself (reported once + * to the console so a missing string is visible without crashing the UI). + */ +export function t7e(key: LocaleKey, params?: Record): string { + let raw = lookup_(bundles_[language_], key); + + if (typeof raw !== 'string' && language_ !== DEFAULT_LOCALE) { + raw = lookup_(bundles_[DEFAULT_LOCALE], key); + } + + if (typeof raw !== 'string') { + if (!reportedMissing_.has(key)) { + reportedMissing_.add(key); + console.warn(`[i18n] missing locale key "${key}"`); + } + + return key; + } + + return params ? interpolate_(raw, params) : raw; +} + +function deepMerge_(target: Bundle, source: Bundle): void { + for (const [k, v] of Object.entries(source)) { + // Plugin bundles are third-party JSON; JSON.parse makes __proto__ an own key + if (k === '__proto__' || k === 'constructor' || k === 'prototype') { + continue; + } + const existing = target[k]; + + if (v !== null && typeof v === 'object' && !Array.isArray(v) && existing !== null && typeof existing === 'object' && !Array.isArray(existing)) { + deepMerge_(existing as Bundle, v as Bundle); + } else { + target[k] = v; + } + } +} + +/** + * Merge a bundle into a language. Later bundles win on conflict; plugins are + * expected to keep to their own `plugins.` namespace so nothing collides. + */ +export function addLocaleBundle(lang: string, bundle: Bundle): void { + bundles_[lang] ??= {}; + deepMerge_(bundles_[lang], cloneBundle(bundle)); + reportedMissing_.clear(); +} + +/** Test seam: back to the bundled English only. */ +export function resetLocales(): void { + bundles_ = { [DEFAULT_LOCALE]: cloneBundle(en as Bundle) }; + language_ = DEFAULT_LOCALE; + reportedMissing_.clear(); +} diff --git a/src/logging/logger.ts b/src/logging/logger.ts index bdf9cf41..1dfd8a04 100644 --- a/src/logging/logger.ts +++ b/src/logging/logger.ts @@ -12,11 +12,16 @@ export class Logger { private static color(type: LogLevel): string { switch (type) { - case 'LOG': return 'color: #2196F3'; // Blue - case 'INFO': return 'color: #4CAF50'; // Green - case 'WARN': return 'color: #FFC107'; // Amber - case 'ERROR': return 'color: #F44336'; // Red - default: return ''; + case 'LOG': + return 'color: #2196F3'; // Blue + case 'INFO': + return 'color: #4CAF50'; // Green + case 'WARN': + return 'color: #FFC107'; // Amber + case 'ERROR': + return 'color: #F44336'; // Red + default: + return ''; } } @@ -47,4 +52,4 @@ export class Logger { if (!Logger.shouldLog_('ERROR')) return; console.error(`%c[ERROR] ${message}`, Logger.color('ERROR'), ...optionalParams); } -} \ No newline at end of file +} diff --git a/src/modal/character-enum.ts b/src/modal/character-enum.ts index 0deb48c5..8805ab72 100644 --- a/src/modal/character-enum.ts +++ b/src/modal/character-enum.ts @@ -1,4 +1,4 @@ -import { getAssetUrl } from "@app/utils/asset-url"; +import { getAssetUrl } from '@app/utils/asset-url'; export enum Character { /** Mid-level ground station operator and guide. British immigrant with a rough tone. */ @@ -11,7 +11,17 @@ export enum Character { FRANCIS_MARTIN = 'francis_martin', /** Satellite Operations Engineer from Halifax. Canadian with subtle Canadian-isms. */ MARCUS_CHEN = 'marcus_chen', - DANA_TORRES = "dana_torres", + DANA_TORRES = 'dana_torres', + /** Charlie's teenage niece. Licensed ham (KD2RLY), SatNOGS contributor, teaches backyard satellite tracking with infectious enthusiasm. */ + RILEY_BROOKS = 'riley_brooks', + /** SH-02 Shetland operator; the player's cross-station peer in Campaign 2. Shetlander; practical, weather-obsessed, understated humor. */ + FIONA_MACLEOD = 'fiona_macleod', + /** MERIDIAN constellation ops engineer (satellite operator HQ, Rotterdam). Precise, procedural, warm under pressure. */ + ANNEKE_VISSER = 'anneke_visser', + /** Customer: senior analyst, Nordic Maritime Watch (SAR imagery tasking). Direct, mission-focused, talks vessels-and-coverage not RF. */ + ERIK_HALVORSEN = 'erik_halvorsen', + /** NATS Group Security / CSIRT lead. Drives the Campaign 2 Phase 3 adversary arc. Calm, methodical, evidence-first. */ + PRIYA_SHARMA = 'priya_sharma', /** System/Self-check - no avatar, used for solo scenarios where no NPC is present */ SYSTEM = 'system', } @@ -36,6 +46,11 @@ export const CharacterAvatars: Record = { [Character.FRANCIS_MARTIN]: getAssetUrl('/assets/characters/francis-martin.png'), [Character.MARCUS_CHEN]: getAssetUrl('/assets/characters/marcus-chen.png'), [Character.DANA_TORRES]: getAssetUrl('/assets/characters/dana-torres.png'), + [Character.RILEY_BROOKS]: getAssetUrl('/assets/characters/riley-brooks.png'), + [Character.FIONA_MACLEOD]: getAssetUrl('/assets/characters/fiona-macleod.png'), + [Character.ANNEKE_VISSER]: getAssetUrl('/assets/characters/anneke-visser.png'), + [Character.ERIK_HALVORSEN]: getAssetUrl('/assets/characters/erik-halvorsen.png'), + [Character.PRIYA_SHARMA]: getAssetUrl('/assets/characters/priya-sharma.png'), [Character.SYSTEM]: '', }; @@ -46,6 +61,11 @@ export const CharacterNames: Record = { [Character.JAMES_OKAFOR]: 'James Okafor', [Character.FRANCIS_MARTIN]: 'Francis Martin', [Character.MARCUS_CHEN]: 'Marcus Chen', + [Character.RILEY_BROOKS]: 'Riley Brooks', + [Character.FIONA_MACLEOD]: 'Fiona MacLeod', + [Character.ANNEKE_VISSER]: 'Anneke Visser', + [Character.ERIK_HALVORSEN]: 'Erik Halvorsen', + [Character.PRIYA_SHARMA]: 'Priya Sharma', [Character.SYSTEM]: 'Knowledge Check', }; @@ -56,6 +76,11 @@ export const CharacterTitles: Record = { [Character.JAMES_OKAFOR]: 'Fleet Captain', [Character.FRANCIS_MARTIN]: 'Board Member', [Character.MARCUS_CHEN]: 'Satellite Operations Engineer', + [Character.RILEY_BROOKS]: 'Amateur Radio Operator - KD2RLY', + [Character.FIONA_MACLEOD]: 'Ground Station Operator', + [Character.ANNEKE_VISSER]: 'Constellation Operations Engineer', + [Character.ERIK_HALVORSEN]: 'Senior Maritime Analyst', + [Character.PRIYA_SHARMA]: 'CSIRT Lead', [Character.SYSTEM]: '', }; @@ -66,6 +91,11 @@ export const CharacterCompany: Record = { [Character.JAMES_OKAFOR]: 'Atlantic Shipping Alliance', [Character.FRANCIS_MARTIN]: 'SeaLink', [Character.MARCUS_CHEN]: 'SeaLink Maritime (Halifax)', + [Character.RILEY_BROOKS]: 'Backyard / AMSAT Member', + [Character.FIONA_MACLEOD]: 'North Atlantic Teleport Services EU (Shetland)', + [Character.ANNEKE_VISSER]: 'MERIDIAN Constellation Operations (Rotterdam)', + [Character.ERIK_HALVORSEN]: 'Nordic Maritime Watch', + [Character.PRIYA_SHARMA]: 'NATS Group Security', [Character.SYSTEM]: '', }; diff --git a/src/modal/decision-manager.ts b/src/modal/decision-manager.ts new file mode 100644 index 00000000..d7a14313 --- /dev/null +++ b/src/modal/decision-manager.ts @@ -0,0 +1,283 @@ +/** + * @file Decision Manager - owns the answer-time side of decision conditions + * @description Mirrors QuizManager. ObjectivesManager registers a decision and + * keeps polling isResolved(); this class owns the interaction: showing the + * modal, grading the chosen option against the evidence facts at that instant, + * the penalty tally, and dispatching the option's consequence. + */ + +import { EventBus } from '@app/events/event-bus'; +import { DecisionAnsweredData, DecisionDismissedData, DecisionEvidenceStatus, DecisionGradedData, DecisionResolvedData, DecisionShowData, Events } from '@app/events/events'; +import { ConsequenceDispatcher } from '@app/objectives/consequence-dispatcher'; +import { evaluateFactRule } from '@app/objectives/evidence-facts'; +import type { DecisionOption, EvidenceFactId } from '@app/objectives/objective-types'; +import type { Character } from './character-enum'; + +export const DEFAULT_DECISION_PENALTY = 10; +export const DEFAULT_PARTIAL_CREDIT_UNEVIDENCED = 0.5; + +/** What ObjectivesManager hands over when it registers a decision */ +export interface DecisionRegistration { + prompt: string; + options: DecisionOption[]; + explanation?: string; + pointPenalty?: number; + partialCreditUnevidenced?: number; + character?: Character; + preserveOptionOrder?: boolean; + groundStationId?: string; + /** Live read of a held evidence fact */ + readFact: (id: EvidenceFactId) => boolean; + /** Evidence latches the decision depends on, with their current state */ + evidenceStatus: () => DecisionEvidenceStatus[]; +} + +export interface DecisionResolution { + optionIndex: number; + label: string; + correct: boolean; + evidenced: boolean; + missingEvidence: string[]; +} + +/** One judgement the player made, for the completion summary */ +export interface DecisionRecord { + objectiveId: string; + conditionIndex: number; + prompt: string; + attempts: number; + pointsDeducted: number; + resolution: DecisionResolution; +} + +interface DecisionState extends DecisionRegistration { + objectiveId: string; + conditionIndex: number; + attempts: number; + totalPointsDeducted: number; + isResolved: boolean; + resolution: DecisionResolution | null; +} + +export class DecisionManager { + private static instance_: DecisionManager | null = null; + + private readonly decisions_ = new Map(); + private pendingKey_: string | null = null; + private readonly dispatcher_ = new ConsequenceDispatcher(); + + private readonly boundAnsweredHandler_: (data: DecisionAnsweredData) => void; + private readonly boundDismissedHandler_: (data: DecisionDismissedData) => void; + private readonly boundResolvedHandler_: (data: DecisionResolvedData) => void; + + private constructor() { + this.boundAnsweredHandler_ = this.handleAnswered_.bind(this); + this.boundDismissedHandler_ = this.handleDismissed_.bind(this); + this.boundResolvedHandler_ = this.handleResolved_.bind(this); + + const bus = EventBus.getInstance(); + bus.on(Events.DECISION_ANSWERED, this.boundAnsweredHandler_); + bus.on(Events.DECISION_DISMISSED, this.boundDismissedHandler_); + bus.on(Events.DECISION_RESOLVED, this.boundResolvedHandler_); + } + + static getInstance(): DecisionManager { + DecisionManager.instance_ ??= new DecisionManager(); + return DecisionManager.instance_; + } + + static hasInstance(): boolean { + return DecisionManager.instance_ !== null; + } + + static destroy(): void { + const instance = DecisionManager.instance_; + if (!instance) return; + const bus = EventBus.getInstance(); + bus.off(Events.DECISION_ANSWERED, instance.boundAnsweredHandler_); + bus.off(Events.DECISION_DISMISSED, instance.boundDismissedHandler_); + bus.off(Events.DECISION_RESOLVED, instance.boundResolvedHandler_); + instance.dispatcher_.releaseAll(); + DecisionManager.instance_ = null; + } + + /** Register a decision; a pending indicator is raised, the modal is not opened. */ + register(objectiveId: string, conditionIndex: number, registration: DecisionRegistration): void { + const key = DecisionManager.keyFor_(objectiveId, conditionIndex); + if (this.decisions_.has(key)) return; + + this.decisions_.set(key, { + ...registration, + objectiveId, + conditionIndex, + attempts: 0, + totalPointsDeducted: 0, + isResolved: false, + resolution: null, + }); + this.pendingKey_ = key; + EventBus.getInstance().emit(Events.DECISION_PENDING, { objectiveId, conditionIndex }); + } + + has(objectiveId: string, conditionIndex: number): boolean { + return this.decisions_.has(DecisionManager.keyFor_(objectiveId, conditionIndex)); + } + + isResolved(objectiveId: string, conditionIndex: number): boolean { + return this.decisions_.get(DecisionManager.keyFor_(objectiveId, conditionIndex))?.isResolved ?? false; + } + + hasPending(): boolean { + return this.pendingKey_ !== null; + } + + getPendingKey(): string | null { + return this.pendingKey_; + } + + /** Open the modal for the pending decision (pending indicator click) */ + reopenPending(): void { + if (!this.pendingKey_) return; + const state = this.decisions_.get(this.pendingKey_); + if (state && !state.isResolved) this.show(state.objectiveId, state.conditionIndex); + } + + show(objectiveId: string, conditionIndex: number): void { + const state = this.decisions_.get(DecisionManager.keyFor_(objectiveId, conditionIndex)); + if (!state || state.isResolved) return; + + const data: DecisionShowData = { + objectiveId, + conditionIndex, + prompt: state.prompt, + options: state.options.map((o) => o.label), + explanation: state.explanation, + pointPenalty: state.pointPenalty ?? DEFAULT_DECISION_PENALTY, + character: state.character, + preserveOptionOrder: state.preserveOptionOrder, + evidence: state.evidenceStatus(), + }; + EventBus.getInstance().emit(Events.DECISION_SHOW, data); + } + + /** Current evidence latches for an open decision (the modal polls this) */ + getEvidenceStatus(objectiveId: string, conditionIndex: number): DecisionEvidenceStatus[] { + return this.decisions_.get(DecisionManager.keyFor_(objectiveId, conditionIndex))?.evidenceStatus() ?? []; + } + + getAttempts(objectiveId: string, conditionIndex: number): number { + return this.decisions_.get(DecisionManager.keyFor_(objectiveId, conditionIndex))?.attempts ?? 0; + } + + getPointsDeducted(objectiveId: string, conditionIndex: number): number { + return this.decisions_.get(DecisionManager.keyFor_(objectiveId, conditionIndex))?.totalPointsDeducted ?? 0; + } + + /** Every resolved decision, for the completion summary */ + getRecords(): DecisionRecord[] { + const records: DecisionRecord[] = []; + for (const state of this.decisions_.values()) { + if (!state.resolution) continue; + records.push({ + objectiveId: state.objectiveId, + conditionIndex: state.conditionIndex, + prompt: state.prompt, + attempts: state.attempts, + pointsDeducted: state.totalPointsDeducted, + resolution: state.resolution, + }); + } + return records; + } + + reset(): void { + this.decisions_.clear(); + this.pendingKey_ = null; + this.dispatcher_.releaseAll(); + } + + /** + * Grade an answer against the facts as they stand right now. Any option's + * consequence fires when it is chosen, right or wrong - that is the point. + * A wrong answer leaves the decision open, like a quiz. + */ + private handleAnswered_(data: DecisionAnsweredData): void { + const state = this.decisions_.get(DecisionManager.keyFor_(data.objectiveId, data.conditionIndex)); + if (!state || state.isResolved) return; + const option = state.options[data.optionIndex]; + if (!option) return; + + state.attempts += 1; + + const correct = this.isCorrect_(state, data.optionIndex); + const missingEvidence = state + .evidenceStatus() + .filter((e) => !e.ready) + .map((e) => e.label); + const evidenced = missingEvidence.length === 0; + const penalty = state.pointPenalty ?? DEFAULT_DECISION_PENALTY; + + if (!correct) { + state.totalPointsDeducted += penalty; + } else if (!evidenced) { + const credit = state.partialCreditUnevidenced ?? DEFAULT_PARTIAL_CREDIT_UNEVIDENCED; + state.totalPointsDeducted += Math.round(penalty * (1 - credit)); + } + + if (option.consequence) { + const result = this.dispatcher_.dispatch(option.consequence, { groundStationId: state.groundStationId }); + state.totalPointsDeducted += result.pointDelta; + for (const s of result.skipped) console.warn(`decision ${data.objectiveId}[${data.conditionIndex}] consequence ${s.key} skipped: ${s.reason}`); + } + state.totalPointsDeducted = Math.max(0, state.totalPointsDeducted); + + if (correct) { + state.resolution = { optionIndex: data.optionIndex, label: option.label, correct, evidenced, missingEvidence }; + } + + const graded: DecisionGradedData = { + objectiveId: data.objectiveId, + conditionIndex: data.conditionIndex, + optionIndex: data.optionIndex, + correct, + evidenced, + missingEvidence, + feedback: option.feedback ?? (correct ? state.explanation : undefined), + attempts: state.attempts, + pointsDeducted: state.totalPointsDeducted, + }; + EventBus.getInstance().emit(Events.DECISION_GRADED, graded); + } + + /** + * An option is correct when its rule holds. If no option's rule holds the + * author has left the scenario in a state the decision cannot describe; + * accept the answer rather than deadlock, and say so on the console. + */ + private isCorrect_(state: DecisionState, optionIndex: number): boolean { + const holds = state.options.map((o) => (o.correctWhen ? evaluateFactRule(o.correctWhen, state.readFact) : false)); + if (!holds.some(Boolean)) { + console.warn(`decision ${state.objectiveId}[${state.conditionIndex}]: no option is correct for the current evidence; accepting the answer`); + return true; + } + return holds[optionIndex]; + } + + private handleDismissed_(data: DecisionDismissedData): void { + const key = DecisionManager.keyFor_(data.objectiveId, data.conditionIndex); + const state = this.decisions_.get(key); + if (state && !state.isResolved) this.pendingKey_ = key; + } + + private handleResolved_(data: DecisionResolvedData): void { + const key = DecisionManager.keyFor_(data.objectiveId, data.conditionIndex); + const state = this.decisions_.get(key); + if (!state) return; + state.isResolved = true; + if (this.pendingKey_ === key) this.pendingKey_ = null; + } + + private static keyFor_(objectiveId: string, conditionIndex: number): string { + return `${objectiveId}:${conditionIndex}`; + } +} diff --git a/src/modal/decision-modal.css b/src/modal/decision-modal.css new file mode 100644 index 00000000..c717cde1 --- /dev/null +++ b/src/modal/decision-modal.css @@ -0,0 +1,81 @@ +/* Decision modal - extends the quiz-modal look with an evidence checklist */ + +.decision-evidence { + margin: 0.25rem 0 0.75rem; + padding: 0.5rem 0.75rem; + border: 1px solid var(--mc-border, rgba(255, 255, 255, 0.12)); + border-radius: 4px; + background: var(--mc-surface-2, rgba(255, 255, 255, 0.03)); + font-size: 0.85rem; +} + +.decision-evidence-title { + font-weight: 600; + margin-bottom: 0.25rem; + letter-spacing: 0.02em; +} + +.decision-evidence-list { + list-style: none; + margin: 0; + padding: 0; +} + +.decision-evidence-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.1rem 0; +} + +.decision-evidence-item.missing { + opacity: 0.75; +} + +.decision-evidence-mark { + display: inline-block; + width: 1.1em; + text-align: center; + font-family: monospace; +} + +.decision-evidence-item.ready .decision-evidence-mark { + color: var(--mc-success, #4caf50); +} + +.decision-evidence-item.missing .decision-evidence-mark { + color: var(--mc-warning, #ffb300); +} + +.decision-evidence-warning { + margin-top: 0.35rem; + color: var(--mc-warning, #ffb300); + font-size: 0.8rem; +} + +.decision-unevidenced { + color: var(--mc-warning, #ffb300); + font-size: 0.85rem; + margin: 0.4rem 0; +} + +/* Decision box (non-modal draggable box) - the quiz box shape, amber so a call reads differently from a quiz */ +#decision-modal.decision-box { + position: absolute; + max-width: 570px; + background: var(--color-dark-background, #1a1a2e); + border: 2px solid var(--mc-warning, #ffb300); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); +} + +#decision-modal .draggable-box__content { + max-height: 70vh; + overflow-y: auto; + border: 0px; +} + +#decision-modal .draggable-box__title-bar { + background: rgba(255, 179, 0, 0.1); + border-bottom: 1px solid var(--hm-border, #444); +} diff --git a/src/modal/decision-modal.ts b/src/modal/decision-modal.ts new file mode 100644 index 00000000..8ad5f82d --- /dev/null +++ b/src/modal/decision-modal.ts @@ -0,0 +1,355 @@ +import { Rng } from '@app/simulation/rng'; +/** + * @file Decision Modal - presents a decision condition to the operator + * @description Same shell as QuizModal (draggable, non-blocking until answered + * correctly) with two differences: an evidence checklist that updates while + * the box is open, and grading that comes back from DecisionManager rather + * than a correctIndex - the right answer depends on the simulation, and the + * chosen option may change the simulation. + */ + +import { DraggableBox } from '@app/engine/ui/draggable-box'; +import { html } from '@app/engine/utils/development/formatter'; +import { getEl, showEl } from '@app/engine/utils/get-el'; +import { EventBus } from '@app/events/event-bus'; +import { DecisionAnsweredData, DecisionDismissedData, DecisionEvidenceStatus, DecisionGradedData, DecisionResolvedData, DecisionShowData, Events } from '@app/events/events'; +import { Character, CharacterNames, Emotion, getCharacterAvatarUrl } from './character-enum'; +import { DecisionManager } from './decision-manager'; +import './decision-modal.css'; +import './quiz-modal.css'; + +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('ui:decision').next(); + +export class DecisionModal extends DraggableBox { + private static instance_: DecisionModal | null = null; + private static readonly EVIDENCE_POLL_MS = 500; + + private current_: DecisionShowData | null = null; + private currentCharacter_: Character = Character.CHARLIE_BROOKS; + private isShowingFeedback_ = false; + private domCreated_ = false; + private shuffledIndices_: number[] = []; + private overlayEl_: HTMLDivElement | null = null; + private evidencePoll_: number | null = null; + private lastGraded_: DecisionGradedData | null = null; + + private readonly boundShowHandler_: (data: DecisionShowData) => void; + private readonly boundGradedHandler_: (data: DecisionGradedData) => void; + + private constructor() { + super('decision-modal', { width: '520px', title: 'Decision', skipDomCreation: true }); + + this.boundShowHandler_ = this.handleShow_.bind(this); + this.boundGradedHandler_ = this.handleGraded_.bind(this); + const bus = EventBus.getInstance(); + bus.on(Events.DECISION_SHOW, this.boundShowHandler_); + bus.on(Events.DECISION_GRADED, this.boundGradedHandler_); + } + + static getInstance(): DecisionModal { + DecisionModal.instance_ ??= new DecisionModal(); + return DecisionModal.instance_; + } + + static destroy(): void { + if (DecisionModal.instance_) { + DecisionModal.instance_.dispose(); + DecisionModal.instance_.close(); + DecisionModal.instance_ = null; + } + } + + protected getBoxContentHtml(): string { + return html` +
+
+
+
+
+ + +
+ `; + } + + override open(cb?: () => void): void { + if (!this.boxEl) return; + showEl(this.boxEl); + if (this.width) this.boxEl.style.minWidth = this.width; + this.boxEl.style.top = `${window.scrollY + (window.innerHeight - this.boxEl.offsetHeight) / 2}px`; + this.boxEl.style.left = `${(window.innerWidth - this.boxEl.offsetWidth) / 2}px`; + this.boxEl.style.zIndex = DraggableBox.increaseMaxZIndex().toString(); + cb?.(); + } + + override close(): void { + this.stopEvidencePoll_(); + if (this.current_ && !this.isShowingFeedback_) { + const dismissed: DecisionDismissedData = { objectiveId: this.current_.objectiveId, conditionIndex: this.current_.conditionIndex }; + EventBus.getInstance().emit(Events.DECISION_DISMISSED, dismissed); + } + this.hideOverlay_(); + // Drop the rendered options so no hidden .quiz-option-btn lingers for the + // shared e2e locators once the box is closed. + const optionsEl = getEl('decision-options'); + if (optionsEl) optionsEl.innerHTML = ''; + super.close(); + } + + dispose(): void { + this.stopEvidencePoll_(); + const bus = EventBus.getInstance(); + bus.off(Events.DECISION_SHOW, this.boundShowHandler_); + bus.off(Events.DECISION_GRADED, this.boundGradedHandler_); + } + + private createDom_(): void { + if (this.domCreated_) return; + document.body.insertAdjacentHTML( + 'beforeend', + html` + + ` + ); + this.domCreated_ = true; + this.onOpen(); + } + + private handleShow_(data: DecisionShowData): void { + this.current_ = data; + this.currentCharacter_ = data.character ?? Character.CHARLIE_BROOKS; + this.isShowingFeedback_ = false; + this.lastGraded_ = null; + if (!this.domCreated_) this.createDom_(); + this.open(() => { + this.render_(); + this.startEvidencePoll_(); + }); + } + + private render_(): void { + if (!this.current_) return; + const promptEl = getEl('decision-prompt'); + const optionsEl = getEl('decision-options'); + const feedbackEl = getEl('decision-feedback'); + const penaltyEl = getEl('decision-penalty-notice'); + const headerEl = this.boxEl?.querySelector('.quiz-header') as HTMLElement | null; + if (!promptEl || !optionsEl || !feedbackEl || !penaltyEl || !headerEl) return; + + const closeBtn = getEl(`${this.boxId}-close`); + if (closeBtn) closeBtn.style.display = ''; + + headerEl.innerHTML = + this.currentCharacter_ === Character.SYSTEM + ? html` +
!
+
+ Decision + Make the call +
+ ` + : html` + ${CharacterNames[this.currentCharacter_]} +
+ ${CharacterNames[this.currentCharacter_]} + needs a call: +
+ `; + + promptEl.textContent = this.current_.prompt; + feedbackEl.style.display = 'none'; + penaltyEl.style.display = 'none'; + this.renderEvidence_(this.current_.evidence); + + const labels = ['A', 'B', 'C', 'D', 'E', 'F']; + this.shuffledIndices_ = this.shuffleIndices_(); + const options = this.current_.options; + optionsEl.innerHTML = this.shuffledIndices_ + .map( + (originalIndex, displayIndex) => html` + + ` + ) + .join(''); + + this.shuffledIndices_.forEach((_, displayIndex) => { + const btn = getEl(`decision-option-${displayIndex}`); + if (!btn) return; + const originalIndex = Number.parseInt(btn.dataset.index ?? '0', 10); + btn.addEventListener('click', () => this.handleOptionClick_(originalIndex)); + }); + } + + private renderEvidence_(evidence: DecisionEvidenceStatus[]): void { + const el = getEl('decision-evidence'); + if (!el) return; + if (evidence.length === 0) { + el.style.display = 'none'; + return; + } + el.style.display = ''; + const missing = evidence.filter((e) => !e.ready).length; + el.innerHTML = html` +
${missing === 0 ? 'Evidence in hand' : 'Look before you decide'}
+
    + ${evidence.map((e) => html`
  • ${e.ready ? '✓' : '○'}${e.label}
  • `).join('')} +
+ ${missing > 0 ? html`
Deciding without looking scores partial credit.
` : ''} + `; + } + + private startEvidencePoll_(): void { + this.stopEvidencePoll_(); + this.evidencePoll_ = window.setInterval(() => { + if (!this.current_ || this.isShowingFeedback_) return; + this.renderEvidence_(DecisionManager.getInstance().getEvidenceStatus(this.current_.objectiveId, this.current_.conditionIndex)); + }, DecisionModal.EVIDENCE_POLL_MS); + } + + private stopEvidencePoll_(): void { + if (this.evidencePoll_ !== null) { + window.clearInterval(this.evidencePoll_); + this.evidencePoll_ = null; + } + } + + private handleOptionClick_(originalIndex: number): void { + if (!this.current_ || this.isShowingFeedback_) return; + const answered: DecisionAnsweredData = { objectiveId: this.current_.objectiveId, conditionIndex: this.current_.conditionIndex, optionIndex: originalIndex }; + EventBus.getInstance().emit(Events.DECISION_ANSWERED, answered); + } + + private handleGraded_(data: DecisionGradedData): void { + if (!this.current_ || data.objectiveId !== this.current_.objectiveId || data.conditionIndex !== this.current_.conditionIndex) return; + this.lastGraded_ = data; + const displayIndex = this.shuffledIndices_.indexOf(data.optionIndex); + const btn = getEl(`decision-option-${displayIndex}`); + + if (data.correct) { + btn?.classList.add('correct'); + this.showCorrectFeedback_(data); + } else { + btn?.classList.add('incorrect', 'disabled'); + btn?.setAttribute('disabled', 'true'); + this.showIncorrectFeedback_(data); + } + } + + private showCorrectFeedback_(data: DecisionGradedData): void { + this.isShowingFeedback_ = true; + this.stopEvidencePoll_(); + const closeBtn = getEl(`${this.boxId}-close`); + if (closeBtn) closeBtn.style.display = 'none'; + this.showOverlay_(); + this.setAvatar_(Emotion.HAPPY); + + const feedbackEl = getEl('decision-feedback'); + if (feedbackEl) { + feedbackEl.innerHTML = html` + + ${data.evidenced ? '' : html`

Not checked: ${data.missingEvidence.join(', ')}. Partial credit.

`} + ${data.feedback ? html`` : ''} + + `; + feedbackEl.style.display = 'block'; + getEl('decision-continue-btn')?.addEventListener('click', () => this.handleContinueClick_()); + } + this.renderPenalty_(data.pointsDeducted); + this.disableOptions_(); + } + + private showIncorrectFeedback_(data: DecisionGradedData): void { + this.setAvatar_(Emotion.CONCERNED); + const feedbackEl = getEl('decision-feedback'); + if (feedbackEl) { + feedbackEl.innerHTML = html` + + ${data.feedback ? html`` : ''} + `; + feedbackEl.style.display = 'block'; + } + this.renderPenalty_(data.pointsDeducted); + window.setTimeout(() => { + if (!this.isShowingFeedback_) this.setAvatar_(Emotion.CONFIDENT); + }, 1500); + } + + private renderPenalty_(total: number): void { + const penaltyEl = getEl('decision-penalty-notice'); + if (!penaltyEl) return; + if (total <= 0) { + penaltyEl.style.display = 'none'; + return; + } + penaltyEl.innerHTML = html`-${total} points so far`; + penaltyEl.style.display = 'block'; + } + + private handleContinueClick_(): void { + if (!this.current_ || !this.lastGraded_) { + this.close(); + return; + } + const resolved: DecisionResolvedData = { + objectiveId: this.current_.objectiveId, + conditionIndex: this.current_.conditionIndex, + totalAttempts: this.lastGraded_.attempts, + totalPointsDeducted: this.lastGraded_.pointsDeducted, + }; + EventBus.getInstance().emit(Events.DECISION_RESOLVED, resolved); + this.current_ = null; + this.close(); + } + + private setAvatar_(emotion: Emotion): void { + if (this.currentCharacter_ === Character.SYSTEM) return; + const avatar = getEl('decision-avatar') as HTMLImageElement | null; + if (avatar) avatar.src = getCharacterAvatarUrl(this.currentCharacter_, emotion); + } + + private disableOptions_(): void { + this.shuffledIndices_.forEach((_, displayIndex) => { + const btn = getEl(`decision-option-${displayIndex}`); + btn?.setAttribute('disabled', 'true'); + }); + } + + private shuffleIndices_(): number[] { + if (!this.current_) return []; + const indices = Array.from({ length: this.current_.options.length }, (_, i) => i); + if (this.current_.preserveOptionOrder || indices.length <= 1) return indices; + for (let i = indices.length - 1; i > 0; i--) { + const j = Math.floor(random() * (i + 1)); + [indices[i], indices[j]] = [indices[j], indices[i]]; + } + return indices; + } + + private showOverlay_(): void { + if (this.overlayEl_) return; + this.overlayEl_ = document.createElement('div'); + this.overlayEl_.className = 'quiz-modal-overlay'; + document.body.appendChild(this.overlayEl_); + } + + private hideOverlay_(): void { + this.overlayEl_?.remove(); + this.overlayEl_ = null; + } +} diff --git a/src/modal/dialog-history-box.ts b/src/modal/dialog-history-box.ts index d03c18f3..5ef3c057 100644 --- a/src/modal/dialog-history-box.ts +++ b/src/modal/dialog-history-box.ts @@ -1,10 +1,10 @@ -import { html } from "@app/engine/utils/development/formatter"; -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; +import { html } from '@app/engine/utils/development/formatter'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; import './dialog-history-box.css'; -import { CharacterNames } from "./character-enum"; -import { DialogHistoryManager } from "./dialog-history-manager"; -import { DraggableHtmlBox } from "./draggable-html-box"; +import { CharacterNames } from './character-enum'; +import { DialogHistoryManager } from './dialog-history-manager'; +import { DraggableHtmlBox } from './draggable-html-box'; export class DialogHistoryBox extends DraggableHtmlBox { private readonly boundHistoryChangedHandler_: () => void; @@ -36,9 +36,10 @@ export class DialogHistoryBox extends DraggableHtmlBox { `; } - const historyItems = history.map((entry, index) => { - const timeStr = new Date(entry.timestamp).toLocaleTimeString(); - return html` + const historyItems = history + .map((entry, index) => { + const timeStr = new Date(entry.timestamp).toLocaleTimeString(); + return html`
${entry.title} @@ -50,7 +51,8 @@ export class DialogHistoryBox extends DraggableHtmlBox {
`; - }).join(''); + }) + .join(''); return html`
diff --git a/src/modal/dialog-history-manager.ts b/src/modal/dialog-history-manager.ts index b2947d94..4a01b0fe 100644 --- a/src/modal/dialog-history-manager.ts +++ b/src/modal/dialog-history-manager.ts @@ -18,7 +18,7 @@ export class DialogHistoryManager { private static instance: DialogHistoryManager; private history: DialogHistoryEntry[] = []; - private constructor() { } + private constructor() {} static getInstance(): DialogHistoryManager { if (!DialogHistoryManager.instance) { @@ -32,7 +32,7 @@ export class DialogHistoryManager { */ addEntry(text: string, character: Character, audioUrl: string, title: string, emotion?: Emotion): void { // Don't add the same audioUrl twice - const isAlreadyInHistory = this.history.some(entry => entry.audioUrl === audioUrl); + const isAlreadyInHistory = this.history.some((entry) => entry.audioUrl === audioUrl); if (isAlreadyInHistory) { return; } @@ -84,9 +84,7 @@ export class DialogHistoryManager { } // Get completed objectives sorted by completion time - const completedStates = objectiveStates - .filter(state => state.isCompleted && state.completedAt) - .sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0)); + const completedStates = objectiveStates.filter((state) => state.isCompleted && state.completedAt).sort((a, b) => (a.completedAt ?? 0) - (b.completedAt ?? 0)); if (completedStates.length === 0) { return; @@ -94,13 +92,7 @@ export class DialogHistoryManager { // Add intro clip first if it exists if (dialogClips.intro) { - this.addEntry( - dialogClips.intro.text, - dialogClips.intro.character, - dialogClips.intro.audioUrl, - 'Introduction', - dialogClips.intro.emotion - ); + this.addEntry(dialogClips.intro.text, dialogClips.intro.character, dialogClips.intro.audioUrl, 'Introduction', dialogClips.intro.emotion); } // Add each completed objective's dialog clip in chronological order @@ -110,16 +102,10 @@ export class DialogHistoryManager { if (dialogClip) { // Find the objective to get its title - const objective = objectives.find(obj => obj.id === objectiveId); + const objective = objectives.find((obj) => obj.id === objectiveId); const title = objective?.title ?? objectiveId; - this.addEntry( - dialogClip.text, - dialogClip.character, - dialogClip.audioUrl, - title, - dialogClip.emotion - ); + this.addEntry(dialogClip.text, dialogClip.character, dialogClip.audioUrl, title, dialogClip.emotion); } } } diff --git a/src/modal/dialog-manager.css b/src/modal/dialog-manager.css index 51c853e1..2bf88aa9 100644 --- a/src/modal/dialog-manager.css +++ b/src/modal/dialog-manager.css @@ -21,8 +21,10 @@ width: 100%; height: 33.33vh; /* Bottom third of 1920x1080 */ - background: #181818; - border-top: 2px solid #444; + /* Campaign-overridable, defaulting to the original literals so campaigns + that do not declare these are unchanged */ + background: var(--mc-dialog-bg, #181818); + border-top: 2px solid var(--mc-dialog-border, #444); box-shadow: 0 -6px 28px rgba(0, 0, 0, 0.6); display: flex; justify-content: space-evenly; @@ -57,9 +59,9 @@ margin-top: -200px; border-radius: 50%; overflow: hidden; - background: #2a2a2a; - border: 3px solid #691915; - box-shadow: 0 4px 12px rgba(255, 53, 53, 0.3); + background: var(--mc-dialog-inset, #2a2a2a); + border: 3px solid var(--mc-dialog-avatar-ring, #691915); + box-shadow: 0 4px 12px var(--mc-dialog-avatar-glow, rgba(255, 53, 53, 0.3)); } .dialog-avatar img { @@ -85,7 +87,7 @@ } .dialog-character-title { - color: #ff3535; + color: var(--mc-accent-red-bright, #ff3535); font-size: 1rem; font-weight: 500; letter-spacing: 0.3px; @@ -108,17 +110,17 @@ } .dialog-text-container::-webkit-scrollbar-track { - background: #2a2a2a; + background: var(--mc-dialog-inset, #2a2a2a); border-radius: 4px; } .dialog-text-container::-webkit-scrollbar-thumb { - background: #ff3535; + background: var(--mc-accent-red-bright, #ff3535); border-radius: 4px; } .dialog-text-container::-webkit-scrollbar-thumb:hover { - background: #ff2827; + background: var(--mc-accent-red-bright, #ff2827); } /* Dialog Text Content */ @@ -176,7 +178,7 @@ .dialog-skip-progress-fill { fill: none; - stroke: #ff3535; + stroke: var(--mc-accent-red-bright, #ff3535); stroke-width: 2; stroke-linecap: round; stroke-dasharray: 100.53; diff --git a/src/modal/dialog-manager.ts b/src/modal/dialog-manager.ts index 92b66450..4e9e7731 100644 --- a/src/modal/dialog-manager.ts +++ b/src/modal/dialog-manager.ts @@ -40,7 +40,7 @@ export class DialogManager { private dialogQueue_: QueuedDialog[] = []; private isTtsActive_ = false; - private constructor() { } + private constructor() {} static getInstance(): DialogManager { if (!DialogManager.instance) { diff --git a/src/modal/draggable-html-box.ts b/src/modal/draggable-html-box.ts index c2b234d1..3f8da9c4 100644 --- a/src/modal/draggable-html-box.ts +++ b/src/modal/draggable-html-box.ts @@ -1,7 +1,7 @@ -import { DraggableBox } from "@app/engine/ui/draggable-box"; -import { html } from "@app/engine/utils/development/formatter"; -import { getEl } from "@app/engine/utils/get-el"; -import { ObjectivesManager } from "@app/objectives/objectives-manager"; +import { DraggableBox } from '@app/engine/ui/draggable-box'; +import { html } from '@app/engine/utils/development/formatter'; +import { getEl } from '@app/engine/utils/get-el'; +import { ObjectivesManager } from '@app/objectives/objectives-manager'; import './draggable-html-box.css'; export class DraggableHtmlBox extends DraggableBox { @@ -10,16 +10,15 @@ export class DraggableHtmlBox extends DraggableBox { onClose: () => void; constructor(title: string, id: string, url?: string, parentId = 'sandbox-page') { - super(`draggable-html-box-${id}`, - { - title, - parentId, - boxContentHtml: html` + super(`draggable-html-box-${id}`, { + title, + parentId, + boxContentHtml: html`
${url ? `` : ''}
- `.trim() - }); + `.trim(), + }); this.popupDom = getEl(`draggable-html-box-content-${id}`); diff --git a/src/modal/hint-modal.css b/src/modal/hint-modal.css index ea80d2a3..fbcd2f48 100644 --- a/src/modal/hint-modal.css +++ b/src/modal/hint-modal.css @@ -90,12 +90,12 @@ } .hint-btn-confirm { - background-color: var(--mc-accent-primary, #ba160c); + background-color: var(--mc-accent-red, #ba160c); color: white; } .hint-btn-confirm:hover { - background-color: var(--mc-accent-hover, #cb1d14); + background-color: var(--mc-accent-red-light, #cb1d14); } /* Hint display styles */ @@ -114,7 +114,7 @@ background-color: var(--mc-bg-secondary, #343a40); padding: 1rem; border-radius: 4px; - border-left: 3px solid var(--mc-accent-primary, #ba160c); + border-left: 3px solid var(--mc-accent-red, #ba160c); text-align: left; margin: 0; } \ No newline at end of file diff --git a/src/modal/hint-modal.ts b/src/modal/hint-modal.ts index 89ab361e..da66441f 100644 --- a/src/modal/hint-modal.ts +++ b/src/modal/hint-modal.ts @@ -3,12 +3,12 @@ * @description Modal that displays condition hints with penalty warning */ -import { EventBus } from '@app/events/event-bus'; -import { Events, HintShownData } from '@app/events/events'; +import bulbPng from '@app/assets/icons/bulb.png'; import { DraggableModal } from '@app/engine/ui/draggable-modal'; import { html } from '@app/engine/utils/development/formatter'; import { getEl } from '@app/engine/utils/get-el'; -import bulbPng from '@app/assets/icons/bulb.png'; +import { EventBus } from '@app/events/event-bus'; +import { Events, HintShownData } from '@app/events/events'; import { HintManager } from './hint-manager'; import './hint-modal.css'; @@ -48,13 +48,7 @@ export class HintModal extends DraggableModal { /** * Show the hint confirmation modal */ - showConfirmation( - objectiveId: string, - conditionIndex: number, - hint: string, - penaltyPoints: number, - objectiveTitle: string - ): void { + showConfirmation(objectiveId: string, conditionIndex: number, hint: string, penaltyPoints: number, objectiveTitle: string): void { this.currentState_ = { objectiveId, conditionIndex, @@ -72,11 +66,7 @@ export class HintModal extends DraggableModal { /** * Show the hint directly without confirmation (for already-revealed hints) */ - showHintDirectly( - objectiveId: string, - conditionIndex: number, - hint: string - ): void { + showHintDirectly(objectiveId: string, conditionIndex: number, hint: string): void { this.currentState_ = { objectiveId, conditionIndex, @@ -171,17 +161,12 @@ export class HintModal extends DraggableModal { if (!this.currentState_) return; // Request the hint through HintManager (this emits HINT_REQUESTED and HINT_SHOWN) - HintManager.getInstance().requestHint( - this.currentState_.objectiveId, - this.currentState_.conditionIndex - ); + HintManager.getInstance().requestHint(this.currentState_.objectiveId, this.currentState_.conditionIndex); } private handleHintShown_(data: HintShownData): void { // Update state with revealed hint and show it - if (this.currentState_ && - this.currentState_.objectiveId === data.objectiveId && - this.currentState_.conditionIndex === data.conditionIndex) { + if (this.currentState_ && this.currentState_.objectiveId === data.objectiveId && this.currentState_.conditionIndex === data.conditionIndex) { this.renderHint_(); } } diff --git a/src/modal/level-complete-modal.css b/src/modal/level-complete-modal.css index f729ae41..336428a1 100644 --- a/src/modal/level-complete-modal.css +++ b/src/modal/level-complete-modal.css @@ -91,6 +91,36 @@ margin-bottom: 1.25rem; } +.complete-modal__signup { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem 1rem; + margin-bottom: 1.25rem; + border: 1px solid var(--hm-muted, #adb5bd); + border-radius: 4px; + text-align: left; +} + +.complete-modal__signup-text { + font-size: 0.85rem; + color: var(--hm-muted, #adb5bd); + line-height: 1.4; +} + +.complete-modal__signup-text--saved { + color: #28a745; +} + +.complete-modal__signup .btn-primary { + width: 100%; + padding: 0.6rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; +} + .complete-modal__actions { display: flex; flex-direction: column; @@ -124,3 +154,34 @@ .complete-modal__actions .btn-success:hover { background-color: #218838; } + +/* Judgements list (decision conditions) */ +.complete-modal__decisions { + margin-top: 0.75rem; + padding-top: 0.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.12); +} + +.decision-summary-list { + list-style: none; + margin: 0.35rem 0 0; + padding: 0; + font-size: 0.85rem; +} + +.decision-summary-item { + display: flex; + gap: 0.5rem; + align-items: baseline; + padding: 0.15rem 0; +} + +.decision-summary-mark { + font-family: monospace; + min-width: 1.4em; +} + +.decision-summary-note { + opacity: 0.7; + font-size: 0.8rem; +} diff --git a/src/modal/level-complete-modal.ts b/src/modal/level-complete-modal.ts index 8cc24473..f9e8c6ef 100644 --- a/src/modal/level-complete-modal.ts +++ b/src/modal/level-complete-modal.ts @@ -2,10 +2,13 @@ import { DraggableModal } from '@app/engine/ui/draggable-modal'; import { html } from '@app/engine/utils/development/formatter'; import { Logger } from '@app/logging/logger'; import { Router } from '@app/router'; -import { ScoreCalculator, type ScoreBreakdown } from '@app/scoring/score-calculator'; +import { type ScoreBreakdown, ScoreCalculator } from '@app/scoring/score-calculator'; import { SimulationManager } from '@app/simulation/simulation-manager'; import { clearPersistedStore } from '@app/sync/storage'; +import { Auth } from '@app/user-account/auth'; +import { ModalLogin } from '@app/user-account/modal-login'; import { getUserDataService } from '@app/user-account/user-data-service'; +import type { DecisionRecord } from './decision-manager'; import { DialogManager } from './dialog-manager'; import './level-complete-modal.css'; import { PendingQuizIndicator } from './pending-quiz-indicator'; @@ -16,6 +19,15 @@ interface CompletionModalOptions { elapsedTimeSeconds: number; campaignId: string; scenarioId: string; + /** + * Whether the player was signed in when the scenario completed. When false + * the modal shows the sign-up funnel: progress only persists to an account, + * so this is the moment to convert. Omitted (replay flow) = treated as + * signed in, no funnel shown. + */ + isAuthenticated?: boolean; + /** Decisions made during the scenario, listed under the score breakdown */ + decisions?: DecisionRecord[]; } export class LevelCompleteModal extends DraggableModal { @@ -29,6 +41,7 @@ export class LevelCompleteModal extends DraggableModal { quizPenalties: 0, timePenalties: 0, hintPenalties: 0, + decisionPenalties: 0, totalScore: 0, objectiveBreakdown: [], timeRemainingSeconds: 0, @@ -40,6 +53,7 @@ export class LevelCompleteModal extends DraggableModal { private onContinueCallback_: (() => void | Promise) | null = null; private isReplayMode_: boolean = false; + private authSubscription_: { data: { subscription: { unsubscribe: () => void } } } | null = null; private constructor() { if (LevelCompleteModal.instance_) { @@ -78,41 +92,71 @@ export class LevelCompleteModal extends DraggableModal { +${score.basePoints}
${this.formatObjectivesDetail_(score.objectiveBreakdown)}
- ${score.timeBonus > 0 ? ` + ${ + score.timeBonus > 0 + ? `
Time Bonus +${score.timeBonus}
${score.timeRemainingSeconds} seconds remaining / ${ScoreCalculator.TIME_BONUS_DIVISOR}
- ` : ''} - ${score.quizPenalties > 0 ? ` + ` + : '' + } + ${ + score.quizPenalties > 0 + ? `
Quiz Penalties -${score.quizPenalties}
${score.quizPenalties} points deducted
- ` : ''} - ${score.timePenalties > 0 ? ` + ` + : '' + } + ${ + score.timePenalties > 0 + ? `
Time Penalties -${score.timePenalties}
${score.timePenalties} points deducted
- ` : ''} - ${score.hintPenalties > 0 ? ` + ` + : '' + } + ${ + score.hintPenalties > 0 + ? `
Hint Penalties -${score.hintPenalties}
${score.hintPenalties} points deducted for hints used
- ` : ''} + ` + : '' + } + ${ + score.decisionPenalties > 0 + ? ` +
+ Decision Penalties + -${score.decisionPenalties} +
+
${score.decisionPenalties} points deducted for wrong or unevidenced calls
+ ` + : '' + }
+ ${this.renderDecisions_()}
${this.isReplayMode_ ? 'Previously completed' : `Completed in ${elapsedFormatted}`}
+ ${this.renderSignUpSection_()} +
${this.isReplayMode_ ? '' : ''} @@ -121,6 +165,47 @@ export class LevelCompleteModal extends DraggableModal { `; } + /** + * Sign-up funnel shown when the scenario was completed while signed out. + * Progress only persists to an account, so this completion (and the unlock + * it earns) is lost unless the player signs in before moving on. + */ + private renderSignUpSection_(): string { + if (this.isReplayMode_ || this.options_.isAuthenticated !== false) { + return ''; + } + + return html` + + `; + } + + /** Swap the sign-up prompt for a confirmation once the player signs in */ + private handleSignedInWhileOpen_(): void { + const section = this.boxEl?.querySelector('#complete-signup-section'); + + if (section) { + section.innerHTML = html` + + `; + } + this.unsubscribeAuth_(); + } + + private unsubscribeAuth_(): void { + this.authSubscription_?.data.subscription.unsubscribe(); + this.authSubscription_ = null; + } + protected override onOpen(): void { super.onOpen(); @@ -139,6 +224,9 @@ export class LevelCompleteModal extends DraggableModal { const playAgainBtn = this.boxEl?.querySelector('#play-again-btn'); playAgainBtn?.addEventListener('click', () => this.handlePlayAgain_()); + + const signUpBtn = this.boxEl?.querySelector('#signup-save-btn'); + signUpBtn?.addEventListener('click', () => ModalLogin.getInstance().open()); } private async handleContinue_(): Promise { @@ -164,10 +252,7 @@ export class LevelCompleteModal extends DraggableModal { // Reset progress (preserves completedAt so prerequisites stay unlocked) and clear checkpoint try { const userDataService = getUserDataService(); - await Promise.all([ - userDataService.resetScenarioForReplay(scenarioId), - userDataService.deleteCheckpoint(scenarioId), - ]); + await Promise.all([userDataService.resetScenarioForReplay(scenarioId), userDataService.deleteCheckpoint(scenarioId)]); Logger.info(`Reset progress and cleared checkpoint for Play Again: ${scenarioId}`); } catch (error) { Logger.error('Failed to reset progress for Play Again:', error); @@ -185,6 +270,7 @@ export class LevelCompleteModal extends DraggableModal { * Actually close the modal (bypasses the override that prevents X/backdrop close) */ private forceClose_(): void { + this.unsubscribeAuth_(); super.close(); } @@ -226,11 +312,45 @@ export class LevelCompleteModal extends DraggableModal { * @param onContinue Callback when Continue button is clicked * @param isReplay True if showing for an already-completed scenario (adds Play Again button) */ + /** The judgements the player made, when the scenario had any decisions */ + private renderDecisions_(): string { + const decisions = this.options_.decisions ?? []; + if (decisions.length === 0) return ''; + + const rows = decisions + .map((d) => { + let mark = '✗'; + if (d.resolution.correct) mark = d.resolution.evidenced ? '✓' : '✓ ?'; + const note = d.resolution.evidenced ? '' : ` (decided before checking: ${d.resolution.missingEvidence.join(', ')})`; + const tries = d.attempts > 1 ? ` (${d.attempts} attempts)` : ''; + return `
  • ${mark}${d.prompt} → ${d.resolution.label}${tries}${note}
  • `; + }) + .join(''); + + return ` +
    +
    Judgements
    +
      ${rows}
    +
    + `; + } + showCompletion(options: CompletionModalOptions, onContinue?: () => void | Promise, isReplay?: boolean): void { this.options_ = options; this.onContinueCallback_ = onContinue ?? null; this.isReplayMode_ = isReplay ?? false; + // While the sign-up funnel is showing, react to a sign-in immediately + // (the actual save is handled by ScenarioCompletionHandler's auth flush) + this.unsubscribeAuth_(); + if (!this.isReplayMode_ && options.isAuthenticated === false) { + this.authSubscription_ = Auth.onAuthStateChange((_event, _user, _profile, accessToken) => { + if (accessToken) { + this.handleSignedInWhileOpen_(); + } + }); + } + // Close any open popups before showing completion modal this.closeAllPopups_(); diff --git a/src/modal/modal-manager.css b/src/modal/modal-manager.css index e30dd85a..5ef7b541 100644 --- a/src/modal/modal-manager.css +++ b/src/modal/modal-manager.css @@ -15,10 +15,10 @@ --hm-bg-end: #1f1f1f; --hm-text: #eaeaea; --hm-muted: #bfc3c6; - --hm-accent: #ff3535; - --hm-accent-2: #ff2827; + --hm-accent: var(--mc-accent-red-bright, #ff3535); + --hm-accent-2: var(--mc-accent-red-bright, #ff2827); /* subtle application theme color for accents */ - --hm-theme: #ba160c; + --hm-theme: var(--mc-accent-red, #ba160c); --hm-border: #444; --hm-radius: 10px; --hm-max-width: 1000px; @@ -207,13 +207,13 @@ color: var(--hm-text); font-size: 0.8rem; font-weight: 700; - border: 1px solid rgba(186, 22, 12, 0.14); + border: 1px solid rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.14); vertical-align: middle; } strong.red { background: var(--hm-theme); - border-color: rgba(255, 40, 39, 0.14); + border-color: rgba(var(--mc-accent-red-bright-rgb, 255, 40, 39), 0.14); } strong.green { diff --git a/src/modal/modal-manager.ts b/src/modal/modal-manager.ts index 35d47ab6..8362b15a 100644 --- a/src/modal/modal-manager.ts +++ b/src/modal/modal-manager.ts @@ -8,7 +8,7 @@ export class ModalManager { private activeTitle: string | null = null; private onHideCallbacks: Array<() => void> = []; - private constructor() { } + private constructor() {} static getInstance(): ModalManager { if (!ModalManager.instance) { diff --git a/src/modal/objective-failed-modal.ts b/src/modal/objective-failed-modal.ts index 26dbd3a0..a080a94c 100644 --- a/src/modal/objective-failed-modal.ts +++ b/src/modal/objective-failed-modal.ts @@ -1,12 +1,12 @@ -import { html } from '@app/engine/utils/development/formatter'; +import stopwatchPng from '@app/assets/icons/stopwatch.png'; import { DraggableModal } from '@app/engine/ui/draggable-modal'; +import { html } from '@app/engine/utils/development/formatter'; import { ScenarioManager } from '@app/scenario-manager'; import { clearPersistedStore } from '@app/sync/storage'; import { ProgressSaveManager } from '@app/user-account/progress-save-manager'; import { DialogManager } from './dialog-manager'; import { PendingQuizIndicator } from './pending-quiz-indicator'; import { QuizModal } from './quiz-modal'; -import stopwatchPng from '@app/assets/icons/stopwatch.png'; import './objective-failed-modal.css'; interface FailureModalOptions { @@ -47,9 +47,7 @@ export class ObjectiveFailedModal extends DraggableModal { } protected getModalContentHtml(): string { - const checkpointButton = this.hasCheckpoint_ - ? `` - : ''; + const checkpointButton = this.hasCheckpoint_ ? `` : ''; // Make Restart Scenario primary when it's the only option const scenarioButtonClass = this.hasCheckpoint_ ? 'btn btn-secondary' : 'btn btn-primary'; diff --git a/src/modal/panel-manager.ts b/src/modal/panel-manager.ts index 84948112..d80b55cb 100644 --- a/src/modal/panel-manager.ts +++ b/src/modal/panel-manager.ts @@ -10,7 +10,7 @@ export class PanelManager { private activeTitle: string | null = null; private onHideCallbacks: Array<() => void> = []; - private constructor() { } + private constructor() {} static getInstance(): PanelManager { if (!PanelManager.instance) { diff --git a/src/modal/pending-quiz-indicator.ts b/src/modal/pending-quiz-indicator.ts index 41971c59..854669bc 100644 --- a/src/modal/pending-quiz-indicator.ts +++ b/src/modal/pending-quiz-indicator.ts @@ -4,7 +4,20 @@ */ import { EventBus } from '@app/events/event-bus'; -import { Events, QuizCompletedData, QuizDismissedData, QuizPassedData, QuizPendingData, QuizShowData } from '@app/events/events'; +import { + DecisionDismissedData, + DecisionGradedData, + DecisionPendingData, + DecisionResolvedData, + DecisionShowData, + Events, + QuizCompletedData, + QuizDismissedData, + QuizPassedData, + QuizPendingData, + QuizShowData, +} from '@app/events/events'; +import { DecisionManager } from './decision-manager'; import './pending-quiz-indicator.css'; import { QuizManager } from './quiz-manager'; @@ -29,6 +42,11 @@ export class PendingQuizIndicator { private readonly boundQuizCompletedHandler_: (data: QuizCompletedData) => void; private readonly boundQuizPendingHandler_: (data: QuizPendingData) => void; private readonly boundQuizPassedHandler_: (data: QuizPassedData) => void; + private readonly boundDecisionShowHandler_: (data: DecisionShowData) => void; + private readonly boundDecisionDismissedHandler_: (data: DecisionDismissedData) => void; + private readonly boundDecisionPendingHandler_: (data: DecisionPendingData) => void; + private readonly boundDecisionGradedHandler_: (data: DecisionGradedData) => void; + private readonly boundDecisionResolvedHandler_: (data: DecisionResolvedData) => void; private constructor() { this.boundQuizShowHandler_ = this.handleQuizShow_.bind(this); @@ -36,6 +54,11 @@ export class PendingQuizIndicator { this.boundQuizCompletedHandler_ = this.handleQuizCompleted_.bind(this); this.boundQuizPendingHandler_ = this.handleQuizPending_.bind(this); this.boundQuizPassedHandler_ = this.handleQuizPassed_.bind(this); + this.boundDecisionShowHandler_ = this.handleDecisionShow_.bind(this); + this.boundDecisionDismissedHandler_ = this.handleDecisionDismissed_.bind(this); + this.boundDecisionPendingHandler_ = this.handleDecisionPending_.bind(this); + this.boundDecisionGradedHandler_ = this.handleDecisionGraded_.bind(this); + this.boundDecisionResolvedHandler_ = this.handleDecisionResolved_.bind(this); this.createIndicatorElement_(); this.setupEventListeners_(); @@ -81,6 +104,11 @@ export class PendingQuizIndicator { eventBus.on(Events.QUIZ_COMPLETED, this.boundQuizCompletedHandler_); eventBus.on(Events.QUIZ_PENDING, this.boundQuizPendingHandler_); eventBus.on(Events.QUIZ_PASSED, this.boundQuizPassedHandler_); + eventBus.on(Events.DECISION_SHOW, this.boundDecisionShowHandler_); + eventBus.on(Events.DECISION_DISMISSED, this.boundDecisionDismissedHandler_); + eventBus.on(Events.DECISION_PENDING, this.boundDecisionPendingHandler_); + eventBus.on(Events.DECISION_GRADED, this.boundDecisionGradedHandler_); + eventBus.on(Events.DECISION_RESOLVED, this.boundDecisionResolvedHandler_); } /** @@ -139,9 +167,46 @@ export class PendingQuizIndicator { } private handleOpenClick_(): void { + if (DecisionManager.hasInstance() && DecisionManager.getInstance().hasPending()) { + DecisionManager.getInstance().reopenPending(); + return; + } QuizManager.getInstance().reopenPendingQuiz(); } + // ── Decision conditions share the indicator ───────────────────────────── + + private handleDecisionShow_(_data: DecisionShowData): void { + this.cancelPendingTimeout_(); + this.hide_(); + } + + private handleDecisionDismissed_(_data: DecisionDismissedData): void { + this.updateMessage_('Make the call to continue'); + this.show_(); + } + + private handleDecisionPending_(_data: DecisionPendingData): void { + this.cancelPendingTimeout_(); + this.pendingShowTimeout_ = window.setTimeout(() => { + this.updateMessage_('Make the call to continue'); + this.show_(); + this.pendingShowTimeout_ = null; + }, PendingQuizIndicator.INITIAL_DELAY_MS); + } + + /** A correct answer hides the indicator before Continue is pressed */ + private handleDecisionGraded_(data: DecisionGradedData): void { + if (!data.correct) return; + this.cancelPendingTimeout_(); + this.hide_(); + } + + private handleDecisionResolved_(_data: DecisionResolvedData): void { + this.cancelPendingTimeout_(); + this.hide_(); + } + private updateMessage_(message: string): void { if (this.messageElement_) { this.messageElement_.textContent = message; diff --git a/src/modal/quiz-modal.ts b/src/modal/quiz-modal.ts index 49c1da2c..0718d2aa 100644 --- a/src/modal/quiz-modal.ts +++ b/src/modal/quiz-modal.ts @@ -1,16 +1,20 @@ +import { Rng } from '@app/simulation/rng'; /** * @file Quiz Modal - Interactive quiz for status-check objective conditions * @description Non-modal draggable box that presents multiple choice questions to verify player understanding */ -import { EventBus } from '@app/events/event-bus'; -import { Events, QuizAnsweredData, QuizCompletedData, QuizDismissedData, QuizPassedData, QuizShowData } from '@app/events/events'; import { DraggableBox } from '@app/engine/ui/draggable-box'; import { html } from '@app/engine/utils/development/formatter'; import { getEl, showEl } from '@app/engine/utils/get-el'; +import { EventBus } from '@app/events/event-bus'; +import { Events, QuizAnsweredData, QuizCompletedData, QuizDismissedData, QuizPassedData, QuizShowData } from '@app/events/events'; import { Character, CharacterAvatars, CharacterNames, Emotion, getCharacterAvatarUrl } from './character-enum'; import './quiz-modal.css'; +/** Seeded draws for this module (see simulation/rng.ts). */ +const random = (): number => Rng.stream('ui:quiz').next(); + /** * Singleton non-modal box for presenting status-check quizzes * Unlike a modal, this allows interaction with background elements @@ -72,7 +76,9 @@ export class QuizModal extends DraggableBox { const parentDom = document.getElementsByTagName('body')[0]; - parentDom.insertAdjacentHTML('beforeend', html` + parentDom.insertAdjacentHTML( + 'beforeend', + html` - ${this.currentQuiz_.explanation ? html` + ${ + this.currentQuiz_.explanation + ? html` - ` : ''} + ` + : '' + } `; feedbackEl.style.display = 'block'; @@ -505,7 +516,7 @@ export class QuizModal extends DraggableBox { if (count === 1 || this.currentQuiz_.preserveOptionOrder) return indices; for (let i = indices.length - 1; i > 0; i--) { - const j = Math.floor(Math.random() * (i + 1)); + const j = Math.floor(random() * (i + 1)); [indices[i], indices[j]] = [indices[j], indices[i]]; } return indices; diff --git a/src/objectives/consequence-dispatcher.ts b/src/objectives/consequence-dispatcher.ts new file mode 100644 index 00000000..9778bdd3 --- /dev/null +++ b/src/objectives/consequence-dispatcher.ts @@ -0,0 +1,173 @@ +/** + * @file Consequence dispatcher - applies what a decision option does + * @description Every key on a DecisionConsequence maps onto a subsystem that + * already exists (fault injector, interference schedule, audit log, objective + * activation, ops log). Nothing here models anything new; it only routes. + * + * A per-scenario budget caps how many injected faults and forced interference + * events can be live at once, so a run of wrong decisions cannot cascade a + * maintained objective into an unrecoverable state. + */ + +import { FAULT_TEMPLATES, FaultInjector, FaultTemplateKey } from '@app/faults'; +import { InterferenceManager } from '@app/interference/interference-manager'; +import { OpsLogManager } from '@app/ops-log/ops-log-manager'; +import { CampaignDocumentStore } from '@app/scenarios/campaign-document-store'; +import { SecurityConsoleCore } from '@app/security-console/security-console-core'; +import type { DecisionConsequence } from './objective-types'; +import { ObjectivesManager } from './objectives-manager'; + +export interface ConsequenceContext { + /** Ground station the deciding objective belongs to (faults need one) */ + groundStationId?: string; +} + +export interface ConsequenceResult { + /** Keys that were applied */ + applied: string[]; + /** Keys skipped, with the reason */ + skipped: { key: string; reason: string }[]; + /** Net point adjustment requested by the option (positive = penalty) */ + pointDelta: number; +} + +export const DEFAULT_CONSEQUENCE_BUDGET = 2; + +export class ConsequenceDispatcher { + /** Faults this dispatcher injected and has not cleared */ + private readonly injectedFaultIds_ = new Set(); + /** Interference events this dispatcher forced on and has not released */ + private readonly forcedOnEventIds_ = new Set(); + + constructor(private readonly maxLiveInjections_ = DEFAULT_CONSEQUENCE_BUDGET) {} + + /** Injected faults plus forced-on interference currently live */ + get liveInjections(): number { + return this.injectedFaultIds_.size + this.forcedOnEventIds_.size; + } + + dispatch(consequence: DecisionConsequence, ctx: ConsequenceContext): ConsequenceResult { + const result: ConsequenceResult = { applied: [], skipped: [], pointDelta: consequence.pointDelta ?? 0 }; + + if (consequence.inject) this.inject_(consequence.inject, ctx, result); + if (consequence.clearFault) this.clearFault_(consequence.clearFault, result); + if (consequence.startInterference) this.forceInterference_(consequence.startInterference, true, result); + if (consequence.stopInterference) this.forceInterference_(consequence.stopInterference, false, result); + if (consequence.auditEvent) this.auditEvent_(consequence.auditEvent, result); + if (consequence.destroyEvidence) this.destroyEvidence_(consequence.destroyEvidence, result); + if (consequence.activateObjective) this.setObjectiveActive_(consequence.activateObjective, true, result); + if (consequence.deactivateObjective) this.setObjectiveActive_(consequence.deactivateObjective, false, result); + if (consequence.log && OpsLogManager.isInitialized()) { + OpsLogManager.getInstance().log(consequence.log, 'system', 'decision'); + result.applied.push('log'); + } + + return result; + } + + /** Release everything this dispatcher forced or injected (scenario teardown) */ + releaseAll(): void { + const faults = FaultInjector.getInstance(); + for (const id of this.injectedFaultIds_) faults.clear(id); + this.injectedFaultIds_.clear(); + + if (InterferenceManager.isInitialized()) { + const interference = InterferenceManager.getInstance(); + for (const id of this.forcedOnEventIds_) interference.forceEvent(id, null); + } + this.forcedOnEventIds_.clear(); + } + + private inject_(spec: NonNullable, ctx: ConsequenceContext, result: ConsequenceResult): void { + if (!ctx.groundStationId) { + result.skipped.push({ key: 'inject', reason: 'objective has no groundStation' }); + return; + } + if (!(spec.template in FAULT_TEMPLATES)) { + result.skipped.push({ key: 'inject', reason: `unknown fault template ${spec.template}` }); + return; + } + if (this.liveInjections >= this.maxLiveInjections_) { + result.skipped.push({ key: 'inject', reason: `consequence budget (${this.maxLiveInjections_}) reached` }); + return; + } + const id = FaultInjector.getInstance().injectTemplate(spec.template as FaultTemplateKey, ctx.groundStationId); + this.injectedFaultIds_.add(id); + result.applied.push('inject'); + } + + private clearFault_(faultId: string, result: ConsequenceResult): void { + if (FaultInjector.getInstance().clear(faultId)) { + this.injectedFaultIds_.delete(faultId); + result.applied.push('clearFault'); + } else { + result.skipped.push({ key: 'clearFault', reason: `no active fault ${faultId}` }); + } + } + + private forceInterference_(eventId: string, active: boolean, result: ConsequenceResult): void { + const key = active ? 'startInterference' : 'stopInterference'; + if (!InterferenceManager.isInitialized()) { + result.skipped.push({ key, reason: 'scenario declares no interference events' }); + return; + } + const interference = InterferenceManager.getInstance(); + if (!interference.getEvent(eventId)) { + result.skipped.push({ key, reason: `unknown interference event ${eventId}` }); + return; + } + if (active && !this.forcedOnEventIds_.has(eventId) && this.liveInjections >= this.maxLiveInjections_) { + result.skipped.push({ key, reason: `consequence budget (${this.maxLiveInjections_}) reached` }); + return; + } + interference.forceEvent(eventId, active); + if (active) { + this.forcedOnEventIds_.add(eventId); + } else { + this.forcedOnEventIds_.delete(eventId); + } + result.applied.push(key); + } + + private auditEvent_(event: NonNullable, result: ConsequenceResult): void { + if (!SecurityConsoleCore.isInitialized()) { + result.skipped.push({ key: 'auditEvent', reason: 'scenario has no security console' }); + return; + } + SecurityConsoleCore.getInstance().injectEvent(event); + result.applied.push('auditEvent'); + } + + private destroyEvidence_(spec: NonNullable, result: ConsequenceResult): void { + let removed = false; + if (spec.auditEventId && SecurityConsoleCore.isInitialized()) { + removed = SecurityConsoleCore.getInstance().removeEvent(spec.auditEventId) || removed; + } + if (spec.faultId) { + removed = FaultInjector.getInstance().clear(spec.faultId) || removed; + this.injectedFaultIds_.delete(spec.faultId); + } + if (removed) { + // Remembered in the campaign record: a later scenario carrying this + // evidence forward (requiresCampaignEvidence) finds it gone. + if (spec.auditEventId) CampaignDocumentStore.noteDestroyedEvidence(spec.auditEventId); + result.applied.push('destroyEvidence'); + } else { + result.skipped.push({ key: 'destroyEvidence', reason: 'nothing matched' }); + } + } + + private setObjectiveActive_(objectiveId: string, active: boolean, result: ConsequenceResult): void { + const key = active ? 'activateObjective' : 'deactivateObjective'; + if (!ObjectivesManager.hasInstance()) { + result.skipped.push({ key, reason: 'no objectives manager' }); + return; + } + const ok = active ? ObjectivesManager.getInstance().activateObjective(objectiveId) : ObjectivesManager.getInstance().deactivateObjective(objectiveId); + if (ok) { + result.applied.push(key); + } else { + result.skipped.push({ key, reason: `objective ${objectiveId} not in a state to ${active ? 'activate' : 'deactivate'}` }); + } + } +} diff --git a/src/objectives/evidence-facts.ts b/src/objectives/evidence-facts.ts new file mode 100644 index 00000000..ed4c6575 --- /dev/null +++ b/src/objectives/evidence-facts.ts @@ -0,0 +1,182 @@ +/** + * @file Evidence facts - the vocabulary a decision is graded in + * @description Pure reads over live simulation state. A decision option's + * `correctWhen` rule is composed from these, so the same option set can have + * a different right answer in a scenario with a different interference + * schedule or a different injected fault. Authors never write evaluators. + * + * Every fact is read through a hold (EvidenceFactRegistry): the raw value has + * to be stable for `holdSeconds` before the held value flips. The 1 Hz LEO + * position step already forced the same discipline onto C/N reads + * (cnHoldSeconds) and the observation dwell; without it a decision's right + * answer could change under the player's cursor. + */ + +import type { GroundStation } from '@app/assets/ground-station/ground-station'; +import { CommandingManager } from '@app/commanding/commanding-manager'; +import { CryptoModule } from '@app/equipment/crypto'; +import { FaultInjector } from '@app/faults'; +import { GnssThreatManager } from '@app/gnss-threat/gnss-threat-manager'; +import { InterferenceManager } from '@app/interference/interference-manager'; +import { SecurityConsoleCore } from '@app/security-console/security-console-core'; +import { TelemetryManager } from '@app/telemetry/telemetry-manager'; +import { WeatherManager } from '@app/weather/weather-manager'; +import { DecisionFactRule, EVIDENCE_FACT_IDS, EvidenceFactId, OBSERVATION_DWELL_GRACE_SECONDS } from './objective-types'; + +export interface EvidenceContext { + /** The deciding objective's ground station, when it has one */ + gs: GroundStation | null; +} + +export type EvidenceFactResolver = (ctx: EvidenceContext) => boolean; + +/** Weather has to be degrading the link by at least this much to be called the cause */ +export const WEATHER_DOMINANT_DB = 3; + +const anyGpsdo = (ctx: EvidenceContext, check: (state: GroundStation['rfFrontEnds'][number]['gpsdoModule']['state']) => boolean): boolean => + ctx.gs?.rfFrontEnds.some((fe) => check(fe.gpsdoModule.state)) ?? false; + +/** + * An interference event is in progress: inside its scheduled envelope, whether + * or not this instant is an "on" phase of its duty cycle. A jammer that cycles + * 60 s on / 30 s off is one incident, and a call about it must not flip every + * half minute. + */ +const interferenceActive = (): boolean => InterferenceManager.isInitialized() && InterferenceManager.getInstance().isAnyEventInEnvelope(); + +/** Same envelope read, split by how the interferer reaches the player */ +const interferenceActiveVia = (path: 'transponder' | 'terrestrial'): boolean => + InterferenceManager.isInitialized() && InterferenceManager.getInstance().isAnyEventInEnvelopeVia(path); + +const faultActive = (ctx: EvidenceContext): boolean => ctx.gs !== null && FaultInjector.getInstance().hasFaults(ctx.gs.state.id); + +/** + * The resolvers, keyed by fact id. Each is safe to call before the subsystem + * it reads exists: an absent subsystem reads as "nothing wrong". + */ +export const EVIDENCE_FACTS: Record = { + 'interference-active': () => interferenceActive(), + + 'equipment-fault-active': (ctx) => faultActive(ctx), + + 'crypto-intact': () => { + if (!CryptoModule.hasInstance()) return true; + const rx = CryptoModule.getInstance().getRxState(); + if (rx.decryptionMode !== 'ACTIVE') return rx.decryptionKeyStatus === 'Valid'; + return rx.decryptionKeyStatus === 'Valid' && rx.decryptionAuthTagVerified; + }, + + 'gnss-constellation-healthy': (ctx) => anyGpsdo(ctx, (s) => s.isPowered && s.gnssSignalPresent && s.satelliteCount >= 4), + + 'timing-drifting': () => GnssThreatManager.isInitialized() && GnssThreatManager.getInstance().isExposedToSpoof, + + 'reference-in-holdover': (ctx) => anyGpsdo(ctx, (s) => s.isPowered && s.isInHoldover), + + 'weather-attenuation-dominant': (ctx) => { + if (!ctx.gs || !WeatherManager.hasInstance()) return false; + if (interferenceActive() || faultActive(ctx)) return false; + return WeatherManager.getInstance() + .getActiveWeatherEvents(ctx.gs.state.id) + .some((e) => e.linkMarginDegradation >= WEATHER_DOMINANT_DB); + }, + + 'audit-anomaly-present': () => SecurityConsoleCore.isInitialized() && SecurityConsoleCore.getInstance().hasUnacknowledgedAnomaly(), + + 'config-drifted': () => { + if (!SecurityConsoleCore.isInitialized()) return false; + const core = SecurityConsoleCore.getInstance(); + return core.getVisibleLog().some((e) => e.category === 'config' && e.isAnomaly === true && !core.isEventAcknowledged(e.id)); + }, + + 'command-window-open': () => CommandingManager.isInitialized() && CommandingManager.getInstance().isWindowOpen(), + + 'evidence-chain-intact': () => !SecurityConsoleCore.isInitialized() || SecurityConsoleCore.getInstance().droppedEvidence.length === 0, + + 'soh-red-limit': () => TelemetryManager.isInitialized() && TelemetryManager.getInstance().hasBand('red'), + + 'soh-yellow-limit': () => TelemetryManager.isInitialized() && TelemetryManager.getInstance().hasBand('yellow'), + + 'telemetry-stale': () => TelemetryManager.isInitialized() && TelemetryManager.getInstance().isStale, + + 'uplink-jammed': () => CommandingManager.isInitialized() && CommandingManager.getInstance().isUplinkJammed(), + + 'transponder-interference-active': () => interferenceActiveVia('transponder'), + + 'terrestrial-interference-active': () => interferenceActiveVia('terrestrial'), +}; + +/** Evaluate a rule against a fact reader (held reads in production, stubs in tests) */ +export function evaluateFactRule(rule: DecisionFactRule, read: (id: EvidenceFactId) => boolean): boolean { + if ('fact' in rule) return read(rule.fact) === rule.is; + if ('all' in rule) return rule.all.every((r) => evaluateFactRule(r, read)); + return rule.any.some((r) => evaluateFactRule(r, read)); +} + +interface HeldFact { + /** The value consumers see */ + value: boolean; + /** The raw value the hold is currently waiting on */ + pendingValue: boolean; + /** How long the raw value has disagreed with `value` */ + pendingSeconds: number; +} + +/** + * Holds every fact so a transient frame cannot flip a decision's right + * answer. The first tick adopts the raw value without a hold, so a scenario + * that opens under interference reads true from its first frame. + */ +export class EvidenceFactRegistry { + private readonly held_ = new Map(); + + constructor( + private readonly holdSeconds_: number = OBSERVATION_DWELL_GRACE_SECONDS, + private readonly resolvers_: Record = EVIDENCE_FACTS + ) {} + + /** Advance every hold by dtSeconds against the current state */ + tick(dtSeconds: number, ctx: EvidenceContext): void { + for (const id of EVIDENCE_FACT_IDS) { + const raw = this.resolvers_[id](ctx); + const held = this.held_.get(id); + + if (!held) { + this.held_.set(id, { value: raw, pendingValue: raw, pendingSeconds: 0 }); + continue; + } + if (raw === held.value) { + held.pendingValue = raw; + held.pendingSeconds = 0; + continue; + } + if (raw !== held.pendingValue) { + held.pendingValue = raw; + held.pendingSeconds = 0; + } + held.pendingSeconds += dtSeconds; + if (held.pendingSeconds >= this.holdSeconds_) { + held.value = raw; + held.pendingSeconds = 0; + } + } + } + + /** Held value; false until the first tick */ + read(id: EvidenceFactId): boolean { + return this.held_.get(id)?.value ?? false; + } + + evaluate(rule: DecisionFactRule): boolean { + return evaluateFactRule(rule, (id) => this.read(id)); + } + + snapshot(): Record { + const out = {} as Record; + for (const id of EVIDENCE_FACT_IDS) out[id] = this.read(id); + return out; + } + + reset(): void { + this.held_.clear(); + } +} diff --git a/src/objectives/objective-types.ts b/src/objectives/objective-types.ts index 66ceff25..45d81edc 100644 --- a/src/objectives/objective-types.ts +++ b/src/objectives/objective-types.ts @@ -95,23 +95,142 @@ export type ConditionType = | 'tx-key-status' // TX encryption key status matches value // Fault injection conditions | 'fault-active' // Check if specific fault is currently injected - | 'fault-cleared'; // Check if specific fault has been cleared + | 'fault-cleared' // Check if specific fault has been cleared + // Geolocation conditions (Campaign 5) + | 'geolocation-measurements-collected' // >= N TDOA/FDOA captures collected + | 'geolocation-fix-accuracy' // Computed fix within N km of the emitter truth + | 'geolocation-ellipse-within' // Computed fix converged with a 95% error ellipse no wider than N km (semi-major) + | 'interference-event-ended' // A scripted interference event's envelope has closed (it ran and stopped) + // Electronic-attack / SATCOM denial conditions (Campaign 4) + | 'jamming-uplink-active' // Jam waveform radiating in the target uplink band + | 'jamming-effective' // J/S at the target transponder meets the denial threshold + // ── nats-eu (Campaign 2 European Operations) mechanics ────────────────────── + // M1 Link-budget / EIRP planning console + | 'link-budget-computed' // Operator's computed C/N worksheet matches truth within tolerance + | 'link-margin-met' // Applied link achieves margin at/above the required threshold + // M2 LEO uplink ops (uplink Doppler + TT&C commanding) + | 'uplink-doppler-comp-enabled' // Uplink Doppler compensation engaged on the command link + | 'command-acknowledged' // A TT&C command was sent inside a valid window and ACKed + // M5 Command-link auth / key ops (over the existing crypto model) + | 'key-rotation-completed' // A scheduled command-link key rotation has completed + | 'zeroize-executed' // The command-link key has been zeroized (emergency destruction) + // M3 Multi-station pass scheduling + | 'contact-assigned' // A pass/contact has been allocated to a ground station + | 'contact-plan-valid' // The contact plan has no conflicts and covers all required passes + // M4 Space-domain events (maneuvers / stale TLEs) + | 'ephemeris-updated' // Fresh ephemeris (TLE) has been loaded after a maneuver + // M6 SOC-lite security console (audit log + access control) + | 'audit-log-reviewed' // The station audit log has been opened and reviewed + | 'security-event-acknowledged' // A specific audit-log event has been acknowledged/flagged + | 'access-control-set' // A station account was moved to the target access state + // M7 TRANSEC anti-jam waveform + | 'transec-mode-set' // The modem TRANSEC waveform mode matches the target (fixed/hopping) + | 'transec-sync-locked' // The TRANSEC hop set is keyed and hop-sync is locked + // M8 GNSS spoofing / timing attack + | 'gpsdo-reference-mode-set' // The GPSDO reference/discipline mode matches the target + | 'gpsdo-time-offset-exceeds' // The GNSS-vs-reference timing offset has walked past a threshold + | 'gpsdo-time-offset-stable' // The timing offset has not moved for a hold period + // Campaign record (phase 18 D) + | 'campaign-document-reviewed' // The campaign record panel (earlier scenarios' Working Documents) has been opened + // Spacecraft telemetry and ranging (phase 18 E) + | 'telemetry-frames-received' // At least minFrames telemetry frames have arrived from the bird + | 'telemetry-channel-in-band' // A telemetry channel reads in the given limit band (green/yellow/red) + | 'telemetry-soh-nominal' // Every telemetry channel is green and the stream is fresh + | 'ranging-measurements' // At least minCount ranging measurements have been taken through the command path + // Campaign 3 SDR receive chain + | 'receiver-afc-enabled' // RX modem AFC state matches the target (on by default) + | 'antenna-polarization-set' // Antenna circular handedness matches the target + // ── D-tier assessment (phase 18) ───────────────────────────────────────────── + | 'decision'; // A judgement graded against live evidence facts, with consequences + +/** + * Evidence facts a `decision` option can be graded against. Each is a pure, + * held read over live simulation state - see evidence-facts.ts. The list is + * closed on purpose: authors compose rules from these, they never write + * evaluators. + */ +export const EVIDENCE_FACT_IDS = [ + 'interference-active', // a scripted interference event is in progress (inside its envelope, on or off phase) + 'equipment-fault-active', // the fault injector has a live fault on this station + 'crypto-intact', // rx key valid and auth tags verifying (true when crypto is not modelled) + 'gnss-constellation-healthy', // GNSS present with >= 4 satellites on the GPSDO + 'timing-drifting', // GNSS spoof active and the station is still trusting GNSS (the tell) + 'reference-in-holdover', // the GPSDO is in holdover + 'weather-attenuation-dominant', // an active weather event is the largest degradation present + 'audit-anomaly-present', // an anomalous audit-log entry is visible and unacknowledged + 'config-drifted', // an unacknowledged config-change anomaly is in the audit log + 'command-window-open', // a TT&C command window is open right now + 'uplink-jammed', // the command carrier is denied by interference on the target's uplink, with no TRANSEC sync + 'evidence-chain-intact', // no audit entry carried forward from an earlier scenario was lost to a destroyEvidence there + 'soh-red-limit', // a telemetry channel reads in its red band with the stream fresh + 'soh-yellow-limit', // a telemetry channel reads yellow or worse with the stream fresh + 'telemetry-stale', // the telemetry stream has no fresh frame (link down) + 'transponder-interference-active', // an interference event relayed through a satellite transponder is in progress + 'terrestrial-interference-active', // an interference event arriving at the dish directly from a ground emitter is in progress +] as const; + +export type EvidenceFactId = (typeof EVIDENCE_FACT_IDS)[number]; + +/** + * A boolean rule over evidence facts. `{ fact, is }` is a leaf; `all` / `any` + * combine. Evaluated at the moment the player answers, not when the decision + * is authored - the same option set can have a different right answer in a + * scenario with a different interference schedule. + */ +export type DecisionFactRule = { fact: EvidenceFactId; is: boolean } | { all: DecisionFactRule[] } | { any: DecisionFactRule[] }; + +/** + * What choosing an option does to the simulation. Every key dispatches to a + * subsystem that already exists; the consequence is the teaching, so wrong + * options usually carry one too. See consequence-dispatcher.ts. + */ +export interface DecisionConsequence { + /** Inject a fault from FAULT_TEMPLATES on this objective's ground station */ + inject?: { template: string; id?: string }; + /** Clear a previously injected fault by id */ + clearFault?: string; + /** Force a scripted interference event on, regardless of its schedule */ + startInterference?: string; + /** Force a scripted interference event off, regardless of its schedule */ + stopInterference?: string; + /** Add an entry to the security console audit log */ + auditEvent?: { + id: string; + actor: string; + action: string; + category: 'auth' | 'config' | 'command' | 'access'; + severity: 'info' | 'warning' | 'critical'; + isAnomaly?: boolean; + /** Wall-clock label shown in the log (e.g. "08:07 UTC"); defaults to the moment of injection */ + timestampLabel?: string; + }; + /** Remove evidence the player should have preserved (the 13.6 lesson) */ + destroyEvidence?: { auditEventId?: string; faultId?: string }; + /** Activate an objective now, bypassing its prerequisites */ + activateObjective?: string; + /** Deactivate an active objective (and reset its conditions) */ + deactivateObjective?: string; + /** Adjust the decision penalty tally by this many points (negative = award) */ + pointDelta?: number; + /** Line written to the ops log when this option is chosen */ + log?: string; +} + +export interface DecisionOption { + /** Option text. Keep all options in one shape and length band. */ + label: string; + /** When this option is the correct call. Omit for an option that is never correct. */ + correctWhen?: DecisionFactRule; + /** Fired when this option is chosen, right or wrong */ + consequence?: DecisionConsequence; + /** Shown after choosing this option; falls back to the decision's explanation */ + feedback?: string; +} /** * Equipment references for condition checking */ -export type EquipmentRef = - | 'antenna' - | 'gpsdo' - | 'buc' - | 'lnb' - | 'hpa' - | 'filter' - | 'coupler' - | 'omt' - | 'spectrum-analyzer' - | 'transmitter' - | 'receiver'; +export type EquipmentRef = 'antenna' | 'gpsdo' | 'buc' | 'lnb' | 'hpa' | 'filter' | 'coupler' | 'omt' | 'spectrum-analyzer' | 'transmitter' | 'receiver'; /** * Parameters for different condition types @@ -167,6 +286,22 @@ export interface ConditionParams { maxAmplitudeTolerance?: number; /** For custom conditions: custom evaluator function */ evaluator?: () => boolean; + + // ── decision ───────────────────────────────────────────────────────────── + /** For decision: the question put to the operator ("What is this?" / "What do you do?") */ + prompt?: string; + /** For decision: the options, graded against evidence facts when answered */ + decisionOptions?: DecisionOption[]; + /** + * For decision: ids of requiresObservation conditions in the same objective + * that must have latched before an answer counts as evidenced. Answering + * before they latch is allowed but scores partialCreditUnevidenced. + */ + evidence?: string[]; + /** For decision: fraction of full credit for a correct but unevidenced answer (default 0.5) */ + partialCreditUnevidenced?: number; + /** For decision: skip option shuffling (default false) */ + preserveDecisionOrder?: boolean; /** Target specific equipment by index (0-based). If omitted, any equipment satisfies. */ equipmentIndex?: number; /** For filter-bandwidth-set: target bandwidth index (0-12) */ @@ -205,8 +340,31 @@ export interface ConditionParams { minOutputPower?: number; /** For receiver-signal-locked/receiver-snr-threshold: which modem (1-4), defaults to active modem */ modemNumber?: number; + /** For receiver-afc-enabled: required AFC state (default true; false asserts manual tuning) */ + afcEnabled?: boolean; + /** For antenna-polarization-set: required circular handedness on the antenna feed */ + circularHandedness?: 'LHCP' | 'RHCP'; /** For receiver-snr-threshold: minimum C/N ratio in dB */ minCNRatio?: number; + /** + * For receiver-snr-threshold: maximum C/N ratio in dB. When set, the + * condition passes while the modem's C/N is at or BELOW this value - used to + * assert a link has been degraded/denied (e.g. a jammed victim downlink + * observed on a monitor receiver). May be combined with minCNRatio to require + * a band. Optional; existing scenarios use only minCNRatio. + */ + maxCNRatio?: number; + /** + * For receiver-snr-threshold: seconds the live C/N must stay inside the + * [minCNRatio, maxCNRatio] band continuously before the condition reads + * true; a frame outside the band resets the run. Meant for maxCNRatio: the + * 1 s satellite position throttle puts one transient low-C/N frame in every + * second of a LEO pass, so an unheld "below X dB" latches anywhere in the + * pass. Counted in evaluation time like maintainDuration, and applied before + * the observation gate so a requiresObservation latch waits for the hold. + * Default 0 (latches on the first frame in band). + */ + cnHoldSeconds?: number; /** For rx-modem-bandwidth-set: target bandwidth in Hz */ bandwidth?: number; /** For rx-modem-bandwidth-set: bandwidth tolerance in Hz */ @@ -278,6 +436,44 @@ export interface ConditionParams { /** For fault-active/fault-cleared: fault ID to check */ faultId?: string; + // Geolocation condition parameters (Campaign 5) + /** For geolocation-measurements-collected: minimum capture count */ + minCount?: number; + /** For geolocation-fix-accuracy: maximum fix error vs truth, km */ + maxErrorKm?: number; + /** For geolocation-ellipse-within: maximum 95% error-ellipse semi-major axis, km */ + maxSemiMajorKm?: number; + /** For geolocation conditions and interference-event-ended: the interference event in question */ + interferenceEventId?: string; + + // ── nats-eu (Campaign 2) condition parameters ────────────────────────────── + /** For link-budget-computed / link-margin-met: minimum required margin in dB (default 0) */ + minMarginDb?: number; + /** For command-acknowledged: specific command id to require (any acked command if omitted) */ + commandId?: string; + /** For contact-assigned / ephemeris-updated / security-event-acknowledged: target entity id */ + eventId?: string; + /** For contact-assigned: pass/contact id that must be allocated */ + contactId?: string; + /** For access-control-set: station account id to check */ + accountId?: string; + /** For access-control-set: target account access state */ + accountStatus?: 'active' | 'disabled' | 'expired'; + /** For transec-mode-set: target TRANSEC waveform mode */ + transecMode?: 'fixed' | 'hopping'; + /** For gpsdo-reference-mode-set: target GPSDO reference/discipline mode */ + referenceMode?: 'gnss' | 'holdover' | 'manual'; + /** For gpsdo-time-offset-exceeds: |offset| in microseconds that counts as walked off (default 20) */ + minOffsetUs?: number; + /** For gpsdo-time-offset-stable: seconds the offset must have held still (default 30) */ + holdSeconds?: number; + /** For telemetry-frames-received: frames required (default 1) */ + minFrames?: number; + /** For telemetry-channel-in-band: the channel to read */ + channelId?: string; + /** For telemetry-channel-in-band: the band the channel must read (default 'green') */ + telemetryBand?: 'green' | 'yellow' | 'red'; + // Observation-gating parameters /** * If true, this (typically passive) condition does not count as satisfied @@ -295,6 +491,15 @@ export interface ConditionParams { * 'dashboard'). Matched by exact id or prefix, like the tab-active condition. */ observationTab?: string; + /** + * For requiresObservation: seconds the value must read true continuously + * while the observation tab is active before it latches. Gives the operator + * time to actually read the panel instead of the checklist ticking the + * instant the tab opens. Leaving the tab or a false frame resets the run. + * Counted in evaluation time like maintainDuration. Defaults to + * DEFAULT_OBSERVATION_DWELL_SECONDS; 0 latches on the first true frame. + */ + observationDwellSeconds?: number; /** Additional context-specific parameters */ [key: string]: unknown; @@ -315,9 +520,30 @@ export interface TimePenalty { /** * Single condition that must be satisfied */ +/** + * Default dwell for requiresObservation conditions (see + * ConditionParams.observationDwellSeconds): long enough to read a panel, + * short enough not to feel like the checklist is stuck. + */ +export const DEFAULT_OBSERVATION_DWELL_SECONDS = 2; + +/** + * How long a requiresObservation read may go false on the observation tab + * before the dwell resets. The 1 s LEO position throttle puts one transient + * low frame into every second of a pass; without a grace no C/N dwell could + * ever be read on a LEO downlink. Leaving the tab still resets at once. + */ +export const OBSERVATION_DWELL_GRACE_SECONDS = 0.5; + export interface Condition { /** Type of condition to check */ type: ConditionType; + /** + * Optional stable identifier, unique within the objective. A `decision` + * condition names the requiresObservation conditions it depends on by this + * id (params.evidence), so the player cannot decide before looking. + */ + id?: string; /** Human-readable description */ description: string; /** Hint or tip to help achieve the condition (optional) */ @@ -433,4 +659,21 @@ export interface ConditionState { * regardless of tab or live value. */ observed?: boolean; + /** + * For requiresObservation conditions: seconds the value has read true on + * the observation tab. Reset to 0 off-tab, or once a false gap outlasts + * OBSERVATION_DWELL_GRACE_SECONDS; the condition latches at the dwell. + */ + observedSeconds?: number; + /** + * For requiresObservation conditions: seconds the value has read false on + * the observation tab since it last read true. A gap shorter than the + * grace keeps the dwell (one throttled low frame a second on a LEO pass). + */ + observedGapSeconds?: number; + /** + * For conditions with a cnHoldSeconds hold: seconds the live reading has + * been continuously inside the band. Reset to 0 by a frame outside it. + */ + heldSeconds?: number; } diff --git a/src/objectives/objectives-manager.css b/src/objectives/objectives-manager.css index 80a8706f..7c15f45e 100644 --- a/src/objectives/objectives-manager.css +++ b/src/objectives/objectives-manager.css @@ -168,7 +168,7 @@ /* Hint button for conditions with available hints */ .condition-hint-btn { - background: var(--mc-status-warning, #ba160c); + background: var(--mc-status-warning, var(--mc-accent-red, #ba160c)); border: none; border-radius: 50%; width: 22px; @@ -224,6 +224,18 @@ margin-left: auto; } + /* Optional-objective badge: the row does not gate Mission Complete */ + .objective-optional { + font-size: 0.75em; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 6px; + border-radius: 4px; + border: 1px solid rgba(160, 160, 160, 0.6); + color: #b0b0b0; + margin-left: 8px; + } + .objective-timer.timer-urgent { background: rgba(255, 0, 0, 0.3); color: #ff4444; diff --git a/src/objectives/objectives-manager.ts b/src/objectives/objectives-manager.ts index 363544d6..385346a6 100644 --- a/src/objectives/objectives-manager.ts +++ b/src/objectives/objectives-manager.ts @@ -5,25 +5,38 @@ */ import { GroundStation } from '@app/assets/ground-station/ground-station'; +import bulbPng from '@app/assets/icons/bulb.png'; +import { CommandingManager } from '@app/commanding/commanding-manager'; +import { ContactScheduleManager } from '@app/contact-schedule/contact-schedule-manager'; +import { ElectronicAttackManager } from '@app/electronic-attack/electronic-attack-manager'; import { CryptoModule } from '@app/equipment/crypto'; -import { TapPoint } from "@app/equipment/rf-front-end/coupler-module/tap-points"; +import { GeolocationConsoleCore } from '@app/equipment/geolocation-console/geolocation-console-core'; +import { FECSimulator } from '@app/equipment/receiver/fec-simulator'; +import { TapPoint } from '@app/equipment/rf-front-end/coupler-module/tap-points'; +import { OrbitalSatellite, observerFromLocation } from '@app/equipment/satellite/orbital-satellite'; import { EventBus } from '@app/events/event-bus'; -import { Events, QuizCompletedData, QuizPassedData } from '@app/events/events'; +import { DecisionGradedData, DecisionResolvedData, Events, QuizCompletedData, QuizPassedData } from '@app/events/events'; import { FaultInjector } from '@app/faults'; +import { GnssThreatManager } from '@app/gnss-threat/gnss-threat-manager'; +import { InterferenceManager } from '@app/interference/interference-manager'; +import { LinkBudgetManager } from '@app/link-budget/link-budget-manager'; +import { DecisionManager } from '@app/modal/decision-manager'; import { HintManager } from '@app/modal/hint-manager'; import { QuizManager } from '@app/modal/quiz-manager'; import { OpsLogManager } from '@app/ops-log/ops-log-manager'; import { TabbedCanvas } from '@app/pages/mission-control/tabbed-canvas'; +import { CampaignDocumentStore } from '@app/scenarios/campaign-document-store'; +import { SecurityConsoleCore } from '@app/security-console/security-console-core'; +import { missionNowMs } from '@app/simulation/mission-clock'; +import { SimClock, STEPS_PER_SECOND } from '@app/simulation/sim-clock'; import { SimulationManager } from '@app/simulation/simulation-manager'; +import { SpaceEventManager } from '@app/space-events/space-event-manager'; +import { TelemetryManager } from '@app/telemetry/telemetry-manager'; import { TrafficControlManager } from '@app/traffic/traffic-control-manager'; +import { TransecManager } from '@app/transec/transec-manager'; import { Milliseconds } from 'ootk'; -import bulbPng from '@app/assets/icons/bulb.png'; -import { - Condition, - ConditionParams, - Objective, - ObjectiveState -} from './objective-types'; +import { EvidenceFactRegistry } from './evidence-facts'; +import { Condition, ConditionParams, DEFAULT_OBSERVATION_DWELL_SECONDS, OBSERVATION_DWELL_GRACE_SECONDS, Objective, ObjectiveState } from './objective-types'; import './objectives-manager.css'; /** @@ -42,7 +55,9 @@ export class ObjectivesManager { private scenarioTimeLimit_: number | null = null; private scenarioTimerRunning_: boolean = false; private scenarioTimeRemaining_: number = 0; - private timerInterval_: number | null = null; + /** Unpaused steps since the last countdown tick */ + private countdownSteps_ = 0; + private readonly boundUpdateHandler_ = this.update_.bind(this); private scenarioStartTime_: number = 0; // Quiz pass state - when true, timers are paused and "PASS" should display @@ -51,25 +66,37 @@ export class ObjectivesManager { private readonly boundQuizPassedHandler_: (data: QuizPassedData) => void; private readonly boundQuizCompletedHandler_: (data: QuizCompletedData) => void; + private readonly boundDecisionGradedHandler_: (data: DecisionGradedData) => void; + private readonly boundDecisionResolvedHandler_: (data: DecisionResolvedData) => void; private readonly boundAssetSelectedHandler_: (data: { type: string; id: string }) => void; + /** + * Held evidence facts, one registry per ground station (facts such as + * equipment-fault-active are station-specific). Ticked lazily by active + * decision conditions, so scenarios without one pay nothing. + */ + private readonly evidenceFacts_ = new Map(); + private constructor(objectives: Objective[], scenarioTimeLimit?: number) { this.eventBus_ = EventBus.getInstance(); // Initialize bound handlers this.boundQuizPassedHandler_ = this.handleQuizPassed_.bind(this); this.boundQuizCompletedHandler_ = this.handleQuizCompleted_.bind(this); + this.boundDecisionGradedHandler_ = this.handleDecisionGraded_.bind(this); + this.boundDecisionResolvedHandler_ = this.handleDecisionResolved_.bind(this); this.boundAssetSelectedHandler_ = this.handleAssetSelected_.bind(this); - // Track scenario start time for elapsed time calculation - this.scenarioStartTime_ = Date.now(); + // Track scenario start time for elapsed time calculation. Measured on the + // mission clock, so time the operator skips counts as time on shift. + this.scenarioStartTime_ = missionNowMs(); // Initialize scenario timer if provided if (scenarioTimeLimit !== undefined && scenarioTimeLimit > 0) { this.scenarioTimeLimit_ = scenarioTimeLimit; this.scenarioTimeRemaining_ = scenarioTimeLimit; // Don't start timer if any objective freezes it - const hasFreezingObjective = objectives.some(obj => obj.freezesScenarioTimer); + const hasFreezingObjective = objectives.some((obj) => obj.freezesScenarioTimer); this.scenarioTimerRunning_ = !hasFreezingObjective; } @@ -79,15 +106,13 @@ export class ObjectivesManager { const isActive = hasNoPrerequisites; // Determine if timer should start now (on-scenario-load) or later (on-activate) - const startsOnLoad = objective.timeLimitSeconds !== undefined && - objective.timerStartTrigger === 'on-scenario-load'; - const startsOnActivate = objective.timeLimitSeconds !== undefined && - objective.timerStartTrigger !== 'on-scenario-load'; + const startsOnLoad = objective.timeLimitSeconds !== undefined && objective.timerStartTrigger === 'on-scenario-load'; + const startsOnActivate = objective.timeLimitSeconds !== undefined && objective.timerStartTrigger !== 'on-scenario-load'; return { objective, isActive, - activatedAt: isActive ? Date.now() : undefined, + activatedAt: isActive ? SimClock.nowMs() : undefined, isCompleted: false, conditionStates: objective.conditions.map((condition) => ({ condition, @@ -107,17 +132,39 @@ export class ObjectivesManager { }); // Subscribe to update loop - this.eventBus_.on(Events.UPDATE, this.update_.bind(this)); + this.eventBus_.on(Events.UPDATE, this.boundUpdateHandler_); + + // Developer/E2E hook (same pattern as window.advanceSimClock): dump the + // live per-condition evaluation of one objective. The checklist shows only + // the latched result, so a spec that fails on "objective not complete" + // cannot tell an unmet condition from an unobserved one from a stale + // render - call window.debugObjective('') to see all three. + (window as unknown as { debugObjective: (id: string) => unknown }).debugObjective = (objectiveId: string) => { + const state = this.objectiveStates_.find((s) => s.objective.id === objectiveId); + if (!state) return { error: `no objective '${objectiveId}'` }; + return { + isActive: state.isActive, + isCompleted: state.isCompleted, + conditions: state.conditionStates.map((cs) => ({ + type: cs.condition.type, + isSatisfied: cs.isSatisfied, + observed: cs.observed ?? null, + maintenanceComplete: cs.isMaintenanceComplete, + evaluatesNow: this.evaluateCondition_(cs.condition, state), + observationActive: this.isObservationContextActive_(cs.condition), + })), + }; + }; // Subscribe to quiz events for timer control this.eventBus_.on(Events.QUIZ_PASSED, this.boundQuizPassedHandler_); this.eventBus_.on(Events.QUIZ_COMPLETED, this.boundQuizCompletedHandler_); + // Decisions pause and resume the timers the same way a quiz does + this.eventBus_.on(Events.DECISION_GRADED, this.boundDecisionGradedHandler_); + this.eventBus_.on(Events.DECISION_RESOLVED, this.boundDecisionResolvedHandler_); // Subscribe to asset selection events for ground-station-selected condition this.eventBus_.on(Events.ASSET_SELECTED, this.boundAssetSelectedHandler_); - - // Start the 1-second timer interval for countdown updates - this.startTimerInterval_(); } /** @@ -147,7 +194,7 @@ export class ObjectivesManager { // If there's no freezing objective, resume simulated time immediately // (OpsLogManager starts paused by default, waiting for scenario to unlock) - const hasFreezingObjective = objectives.some(obj => obj.freezesScenarioTimer); + const hasFreezingObjective = objectives.some((obj) => obj.freezesScenarioTimer); if (!hasFreezingObjective && OpsLogManager.isInitialized()) { OpsLogManager.getInstance().resume(); } @@ -165,21 +212,28 @@ export class ObjectivesManager { return ObjectivesManager.instance_; } + /** + * Whether objectives are being tracked. Unlike getInstance() this does not + * throw, so callers outside the scenario lifecycle (the time-skip pre-flight + * check, which also runs in the sandbox) can ask safely. + */ + static hasInstance(): boolean { + return ObjectivesManager.instance_ !== null; + } + /** * Destroy the objectives manager and clean up */ static destroy(): void { if (ObjectivesManager.instance_) { - ObjectivesManager.instance_.eventBus_.off(Events.UPDATE, ObjectivesManager.instance_.update_.bind(ObjectivesManager.instance_)); + ObjectivesManager.instance_.eventBus_.off(Events.UPDATE, ObjectivesManager.instance_.boundUpdateHandler_); ObjectivesManager.instance_.eventBus_.off(Events.QUIZ_PASSED, ObjectivesManager.instance_.boundQuizPassedHandler_); ObjectivesManager.instance_.eventBus_.off(Events.QUIZ_COMPLETED, ObjectivesManager.instance_.boundQuizCompletedHandler_); + ObjectivesManager.instance_.eventBus_.off(Events.DECISION_GRADED, ObjectivesManager.instance_.boundDecisionGradedHandler_); + ObjectivesManager.instance_.eventBus_.off(Events.DECISION_RESOLVED, ObjectivesManager.instance_.boundDecisionResolvedHandler_); ObjectivesManager.instance_.eventBus_.off(Events.ASSET_SELECTED, ObjectivesManager.instance_.boundAssetSelectedHandler_); - // Clear timer interval - if (ObjectivesManager.instance_.timerInterval_) { - clearInterval(ObjectivesManager.instance_.timerInterval_); - ObjectivesManager.instance_.timerInterval_ = null; - } + delete (window as unknown as { debugObjective?: unknown }).debugObjective; ObjectivesManager.instance_ = null; } @@ -217,9 +271,7 @@ export class ObjectivesManager { if (!ObjectivesManager.instance_) { return false; } - return ObjectivesManager.instance_.objectiveStates_.some( - state => state.objective.freezesScenarioTimer && !state.isCompleted - ); + return ObjectivesManager.instance_.objectiveStates_.some((state) => state.objective.freezesScenarioTimer && !state.isCompleted); } /** @@ -248,10 +300,19 @@ export class ObjectivesManager { } /** - * Check if all objectives are completed + * Check whether every required objective is completed. + * + * Objectives flagged `isOptional` do not gate scenario completion: Mission + * Complete fires once the required set is done, whether or not the player + * also finished the optional ones. A scenario with no required objectives + * at all falls back to requiring every objective, so a purely optional list + * cannot complete itself on the first tick. */ areAllObjectivesCompleted(): boolean { - return this.objectiveStates_.every((state) => state.isCompleted); + const required = this.objectiveStates_.filter((state) => !state.objective.isOptional); + const gating = required.length > 0 ? required : this.objectiveStates_; + + return gating.every((state) => state.isCompleted); } /** @@ -263,7 +324,7 @@ export class ObjectivesManager { return this.scenarioTimeLimit_ - this.scenarioTimeRemaining_; } // No countdown timer - calculate from start time - return Math.floor((Date.now() - this.scenarioStartTime_) / 1000); + return Math.floor((missionNowMs() - this.scenarioStartTime_) / 1000); } /** @@ -305,9 +366,7 @@ export class ObjectivesManager { */ forceCompleteCurrentObjective(): boolean { // Find first active, non-completed, non-failed objective - const activeObjective = this.objectiveStates_.find( - (state) => state.isActive && !state.isCompleted && !state.isFailed - ); + const activeObjective = this.objectiveStates_.find((state) => state.isActive && !state.isCompleted && !state.isFailed); if (!activeObjective) { return false; @@ -317,13 +376,13 @@ export class ObjectivesManager { for (const condState of activeObjective.conditionStates) { condState.isSatisfied = true; condState.isMaintenanceComplete = true; - condState.satisfiedAt = Date.now(); + condState.satisfiedAt = SimClock.nowMs(); condState.observed = true; // a force-completed objective counts as observed } // Mark objective as complete activeObjective.isCompleted = true; - activeObjective.completedAt = Date.now(); + activeObjective.completedAt = SimClock.nowMs(); activeObjective.isTimerRunning = false; // Collapse the objective @@ -342,7 +401,7 @@ export class ObjectivesManager { // Handle freezing objectives - start scenario timer and resume simulated time if (activeObjective.objective.freezesScenarioTimer) { this.scenarioTimerRunning_ = true; - this.scenarioStartTime_ = Date.now(); + this.scenarioStartTime_ = missionNowMs(); if (OpsLogManager.isInitialized()) { OpsLogManager.getInstance().resume(); } @@ -371,16 +430,12 @@ export class ObjectivesManager { */ uncompleteLastObjective(): boolean { // Find the most recently completed objective - const completedObjectives = this.objectiveStates_.filter( - (state) => state.isCompleted && state.completedAt - ); + const completedObjectives = this.objectiveStates_.filter((state) => state.isCompleted && state.completedAt); if (completedObjectives.length === 0) { return false; } - const lastCompleted = completedObjectives.reduce((latest, current) => - (current.completedAt ?? 0) > (latest.completedAt ?? 0) ? current : latest - , completedObjectives[0]); + const lastCompleted = completedObjectives.reduce((latest, current) => ((current.completedAt ?? 0) > (latest.completedAt ?? 0) ? current : latest), completedObjectives[0]); // Reset objective state lastCompleted.isCompleted = false; @@ -429,9 +484,7 @@ export class ObjectivesManager { */ setCurrentObjectiveTimeRemaining(seconds: number): boolean { // Find first active, non-completed, non-failed objective with a timer - const activeObjective = this.objectiveStates_.find( - (state) => state.isActive && !state.isCompleted && !state.isFailed && state.timeRemainingSeconds !== undefined - ); + const activeObjective = this.objectiveStates_.find((state) => state.isActive && !state.isCompleted && !state.isFailed && state.timeRemainingSeconds !== undefined); if (!activeObjective) { return false; @@ -444,18 +497,25 @@ export class ObjectivesManager { } /** - * Start the 1-second timer interval for countdown updates + * Accumulate one simulation step of scenario time and tick the countdowns + * once per whole second of it. Countdowns follow the scenario clock, so + * they stop with it (brief freeze, quiz, failure, hidden tab). Skips do not + * pass through here; see applyTimeSkip(). */ - private startTimerInterval_(): void { - if (this.timerInterval_) return; + private advanceCountdowns_(): void { + if (SimClock.isPaused('scenario')) { + return; + } - this.timerInterval_ = window.setInterval(() => { + this.countdownSteps_++; + if (this.countdownSteps_ >= STEPS_PER_SECOND) { + this.countdownSteps_ = 0; this.tickTimers_(); - }, 1000); + } } /** - * Called every second to update timers + * Called every second of scenario time to update timers */ private tickTimers_(): void { // Don't tick if OpsLogManager is paused - keep timers in sync with simulated time @@ -484,12 +544,65 @@ export class ObjectivesManager { } } + /** + * Advance every countdown timer by a skipped interval. + * + * Elapsed time needs no adjustment here - it is measured on the mission clock + * (missionNowMs), which TimeSkipController has already advanced. Countdown + * timers are decremented state, not derived, so they must be told. + * + * TimeSkipController refuses to skip while an objective timer is running, so + * in practice this only ever moves the scenario-wide timer. It handles the + * objective case anyway rather than depending on a guardrail elsewhere. + * + * @param deltaMs Skipped interval in milliseconds + */ + applyTimeSkip(deltaMs: number): void { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) { + return; + } + + const deltaS = deltaMs / 1000; + + if (this.scenarioTimerRunning_ && this.scenarioTimeRemaining_ > 0) { + this.scenarioTimeRemaining_ = Math.max(0, this.scenarioTimeRemaining_ - deltaS); + if (this.scenarioTimeRemaining_ <= 0) { + this.handleScenarioTimeout_(); + } + } + + for (const state of this.objectiveStates_) { + if (!state.isTimerRunning || state.isCompleted || state.isFailed) { + continue; + } + if (state.timeRemainingSeconds === undefined || state.timeRemainingSeconds <= 0) { + continue; + } + + state.timeRemainingSeconds = Math.max(0, state.timeRemainingSeconds - deltaS); + if (state.timeRemainingSeconds <= 0) { + this.failObjective_(state, 'timeout'); + } + } + } + + /** + * Whether any objective countdown timer is currently running. A time skip is + * blocked while one is, because skipping would burn the operator's clock on + * an objective they are actively being timed on. + */ + hasRunningObjectiveTimer(): boolean { + return this.objectiveStates_.some( + (state) => state.isTimerRunning && !state.isCompleted && !state.isFailed && state.timeRemainingSeconds !== undefined && state.timeRemainingSeconds > 0 + ); + } + /** * Mark an objective as failed */ private failObjective_(state: ObjectiveState, reason: 'timeout'): void { state.isFailed = true; - state.failedAt = Date.now(); + state.failedAt = SimClock.nowMs(); // Stop ALL timers when any objective fails this.stopAllTimers(); @@ -538,6 +651,22 @@ export class ObjectivesManager { * Handle quiz passed - pause all timers and set passed state * Called when user selects correct answer (before clicking Continue) */ + /** A correct decision pauses timers exactly as a passed quiz does */ + private handleDecisionGraded_(data: DecisionGradedData): void { + if (!data.correct) return; + this.handleQuizPassed_({ objectiveId: data.objectiveId, conditionIndex: data.conditionIndex, attempts: data.attempts, pointsDeducted: data.pointsDeducted }); + } + + /** Continue after a decision resumes timers exactly as a completed quiz does */ + private handleDecisionResolved_(data: DecisionResolvedData): void { + this.handleQuizCompleted_({ + objectiveId: data.objectiveId, + conditionIndex: data.conditionIndex, + totalAttempts: data.totalAttempts, + totalPointsDeducted: data.totalPointsDeducted, + }); + } + private handleQuizPassed_(data: QuizPassedData): void { this.isQuizPassed_ = true; this.passedObjectiveId_ = data.objectiveId; @@ -546,7 +675,7 @@ export class ObjectivesManager { this.scenarioTimerRunning_ = false; // Pause the objective timer for the passed objective - const state = this.objectiveStates_.find(s => s.objective.id === data.objectiveId); + const state = this.objectiveStates_.find((s) => s.objective.id === data.objectiveId); if (state) { state.isTimerRunning = false; } @@ -570,10 +699,7 @@ export class ObjectivesManager { // 2. Time remains // 3. Not all objectives complete // 4. No incomplete freezing objectives (scenario is unlocked) - if (this.scenarioTimeLimit_ !== null && - this.scenarioTimeRemaining_ > 0 && - !this.areAllObjectivesCompleted() && - !ObjectivesManager.isScenarioLocked()) { + if (this.scenarioTimeLimit_ !== null && this.scenarioTimeRemaining_ > 0 && !this.areAllObjectivesCompleted() && !ObjectivesManager.isScenarioLocked()) { this.scenarioTimerRunning_ = true; } // Note: objective timer doesn't resume - it will be replaced by next objective's timer @@ -663,6 +789,13 @@ export class ObjectivesManager { continue; } + // Condition states restore by index, so a save taken before an objective's + // conditions were re-authored would tick the wrong rows. Treat such an + // objective as fresh rather than half-restoring it. + if (savedState.conditionStates.length !== currentState.conditionStates.length) { + continue; + } + // Restore activation state and timing currentState.isActive = savedState.isActive; currentState.activatedAt = savedState.activatedAt; @@ -710,9 +843,7 @@ export class ObjectivesManager { // Resume OpsLogManager if no freezing objective is incomplete // (scenario should be unlocked if freezing objective was already completed) - const hasIncompleteFreezingObjective = this.objectiveStates_.some( - state => state.objective.freezesScenarioTimer && !state.isCompleted - ); + const hasIncompleteFreezingObjective = this.objectiveStates_.some((state) => state.objective.freezesScenarioTimer && !state.isCompleted); if (!hasIncompleteFreezingObjective && OpsLogManager.isInitialized()) { OpsLogManager.getInstance().resume(); } @@ -778,6 +909,13 @@ export class ObjectivesManager { html += ``; html += `${objective.title} - ${stateLabel}`; + // Optional objectives do not gate Mission Complete (see + // areAllObjectivesCompleted); say so on the row, or the player cannot + // tell which rows they may leave. + if (objective.isOptional) { + html += `Optional`; + } + // Add timer display if objective has a running timer if (objectiveState.isTimerRunning && objectiveState.timeRemainingSeconds !== undefined) { const timeStr = this.formatTimeRemaining(objectiveState.timeRemainingSeconds); @@ -851,6 +989,8 @@ export class ObjectivesManager { private update_(dt: Milliseconds): void { const dtSeconds = dt / 1000; + this.advanceCountdowns_(); + for (const objectiveState of this.objectiveStates_) { // Skip already completed objectives if (objectiveState.isCompleted) { @@ -874,7 +1014,7 @@ export class ObjectivesManager { const isObjectiveComplete = this.checkObjectiveComplete_(objectiveState); if (isObjectiveComplete && !objectiveState.isCompleted) { objectiveState.isCompleted = true; - objectiveState.completedAt = Date.now(); + objectiveState.completedAt = SimClock.nowMs(); objectiveState.isTimerRunning = false; // Stop timer on completion // Check for time penalty @@ -911,7 +1051,7 @@ export class ObjectivesManager { // If this was a freezing objective, start the scenario timer and resume simulated time if (objectiveState.objective.freezesScenarioTimer) { this.scenarioTimerRunning_ = true; - this.scenarioStartTime_ = Date.now(); // Reset start time so elapsed time is from now + this.scenarioStartTime_ = missionNowMs(); // Reset start time so elapsed time is from now // Resume simulated time (OpsLogManager starts paused until scenario unlocks) if (OpsLogManager.isInitialized()) { @@ -973,7 +1113,7 @@ export class ObjectivesManager { * Activate objectives that were waiting for a specific prerequisite */ private activateDependentObjectives_(completedObjectiveId: string): void { - const now = Date.now(); + const now = SimClock.nowMs(); for (const objectiveState of this.objectiveStates_) { // Skip already active or completed objectives @@ -995,29 +1135,72 @@ export class ObjectivesManager { // Activate if all prerequisites are met if (allPrerequisitesMet) { - objectiveState.isActive = true; - objectiveState.activatedAt = now; - - // Start timer for objectives with 'on-activate' trigger (default behavior) - const objective = objectiveState.objective; - if (objective.timeLimitSeconds !== undefined && - objective.timerStartTrigger !== 'on-scenario-load') { - objectiveState.timeRemainingSeconds = objective.timeLimitSeconds; - objectiveState.isTimerRunning = true; - } + this.activateObjectiveState_(objectiveState, now); + } + } + } - // Remove from collapsed set so it expands when it becomes active - this.collapsedObjectiveIds_.delete(objectiveState.objective.id); + /** Make an objective active: timer, checklist expansion, first evaluation, event */ + private activateObjectiveState_(objectiveState: ObjectiveState, now: number): void { + objectiveState.isActive = true; + objectiveState.activatedAt = now; - // Immediately evaluate conditions for the newly activated objective - this.evaluateObjectiveConditions_(objectiveState, 0); + // Start timer for objectives with 'on-activate' trigger (default behavior) + const objective = objectiveState.objective; + if (objective.timeLimitSeconds !== undefined && objective.timerStartTrigger !== 'on-scenario-load') { + objectiveState.timeRemainingSeconds = objective.timeLimitSeconds; + objectiveState.isTimerRunning = true; + } - this.eventBus_.emit(Events.OBJECTIVE_ACTIVATED, { - objectiveId: objectiveState.objective.id, - objective: objectiveState.objective, - activatedAt: now, - }); - } + // Remove from collapsed set so it expands when it becomes active + this.collapsedObjectiveIds_.delete(objectiveState.objective.id); + + // Immediately evaluate conditions for the newly activated objective + this.evaluateObjectiveConditions_(objectiveState, 0); + + this.eventBus_.emit(Events.OBJECTIVE_ACTIVATED, { + objectiveId: objectiveState.objective.id, + objective: objectiveState.objective, + activatedAt: now, + }); + } + + /** + * Activate an objective now, bypassing its prerequisites. Used by decision + * consequences (a wrong call opens the objective that deals with it). + * Returns false if the objective is unknown, already active, or complete. + */ + activateObjective(objectiveId: string): boolean { + const state = this.objectiveStates_.find((s) => s.objective.id === objectiveId); + if (!state || state.isActive || state.isCompleted) return false; + this.activateObjectiveState_(state, SimClock.nowMs()); + return true; + } + + /** + * Deactivate an active, incomplete objective and reset its conditions, then + * cascade to its dependents. Returns false if it was not active. + */ + deactivateObjective(objectiveId: string): boolean { + const state = this.objectiveStates_.find((s) => s.objective.id === objectiveId); + if (!state || !state.isActive || state.isCompleted) return false; + this.deactivateObjectiveState_(state); + this.deactivateDependentObjectives_(objectiveId); + return true; + } + + /** Reset an objective to inactive with untouched conditions */ + private deactivateObjectiveState_(objectiveState: ObjectiveState): void { + objectiveState.isActive = false; + objectiveState.activatedAt = undefined; + objectiveState.isTimerRunning = false; + + for (const condState of objectiveState.conditionStates) { + condState.isSatisfied = false; + condState.satisfiedAt = undefined; + condState.maintainedDuration = 0; + condState.isMaintenanceComplete = false; + condState.observed = false; // re-observe after a prerequisite reset } } @@ -1035,19 +1218,7 @@ export class ObjectivesManager { // Check if this objective has the given objective as a prerequisite const prerequisites = objectiveState.objective.prerequisiteObjectiveIds || []; if (prerequisites.includes(objectiveId)) { - // Deactivate this objective - objectiveState.isActive = false; - objectiveState.activatedAt = undefined; - objectiveState.isTimerRunning = false; - - // Reset condition states - for (const condState of objectiveState.conditionStates) { - condState.isSatisfied = false; - condState.satisfiedAt = undefined; - condState.maintainedDuration = 0; - condState.isMaintenanceComplete = false; - condState.observed = false; // re-observe after a prerequisite reset - } + this.deactivateObjectiveState_(objectiveState); // Recursively deactivate objectives that depend on this one this.deactivateDependentObjectives_(objectiveState.objective.id); @@ -1064,41 +1235,70 @@ export class ObjectivesManager { const conditionState = objectiveState.conditionStates[condIndex]; // Skip already completed maintenance (unless it's indefinite maintenance) - if (conditionState.isMaintenanceComplete && - !conditionState.condition.maintainUntilObjectiveComplete) { + if (conditionState.isMaintenanceComplete && !conditionState.condition.maintainUntilObjectiveComplete) { continue; } const wasSatisfied = conditionState.isSatisfied; - let isNowSatisfied = this.evaluateCondition_(conditionState.condition, objectiveState); + this.lastObserved_ = undefined; + let isNowSatisfied = this.evaluateCondition_(conditionState.condition, objectiveState, dtSeconds); // Observation gate: a flagged passive condition does not count from // ambient simulation state alone. It must be seen on the correct tab - // once, after which it latches satisfied (stays checked even if the - // operator navigates away or the live value changes). + // for the dwell (continuously true while the tab is active), after + // which it latches satisfied (stays checked even if the operator + // navigates away or the live value changes). The dwell is what gives + // the operator time to read the panel before the checklist ticks. const condParams = conditionState.condition.params; if (condParams?.requiresObservation && condParams?.observationTab) { if (conditionState.observed) { isNowSatisfied = true; // already observed - latched - } else if (isNowSatisfied && this.isObservationContextActive_(conditionState.condition)) { - conditionState.observed = true; // observed for the first time - latch - } else { + } else if (!this.isObservationContextActive_(conditionState.condition)) { + conditionState.observedSeconds = 0; // off-tab: the read starts over + conditionState.observedGapSeconds = 0; isNowSatisfied = false; // value not yet observed on the right tab + } else if (isNowSatisfied) { + const dwellSeconds = condParams.observationDwellSeconds ?? DEFAULT_OBSERVATION_DWELL_SECONDS; + conditionState.observedSeconds = (conditionState.observedSeconds ?? 0) + dtSeconds; + conditionState.observedGapSeconds = 0; + if (conditionState.observedSeconds >= dwellSeconds) { + conditionState.observed = true; // read for long enough - latch + } else { + isNowSatisfied = false; // on the right tab, still reading + } + } else { + // On the right tab but reading false. A LEO pass drops one low frame + // every second (position throttle), so a gap inside the grace keeps + // the dwell; a longer one means the value really went away. + conditionState.observedGapSeconds = (conditionState.observedGapSeconds ?? 0) + dtSeconds; + if (conditionState.observedGapSeconds > OBSERVATION_DWELL_GRACE_SECONDS) { + conditionState.observedSeconds = 0; + } + isNowSatisfied = false; } } // Update satisfied state conditionState.isSatisfied = isNowSatisfied; + if (this.isConditionTelemetryEnabled_) { + this.eventBus_.emit(Events.OBJECTIVE_CONDITION_EVALUATED, { + objectiveId: objectiveState.objective.id, + conditionIndex: condIndex, + type: conditionState.condition.type, + isSatisfied: isNowSatisfied, + observed: this.lastObserved_, + }); + } + // Handle condition state changes if (isNowSatisfied && !wasSatisfied) { // Condition just became satisfied - conditionState.satisfiedAt = Date.now(); + conditionState.satisfiedAt = SimClock.nowMs(); conditionState.maintainedDuration = 0; // Mark as complete based on condition type - if (conditionState.condition.maintainUntilObjectiveComplete || - !conditionState.condition.mustMaintain) { + if (conditionState.condition.maintainUntilObjectiveComplete || !conditionState.condition.mustMaintain) { conditionState.isMaintenanceComplete = true; } @@ -1113,7 +1313,7 @@ export class ObjectivesManager { conditionState.satisfiedAt = undefined; conditionState.maintainedDuration = 0; conditionState.lostTimestamps = conditionState.lostTimestamps || []; - conditionState.lostTimestamps.push(Date.now()); + conditionState.lostTimestamps.push(SimClock.nowMs()); // Reset maintenance complete for indefinite-maintenance conditions if (conditionState.condition.maintainUntilObjectiveComplete) { @@ -1171,11 +1371,77 @@ export class ObjectivesManager { * If equipmentIndex is specified, checks only that equipment * If equipmentIndex is omitted, checks if ANY equipment satisfies */ - private evaluateEquipment_( - equipmentArray: readonly T[], - params: ConditionParams | undefined, - checker: (item: T) => boolean - ): boolean { + // ── Condition telemetry (authoring / dev harness) ────────────────────── + // Off by default. When enabled, every evaluation of every active condition + // emits OBJECTIVE_CONDITION_EVALUATED carrying the value the check compared + // (frequency, mode, power...) so an author can see why a row is not green. + private isConditionTelemetryEnabled_ = false; + private lastObserved_: unknown = undefined; + + /** Enable or disable per-evaluation condition telemetry events. */ + enableConditionTelemetry(enabled: boolean): void { + this.isConditionTelemetryEnabled_ = enabled; + } + + /** Record the value a condition check compared against its target. No-op when telemetry is off. */ + private observe_(value: unknown): void { + if (this.isConditionTelemetryEnabled_) { + this.lastObserved_ = value; + } + } + + /** + * Developer / authoring aid: mark every transitive prerequisite of + * `objectiveId` complete (conditions satisfied, maintenance done) so the + * scenario resumes at that objective. Equipment state is NOT synthesised; + * the author sets it by hand. Refuses unless DEVELOPER_MODE is on. + * @returns the objective ids marked complete, or null when refused / unknown id + */ + devCompleteThrough(objectiveId: string): string[] | null { + if (!window.DEVELOPER_MODE) { + console.warn('[ObjectivesManager] devCompleteThrough requires DEVELOPER_MODE'); + return null; + } + const byId = new Map(this.objectiveStates_.map((state) => [state.objective.id, state])); + if (!byId.has(objectiveId)) { + return null; + } + const toComplete = new Set(); + const visit = (id: string): void => { + for (const prereq of byId.get(id)?.objective.prerequisiteObjectiveIds ?? []) { + if (!toComplete.has(prereq)) { + toComplete.add(prereq); + visit(prereq); + } + } + }; + visit(objectiveId); + + const now = SimClock.nowMs(); + const saved: ObjectiveState[] = [...toComplete].map((id) => { + const state = byId.get(id)!; + return { + ...state, + isActive: false, + isCompleted: true, + activatedAt: state.activatedAt ?? now, + completedAt: now, + isFailed: false, + isTimerRunning: false, + conditionStates: state.conditionStates.map((cs) => ({ + ...cs, + isSatisfied: true, + satisfiedAt: now, + isMaintenanceComplete: true, + observed: true, + })), + }; + }); + this.restoreState(saved); + return [...toComplete]; + } + + private evaluateEquipment_(equipmentArray: readonly T[], params: ConditionParams | undefined, checker: (item: T) => boolean): boolean { if (!equipmentArray || equipmentArray.length === 0) return false; if (params?.equipmentIndex !== undefined) { @@ -1205,9 +1471,11 @@ export class ObjectivesManager { } /** - * Evaluate a single condition and return whether it's currently satisfied + * Evaluate a single condition and return whether it's currently satisfied. + * dtSeconds advances any hold the condition carries (cnHoldSeconds); omit it + * for a read-only probe that must not touch the hold state. */ - private evaluateCondition_(condition: Condition, objectiveState: ObjectiveState): boolean { + private evaluateCondition_(condition: Condition, objectiveState: ObjectiveState, dtSeconds?: number): boolean { const sim = SimulationManager.getInstance(); const gs = this.getGroundStation_(objectiveState); @@ -1219,12 +1487,11 @@ export class ObjectivesManager { case 'antenna-locked': { return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { const state = antenna.state; + this.observe_({ isLocked: state.isLocked, azimuth: state.azimuth, elevation: state.elevation }); if (!state.isLocked) return false; // If a specific satellite is required, check it - const requiredNoradId = - (condition.params?.noradId as number | undefined) ?? - (condition.params?.satelliteId as number | undefined); + const requiredNoradId = (condition.params?.noradId as number | undefined) ?? (condition.params?.satelliteId as number | undefined); if (requiredNoradId !== undefined) { const targetSat = sim.getSatByNoradId(requiredNoradId); @@ -1237,10 +1504,14 @@ export class ObjectivesManager { return false; } + // A LEO sits at a different az/el from each site: judge the lock + // from the objective's own station. + const view = targetSat instanceof OrbitalSatellite ? targetSat.geometryFor(observerFromLocation(gs.state.location)) : targetSat; + // Handle 360° wraparound for azimuth - let azDiff = Math.abs(state.azimuth - targetSat.az); + let azDiff = Math.abs(state.azimuth - view.az); if (azDiff > 180) azDiff = 360 - azDiff; - const elDiff = Math.abs(state.elevation - targetSat.el); + const elDiff = Math.abs(state.elevation - view.el); return azDiff <= 1.5 && elDiff <= 1.5; } return true; @@ -1248,31 +1519,20 @@ export class ObjectivesManager { } case 'gpsdo-locked': { - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.gpsdoModule.state.isLocked; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.gpsdoModule.state.isLocked); } case 'gpsdo-warmed-up': { return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const gpsdoState = rfFrontEnd.gpsdoModule.state; - return ( - gpsdoState.isPowered && - gpsdoState.warmupTimeRemaining === 0 && - gpsdoState.temperature >= 65 && - gpsdoState.temperature <= 75 - ); + return gpsdoState.isPowered && gpsdoState.warmupTimeRemaining === 0 && gpsdoState.temperature >= 65 && gpsdoState.temperature <= 75; }); } case 'gpsdo-gnss-locked': { return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const gpsdoState = rfFrontEnd.gpsdoModule.state; - return ( - gpsdoState.isPowered && - gpsdoState.gnssSignalPresent && - gpsdoState.satelliteCount >= 4 - ); + return gpsdoState.isPowered && gpsdoState.gnssSignalPresent && gpsdoState.satelliteCount >= 4; }); } @@ -1280,12 +1540,9 @@ export class ObjectivesManager { const maxAccuracy = condition.params?.maxFrequencyAccuracy ?? 5; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const gpsdoState = rfFrontEnd.gpsdoModule.state; + this.observe_({ frequencyAccuracy: gpsdoState.frequencyAccuracy, allanDeviation: gpsdoState.allanDeviation, phaseNoise: gpsdoState.phaseNoise }); return ( - gpsdoState.isPowered && - gpsdoState.isLocked && - gpsdoState.frequencyAccuracy < maxAccuracy && - gpsdoState.allanDeviation < maxAccuracy && - gpsdoState.phaseNoise < -125 + gpsdoState.isPowered && gpsdoState.isLocked && gpsdoState.frequencyAccuracy < maxAccuracy && gpsdoState.allanDeviation < maxAccuracy && gpsdoState.phaseNoise < -125 ); }); } @@ -1298,19 +1555,13 @@ export class ObjectivesManager { } case 'buc-locked': { - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.bucModule.state.isExtRefLocked; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.bucModule.state.isExtRefLocked); } case 'buc-reference-locked': { return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const bucState = rfFrontEnd.bucModule.state; - return ( - bucState.isPowered && - bucState.isExtRefLocked && - bucState.frequencyError === 0 - ); + return bucState.isPowered && bucState.isExtRefLocked && bucState.frequencyError === 0; }); } @@ -1325,6 +1576,7 @@ export class ObjectivesManager { const maxCurrent = condition.params?.maxCurrentDraw ?? 4.5; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const bucState = rfFrontEnd.bucModule.state; + this.observe_(bucState.currentDraw); return bucState.isPowered && bucState.currentDraw <= maxCurrent; }); } @@ -1332,10 +1584,8 @@ export class ObjectivesManager { case 'buc-not-saturated': { return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const bucState = rfFrontEnd.bucModule.state; - return ( - bucState.isPowered && - bucState.outputPower <= (bucState.saturationPower - 2) - ); + this.observe_({ outputPower: bucState.outputPower, saturationPower: bucState.saturationPower }); + return bucState.isPowered && bucState.outputPower <= bucState.saturationPower - 2; }); } @@ -1357,6 +1607,7 @@ export class ObjectivesManager { const maxTemp = condition.params?.maxTemperature ?? 70; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const bucState = rfFrontEnd.bucModule.state; + this.observe_(bucState.temperature); return bucState.isPowered && bucState.temperature <= maxTemp; }); } @@ -1364,11 +1615,7 @@ export class ObjectivesManager { case 'lnb-reference-locked': { return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const lnbState = rfFrontEnd.lnbModule.state; - return ( - lnbState.isPowered && - lnbState.isExtRefLocked && - lnbState.frequencyError === 0 - ); + return lnbState.isPowered && lnbState.isExtRefLocked && lnbState.frequencyError === 0; }); } @@ -1378,10 +1625,8 @@ export class ObjectivesManager { const tolerance = condition.params.loFrequencyTolerance ?? 0; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const lnbState = rfFrontEnd.lnbModule.state; - return ( - lnbState.isPowered && - Math.abs(lnbState.loFrequency - targetLoFrequency) <= tolerance - ); + this.observe_(lnbState.loFrequency); + return lnbState.isPowered && Math.abs(lnbState.loFrequency - targetLoFrequency) <= tolerance; }); } @@ -1391,23 +1636,15 @@ export class ObjectivesManager { const tolerance = condition.params.gainTolerance ?? 0; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const lnbState = rfFrontEnd.lnbModule.state; - return ( - lnbState.isPowered && - Math.abs(lnbState.gain - targetGain) <= tolerance - ); + this.observe_(lnbState.gain); + return lnbState.isPowered && Math.abs(lnbState.gain - targetGain) <= tolerance; }); } case 'lnb-thermally-stable': { return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const lnbState = rfFrontEnd.lnbModule.state; - return ( - lnbState.isPowered && - lnbState.noiseTemperature < 100 && - lnbState.temperature >= 25 && - lnbState.temperature <= 50 && - lnbState.frequencyError === 0 - ); + return lnbState.isPowered && lnbState.noiseTemperature < 100 && lnbState.temperature >= 25 && lnbState.temperature <= 50 && lnbState.frequencyError === 0; }); } @@ -1415,6 +1652,7 @@ export class ObjectivesManager { const maxNoiseTemp = condition.params?.maxNoiseTemperature ?? 100; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const lnbState = rfFrontEnd.lnbModule.state; + this.observe_(lnbState.noiseTemperature); return lnbState.isPowered && lnbState.noiseTemperature <= maxNoiseTemp; }); } @@ -1424,37 +1662,25 @@ export class ObjectivesManager { switch (condition.params.equipment) { case 'antenna': - return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { - return antenna.state.isPowered; - }); + return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => antenna.state.isPowered); case 'gpsdo': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.gpsdoModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.gpsdoModule.state.isPowered); case 'buc': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.bucModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.bucModule.state.isPowered); case 'lnb': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.lnbModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.lnbModule.state.isPowered); case 'spectrum-analyzer': return true; // Spectrum analyzer always powered on for this simulation case 'transmitter': return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); return modem?.isPowered ?? false; }); case 'hpa': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.hpaModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.hpaModule.state.isPowered); case 'filter': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return rfFrontEnd.filterModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => rfFrontEnd.filterModule.state.isPowered); default: return false; } @@ -1465,33 +1691,21 @@ export class ObjectivesManager { switch (condition.params.equipment) { case 'antenna': - return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { - return !antenna.state.isPowered; - }); + return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => !antenna.state.isPowered); case 'gpsdo': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return !rfFrontEnd.gpsdoModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => !rfFrontEnd.gpsdoModule.state.isPowered); case 'buc': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return !rfFrontEnd.bucModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => !rfFrontEnd.bucModule.state.isPowered); case 'lnb': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return !rfFrontEnd.lnbModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => !rfFrontEnd.lnbModule.state.isPowered); case 'hpa': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return !rfFrontEnd.hpaModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => !rfFrontEnd.hpaModule.state.isPowered); case 'filter': - return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { - return !rfFrontEnd.filterModule.state.isPowered; - }); + return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => !rfFrontEnd.filterModule.state.isPowered); case 'transmitter': return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); return !(modem?.isPowered ?? true); }); default: @@ -1510,13 +1724,14 @@ export class ObjectivesManager { } // Find the specific signal by ID - const targetSignal = signals.find(s => s.signalId === condition.params?.signalId); + const targetSignal = signals.find((s) => s.signalId === condition.params?.signalId); if (!targetSignal) return false; // If minPower specified, check signal meets threshold (include path gain) if (condition.params?.minPower !== undefined) { const totalGain = specA.rfFrontEnd_.couplerModule.signalPathManager.getTotalGainTo(TapPoint.RX_IF); const effectivePower = targetSignal.power + totalGain; + this.observe_(effectivePower); return effectivePower >= condition.params.minPower; } @@ -1536,12 +1751,13 @@ export class ObjectivesManager { return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { const signals = specA.getInputSignals(); - const targetSignal = signals.find(s => s.signalId === targetSignalId); + const targetSignal = signals.find((s) => s.signalId === targetSignalId); if (!targetSignal) return false; // Include path gain to get effective power at spectrum analyzer const totalGain = specA.rfFrontEnd_.couplerModule.signalPathManager.getTotalGainTo(TapPoint.RX_IF); const effectivePower = targetSignal.power + totalGain; + this.observe_(effectivePower); return effectivePower >= minPower; }); } @@ -1549,8 +1765,9 @@ export class ObjectivesManager { case 'frequency-set': { if (!condition.params?.frequency) return false; const targetFrequency = condition.params.frequency; - const tolerance = condition.params.frequencyTolerance || 1e6; + const tolerance = condition.params.frequencyTolerance ?? 1e6; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.centerFrequency); const diff = Math.abs(specA.state.centerFrequency - targetFrequency); return diff <= tolerance; }); @@ -1559,8 +1776,9 @@ export class ObjectivesManager { case 'speca-span-set': { if (!condition.params?.span) return false; const targetSpan = condition.params.span; - const tolerance = condition.params.frequencyTolerance || 1e6; + const tolerance = condition.params.frequencyTolerance ?? 1e6; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.span); const diff = Math.abs(specA.state.span - targetSpan); return diff <= tolerance; }); @@ -1569,8 +1787,9 @@ export class ObjectivesManager { case 'speca-rbw-set': { if (condition.params?.rbw === undefined) return false; const targetRbw = condition.params.rbw; // null means "Automatic" - const tolerance = condition.params.frequencyTolerance || 1e3; + const tolerance = condition.params.frequencyTolerance ?? 1e3; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.rbw); // Handle "Automatic" mode (null) if (targetRbw === null) { return specA.state.rbw === null; @@ -1587,6 +1806,7 @@ export class ObjectivesManager { const targetRefLevel = condition.params.referenceLevel; const tolerance = condition.params.referenceLevelTolerance ?? 1; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.referenceLevel); const diff = Math.abs(specA.state.referenceLevel - targetRefLevel); return diff <= tolerance; }); @@ -1597,6 +1817,7 @@ export class ObjectivesManager { const targetCenterFreq = condition.params.centerFrequency; const tolerance = condition.params.centerFrequencyTolerance ?? 1e6; // Default 1 MHz return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.centerFrequency); const diff = Math.abs(specA.state.centerFrequency - targetCenterFreq); return diff <= tolerance; }); @@ -1606,6 +1827,7 @@ export class ObjectivesManager { const maxSignalStrength = condition.params?.maxSignalStrength ?? -60; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { const signals = specA.getInputSignals(); + this.observe_(Math.max(...signals.map((signal) => signal.power))); return signals.every((signal) => signal.power < maxSignalStrength); }); } @@ -1615,6 +1837,7 @@ export class ObjectivesManager { const targetMinAmplitude = condition.params.minAmplitude; const tolerance = condition.params.minAmplitudeTolerance ?? 5; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.minAmplitude); const diff = Math.abs(specA.state.minAmplitude - targetMinAmplitude); return diff <= tolerance; }); @@ -1625,6 +1848,7 @@ export class ObjectivesManager { const targetMaxAmplitude = condition.params.maxAmplitude; const tolerance = condition.params.maxAmplitudeTolerance ?? 5; return this.evaluateEquipment_(gs.spectrumAnalyzers, condition.params, (specA) => { + this.observe_(specA.state.maxAmplitude); const diff = Math.abs(specA.state.maxAmplitude - targetMaxAmplitude); return diff <= tolerance; }); @@ -1634,6 +1858,7 @@ export class ObjectivesManager { if (condition.params?.bandwidthIndex === undefined) return false; const targetIndex = condition.params.bandwidthIndex; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { + this.observe_(rfFrontEnd.filterModule.state.bandwidthIndex); return rfFrontEnd.filterModule.state.bandwidthIndex === targetIndex; }); } @@ -1660,9 +1885,7 @@ export class ObjectivesManager { if (!notchState.isPowered) return false; // Check specific notch or any notch - const notchesToCheck = specificNotchIndex !== undefined - ? [notchState.notches[specificNotchIndex]].filter(Boolean) - : notchState.notches; + const notchesToCheck = specificNotchIndex !== undefined ? [notchState.notches[specificNotchIndex]].filter(Boolean) : notchState.notches; return notchesToCheck.some((notch) => { if (!notch.enabled) return false; @@ -1693,6 +1916,7 @@ export class ObjectivesManager { const targetFrequency = condition.params.beaconFrequency; const tolerance = condition.params.frequencyTolerance ?? 1e6; // 1 MHz default return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { + this.observe_(antenna.state.beaconFrequencyHz); const diff = Math.abs(antenna.state.beaconFrequencyHz - targetFrequency); return diff <= tolerance; }); @@ -1702,21 +1926,28 @@ export class ObjectivesManager { if (!condition.params?.trackingMode) return false; const targetMode = condition.params.trackingMode; return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { + this.observe_({ trackingMode: antenna.state.trackingMode, isStepTrackEnabled: antenna.state.isStepTrackEnabled }); // Step-track is an optimization layer on top of program-track, not a separate mode if (targetMode === 'step-track') { - return antenna.state.trackingMode === 'program-track' && - antenna.state.isStepTrackEnabled === true; + return antenna.state.trackingMode === 'program-track' && antenna.state.isStepTrackEnabled === true; } return antenna.state.trackingMode === targetMode; }); } - case 'antenna-beacon-locked': { + case 'antenna-polarization-set': { + if (!condition.params?.circularHandedness) return false; + const targetHandedness = condition.params.circularHandedness; return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { - return antenna.state.isBeaconLocked === true; + this.observe_(antenna.state.circularHandedness); + return antenna.state.circularHandedness === targetHandedness; }); } + case 'antenna-beacon-locked': { + return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => antenna.state.isBeaconLocked === true); + } + case 'antenna-position': { const targetAz = condition.params?.azimuth; const targetEl = condition.params?.elevation; @@ -1730,6 +1961,7 @@ export class ObjectivesManager { return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { const state = antenna.state; + this.observe_({ azimuth: state.azimuth, elevation: state.elevation }); // Check azimuth if specified (handle 360° wraparound) if (targetAz !== undefined) { @@ -1749,9 +1981,7 @@ export class ObjectivesManager { } case 'feed-heater-enabled': { - return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => { - return antenna.state.isHeaterEnabled === true; - }); + return this.evaluateEquipment_(gs.antennas, condition.params, (antenna) => antenna.state.isHeaterEnabled === true); } case 'buc-unmuted': { @@ -1767,10 +1997,8 @@ export class ObjectivesManager { const tolerance = condition.params.gainTolerance ?? 0; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const bucState = rfFrontEnd.bucModule.state; - return ( - bucState.isPowered && - Math.abs(bucState.gain - targetGain) <= tolerance - ); + this.observe_(bucState.gain); + return bucState.isPowered && Math.abs(bucState.gain - targetGain) <= tolerance; }); } @@ -1787,10 +2015,8 @@ export class ObjectivesManager { const tolerance = condition.params.backOffTolerance ?? 0.5; return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const hpaState = rfFrontEnd.hpaModule.state; - return ( - hpaState.isPowered && - Math.abs(hpaState.backOff - targetBackOff) <= tolerance - ); + this.observe_(hpaState.backOff); + return hpaState.isPowered && Math.abs(hpaState.backOff - targetBackOff) <= tolerance; }); } @@ -1809,6 +2035,7 @@ export class ObjectivesManager { const minPowerDbm = 10 * Math.log10(minPowerWatts * 1000); return this.evaluateEquipment_(gs.rfFrontEnds, condition.params, (rfFrontEnd) => { const hpaState = rfFrontEnd.hpaModule.state; + this.observe_(hpaState.outputPower); return hpaState.isPowered && hpaState.isHpaEnabled && hpaState.outputPower >= minPowerDbm; }); } @@ -1830,7 +2057,7 @@ export class ObjectivesManager { case 'receiver-signal-locked': { return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; - const modem = receiver.state.modems.find(m => m.modemNumber === modemNum); + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); if (!modem?.isPowered) return false; const signalInfo = receiver.getSignalsInBandwidth(modem); @@ -1839,14 +2066,47 @@ export class ObjectivesManager { } case 'receiver-snr-threshold': { - const minCNRatio = condition.params?.minCNRatio ?? 10; - return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { + // minCNRatio: C/N must be at or above (default). maxCNRatio (optional): + // C/N must be at or below - used to assert a link has been denied. When + // only maxCNRatio is given, the lower bound defaults to -Infinity. + const hasMax = condition.params?.maxCNRatio !== undefined; + const minCNRatio = condition.params?.minCNRatio ?? (hasMax ? -Infinity : 10); + const maxCNRatio = condition.params?.maxCNRatio ?? Infinity; + const inBand = this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; - const modem = receiver.state.modems.find(m => m.modemNumber === modemNum); + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); if (!modem?.isPowered) return false; const snr = receiver.getSnrForModem(modem); - return snr !== null && snr >= minCNRatio; + this.observe_(snr); + return snr !== null && snr >= minCNRatio && snr <= maxCNRatio; + }); + + // cnHoldSeconds: the reading must stay in band for a continuous run + // before the condition reads true. Without it a maxCNRatio ceiling + // latches on the one transient low frame the 1 s LEO position + // throttle puts in every second of a pass. The run lives on the + // condition state; a read-only probe (no dtSeconds) leaves it alone. + const holdSeconds = condition.params?.cnHoldSeconds ?? 0; + if (holdSeconds <= 0) return inBand; + const held = objectiveState.conditionStates.find((cs) => cs.condition === condition); + if (!held) return inBand; + if (dtSeconds !== undefined) { + held.heldSeconds = inBand ? (held.heldSeconds ?? 0) + dtSeconds : 0; + } + return inBand && (held.heldSeconds ?? 0) >= holdSeconds; + } + + case 'receiver-afc-enabled': { + // Default target is true (AFC on); afcEnabled: false asserts the + // operator is tuning by hand (AFC absent on legacy modems counts as off) + const targetAfc = condition.params?.afcEnabled ?? true; + return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { + const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); + if (!modem?.isPowered) return false; + + return (modem.isAfcEnabled === true) === targetAfc; }); } @@ -1856,11 +2116,12 @@ export class ObjectivesManager { const tolerance = condition.params.frequencyTolerance ?? 1e6; // Default 1 MHz return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; - const modem = receiver.state.modems.find(m => m.modemNumber === modemNum); + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); if (!modem?.isPowered) return false; // Modem frequency is in MHz, target is in Hz const modemFreqHz = modem.frequency * 1e6; + this.observe_(modemFreqHz); const diff = Math.abs(modemFreqHz - targetFrequency); return diff <= tolerance; }); @@ -1872,11 +2133,12 @@ export class ObjectivesManager { const tolerance = condition.params.bandwidthTolerance ?? 1e6; // Default 1 MHz return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; - const modem = receiver.state.modems.find(m => m.modemNumber === modemNum); + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); if (!modem?.isPowered) return false; // Modem bandwidth is in MHz, target is in Hz const modemBwHz = modem.bandwidth * 1e6; + this.observe_(modemBwHz); const diff = Math.abs(modemBwHz - targetBandwidth); return diff <= tolerance; }); @@ -1887,9 +2149,10 @@ export class ObjectivesManager { const targetModulation = condition.params.modulation; return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; - const modem = receiver.state.modems.find(m => m.modemNumber === modemNum); + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); if (!modem?.isPowered) return false; + this.observe_(modem.modulation); return modem.modulation === targetModulation; }); } @@ -1899,9 +2162,10 @@ export class ObjectivesManager { const targetFec = condition.params.fec; return this.evaluateEquipment_(gs.receivers, condition.params, (receiver) => { const modemNum = condition.params?.modemNumber ?? receiver.state.activeModem; - const modem = receiver.state.modems.find(m => m.modemNumber === modemNum); + const modem = receiver.state.modems.find((m) => m.modemNumber === modemNum); if (!modem?.isPowered) return false; + this.observe_(modem.fec); return modem.fec === targetFec; }); } @@ -1912,9 +2176,10 @@ export class ObjectivesManager { const tolerance = condition.params.frequencyTolerance ?? 1e6; // Default 1 MHz return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); if (!modem?.isPowered) return false; // Transmitter frequency is in Hz (stored in ifSignal) + this.observe_(modem.ifSignal.frequency); const diff = Math.abs(modem.ifSignal.frequency - targetFrequency); return diff <= tolerance; }); @@ -1926,8 +2191,9 @@ export class ObjectivesManager { const tolerance = condition.params.powerTolerance ?? 1; // Default 1 dB return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); if (!modem?.isPowered) return false; + this.observe_(modem.ifSignal.power); const diff = Math.abs(modem.ifSignal.power - targetPower); return diff <= tolerance; }); @@ -1939,8 +2205,9 @@ export class ObjectivesManager { const tolerance = condition.params.bandwidthTolerance ?? 1e6; // Default 1 MHz return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); if (!modem?.isPowered) return false; + this.observe_(modem.ifSignal.bandwidth); const diff = Math.abs(modem.ifSignal.bandwidth - targetBandwidth); return diff <= tolerance; }); @@ -1951,8 +2218,9 @@ export class ObjectivesManager { const targetModulation = condition.params.modulation; return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); if (!modem?.isPowered) return false; + this.observe_(modem.ifSignal.modulation); return modem.ifSignal.modulation === targetModulation; }); } @@ -1962,12 +2230,13 @@ export class ObjectivesManager { const targetFec = condition.params.fec; return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); if (!modem?.isPowered) { console.log(`[tx-modem-fec-set] Modem ${modemNum} not powered. isPowered=${modem?.isPowered}`); return false; } const actualFec = modem.ifSignal.fec; + this.observe_(actualFec); const result = actualFec === targetFec; console.log(`[tx-modem-fec-set] gs=${gs.state.id}, modem=${modemNum}, targetFec=${targetFec}, actualFec=${actualFec}, result=${result}`); return result; @@ -1977,7 +2246,7 @@ export class ObjectivesManager { case 'tx-modem-transmitting': { return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); return modem?.isPowered === true && modem?.isTransmitting === true; }); } @@ -1985,7 +2254,7 @@ export class ObjectivesManager { case 'tx-modem-not-transmitting': { return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); // Modem must be powered but NOT transmitting return modem?.isPowered === true && modem?.isTransmitting === false; }); @@ -1995,6 +2264,7 @@ export class ObjectivesManager { if (condition.params?.modemNumber === undefined) return false; const targetModem = condition.params.modemNumber; return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { + this.observe_(transmitter.state.activeModem); return transmitter.state.activeModem === targetModem; }); } @@ -2002,7 +2272,7 @@ export class ObjectivesManager { case 'tx-modem-loopback-enabled': { return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); return modem?.isPowered === true && modem?.isLoopback === true; }); } @@ -2010,7 +2280,7 @@ export class ObjectivesManager { case 'tx-modem-loopback-disabled': { return this.evaluateEquipment_(gs.transmitters, condition.params, (transmitter) => { const modemNum = condition.params?.modemNumber ?? transmitter.state.activeModem; - const modem = transmitter.state.modems.find(m => m.modem_number === modemNum); + const modem = transmitter.state.modems.find((m) => m.modem_number === modemNum); return modem?.isPowered === true && modem?.isLoopback === false; }); } @@ -2024,9 +2294,7 @@ export class ObjectivesManager { } const quizManager = QuizManager.getInstance(); - const conditionIndex = objectiveState.conditionStates.findIndex( - cs => cs.condition === condition - ); + const conditionIndex = objectiveState.conditionStates.findIndex((cs) => cs.condition === condition); // Register the quiz if not already registered // Note: Quiz is NOT shown immediately - pending indicator appears instead @@ -2049,6 +2317,54 @@ export class ObjectivesManager { return quizManager.isQuizComplete(objectiveState.objective.id, conditionIndex); } + case 'decision': { + // A judgement graded against held evidence facts at answer time. + // Registration hands DecisionManager live readers; this case only + // ticks the facts and reports whether the decision has been resolved. + const params = condition.params; + if (!params?.prompt || !params.decisionOptions?.length) { + console.warn('decision condition missing required params (prompt, decisionOptions)'); + return false; + } + + const gsKey = gs?.state.id ?? ''; + let registry = this.evidenceFacts_.get(gsKey); + if (!registry) { + registry = new EvidenceFactRegistry(); + this.evidenceFacts_.set(gsKey, registry); + } + registry.tick(dtSeconds ?? 0, { gs }); + + const decisions = DecisionManager.getInstance(); + const conditionIndex = objectiveState.conditionStates.findIndex((cs) => cs.condition === condition); + if (!decisions.has(objectiveState.objective.id, conditionIndex)) { + const factReader = registry; + const evidenceIds = params.evidence ?? []; + decisions.register(objectiveState.objective.id, conditionIndex, { + prompt: params.prompt, + options: params.decisionOptions, + explanation: params.explanation, + pointPenalty: params.pointPenalty, + partialCreditUnevidenced: params.partialCreditUnevidenced, + character: params.character, + preserveOptionOrder: params.preserveDecisionOrder, + groundStationId: objectiveState.objective.groundStation, + readFact: (id) => factReader.read(id), + evidenceStatus: () => + evidenceIds.map((id) => { + const sibling = objectiveState.conditionStates.find((cs) => cs.condition.id === id); + if (!sibling) { + console.warn(`decision evidence "${id}" names no condition in objective ${objectiveState.objective.id}`); + return { id, label: id, ready: false }; + } + return { id, label: sibling.condition.description, ready: sibling.observed === true || sibling.isMaintenanceComplete }; + }), + }); + } + + return decisions.isResolved(objectiveState.objective.id, conditionIndex); + } + case 'handover-complete': { // Check if handover to target station completed const targetGsId = condition.params?.targetGroundStationId; @@ -2127,6 +2443,7 @@ export class ObjectivesManager { if (!targetTab) return false; const activeTab = TabbedCanvas.getActiveTab(); + this.observe_(activeTab); if (!activeTab) return false; // Match exact tab ID or prefix (e.g., 'acu-control' matches 'acu-control-0') @@ -2154,7 +2471,6 @@ export class ObjectivesManager { const modem = receiver.activeModem; // FECSimulator calculates frame sync from signal conditions - const { FECSimulator } = require('@app/equipment/receiver/fec-simulator'); const fecSim = new FECSimulator(); const metrics = fecSim.calculate({ cnRatio_dB: signalInfo.cnRatio_dB, @@ -2164,6 +2480,7 @@ export class ObjectivesManager { fec: modem.fec, }); + this.observe_(metrics.frameSyncLocked); return metrics.frameSyncLocked === expectedLocked; } @@ -2180,7 +2497,6 @@ export class ObjectivesManager { const signalInfo = receiver.getSignalsInBandwidth(); const modem = receiver.activeModem; - const { FECSimulator } = require('@app/equipment/receiver/fec-simulator'); const fecSim = new FECSimulator(); const metrics = fecSim.calculate({ cnRatio_dB: signalInfo.cnRatio_dB, @@ -2190,6 +2506,7 @@ export class ObjectivesManager { fec: modem.fec, }); + this.observe_(metrics.ber); if (comparison === 'below') { return metrics.ber < threshold; } else { @@ -2207,7 +2524,6 @@ export class ObjectivesManager { const signalInfo = receiver.getSignalsInBandwidth(); const modem = receiver.activeModem; - const { FECSimulator } = require('@app/equipment/receiver/fec-simulator'); const fecSim = new FECSimulator(); const metrics = fecSim.calculate({ cnRatio_dB: signalInfo.cnRatio_dB, @@ -2217,6 +2533,7 @@ export class ObjectivesManager { fec: modem.fec, }); + this.observe_(metrics.rsUncorrectableBlocks); return metrics.rsUncorrectableBlocks > 0; } @@ -2232,7 +2549,6 @@ export class ObjectivesManager { const signalInfo = receiver.getSignalsInBandwidth(); const modem = receiver.activeModem; - const { FECSimulator } = require('@app/equipment/receiver/fec-simulator'); const fecSim = new FECSimulator(); const metrics = fecSim.calculate({ cnRatio_dB: signalInfo.cnRatio_dB, @@ -2242,6 +2558,7 @@ export class ObjectivesManager { fec: modem.fec, }); + this.observe_(metrics.channelStatus); return metrics.channelStatus === expectedStatus; } @@ -2256,6 +2573,7 @@ export class ObjectivesManager { const crypto = CryptoModule.getInstance(); const rxState = crypto.getRxState(); + this.observe_(rxState.decryptionMode); return rxState.decryptionMode === expectedMode; } @@ -2266,6 +2584,7 @@ export class ObjectivesManager { const crypto = CryptoModule.getInstance(); const rxState = crypto.getRxState(); + this.observe_(rxState.decryptionKeyStatus); return rxState.decryptionKeyStatus === expectedStatus; } @@ -2276,6 +2595,7 @@ export class ObjectivesManager { const crypto = CryptoModule.getInstance(); const txState = crypto.getTxState(); + this.observe_(txState.encryptionMode); return txState.encryptionMode === expectedMode; } @@ -2286,6 +2606,7 @@ export class ObjectivesManager { const crypto = CryptoModule.getInstance(); const txState = crypto.getTxState(); + this.observe_(txState.encryptionKeyStatus); return txState.encryptionKeyStatus === expectedStatus; } @@ -2311,6 +2632,215 @@ export class ObjectivesManager { return !faultInjector.isActive(faultId); } + case 'geolocation-measurements-collected': { + // >= minCount TDOA/FDOA captures collected on the geolocation console + if (!GeolocationConsoleCore.isInitialized()) return false; + const minCount = condition.params?.minCount ?? 1; + const eventId = condition.params?.interferenceEventId; + const measurements = GeolocationConsoleCore.getInstance().state.measurements; + const count = eventId ? measurements.filter((m) => m.interferenceEventId === eventId).length : measurements.length; + return count >= minCount; + } + + case 'geolocation-fix-accuracy': { + // Computed fix within maxErrorKm of the emitter ground truth + if (!GeolocationConsoleCore.isInitialized()) return false; + const maxErrorKm = condition.params?.maxErrorKm ?? 25; + const state = GeolocationConsoleCore.getInstance().state; + return state.fix !== null && state.fixErrorKm !== null && state.fixErrorKm <= maxErrorKm; + } + + case 'interference-event-ended': { + // The named interference event ran its envelope and stopped + if (!InterferenceManager.isInitialized()) return false; + const eventId = condition.params?.interferenceEventId; + return eventId !== undefined && InterferenceManager.getInstance().hasEventEnded(eventId); + } + + case 'geolocation-ellipse-within': { + // Converged fix whose 95% error ellipse is no wider than maxSemiMajorKm. + // Graded on the ellipse the console reports, not on the hidden truth: + // it is the operator's own confidence statement that has to be small. + if (!GeolocationConsoleCore.isInitialized()) return false; + const maxSemiMajorKm = condition.params?.maxSemiMajorKm ?? 15; + const fix = GeolocationConsoleCore.getInstance().state.fix; + return fix !== null && fix.isConverged && fix.errorEllipse !== null && fix.errorEllipse.semiMajorKm <= maxSemiMajorKm; + } + + case 'jamming-uplink-active': { + // A jam waveform is radiating in the target transponder's uplink band + if (!ElectronicAttackManager.isInitialized()) return false; + const assessment = ElectronicAttackManager.getInstance().getAssessment(); + return assessment?.isRadiatingInBand === true; + } + + case 'jamming-effective': { + // Denial achieved: radiating on target with J/S at/above the threshold + if (!ElectronicAttackManager.isInitialized()) return false; + const assessment = ElectronicAttackManager.getInstance().getAssessment(); + return assessment?.isEffective === true; + } + + // ═══════════════════════════════════════════════════════════════ + // nats-eu (Campaign 2 European Operations) Conditions + // ═══════════════════════════════════════════════════════════════ + + case 'link-budget-computed': { + // Operator's computed C/N worksheet matches the acceptance truth + if (!LinkBudgetManager.isInitialized()) return false; + return LinkBudgetManager.getInstance().isBudgetComputedCorrectly(); + } + + case 'link-margin-met': { + // Committed link achieves the required margin over the demod threshold + if (!LinkBudgetManager.isInitialized()) return false; + return LinkBudgetManager.getInstance().isMarginMet(condition.params?.minMarginDb); + } + + case 'uplink-doppler-comp-enabled': { + // Uplink Doppler compensation engaged on the command link + if (!CommandingManager.isInitialized()) return false; + return CommandingManager.getInstance().state.dopplerCompEnabled; + } + + case 'command-acknowledged': { + // A TT&C command (or a specific one) was sent in-window and ACKed + if (!CommandingManager.isInitialized()) return false; + return CommandingManager.getInstance().isCommandAcknowledged(condition.params?.commandId); + } + + case 'key-rotation-completed': { + // A scheduled command-link key rotation has completed + if (!CommandingManager.isInitialized()) return false; + return CommandingManager.getInstance().state.keyRotationCompleted; + } + + case 'zeroize-executed': { + // The command-link key has been zeroized (emergency destruction) + if (!CommandingManager.isInitialized()) return false; + return CommandingManager.getInstance().state.zeroized; + } + + case 'contact-assigned': { + // A pass/contact has been allocated (to a specific station if given) + if (!ContactScheduleManager.isInitialized()) return false; + const contactId = condition.params?.contactId; + if (!contactId) return false; + return ContactScheduleManager.getInstance().isContactAssigned(contactId, condition.params?.groundStationId); + } + + case 'contact-plan-valid': { + // The contact plan has no conflicts and covers all required passes + if (!ContactScheduleManager.isInitialized()) return false; + return ContactScheduleManager.getInstance().isPlanValid(); + } + + case 'ephemeris-updated': { + // Fresh ephemeris loaded after a maneuver (specific event if given) + if (!SpaceEventManager.isInitialized()) return false; + return SpaceEventManager.getInstance().isEphemerisUpdated(condition.params?.eventId); + } + + case 'audit-log-reviewed': { + // The station audit log has been opened and reviewed + if (!SecurityConsoleCore.isInitialized()) return false; + return SecurityConsoleCore.getInstance().isReviewed; + } + + case 'security-event-acknowledged': { + // A specific audit-log event has been acknowledged/flagged + if (!SecurityConsoleCore.isInitialized()) return false; + const eventId = condition.params?.eventId; + if (!eventId) return false; + return SecurityConsoleCore.getInstance().isEventAcknowledged(eventId); + } + + case 'access-control-set': { + // A station account is at the target access state (default 'disabled') + if (!SecurityConsoleCore.isInitialized()) return false; + const accountId = condition.params?.accountId; + if (!accountId) return false; + const target = condition.params?.accountStatus ?? 'disabled'; + return SecurityConsoleCore.getInstance().getAccountStatus(accountId) === target; + } + + case 'transec-mode-set': { + // The modem TRANSEC waveform mode matches the target + if (!TransecManager.isInitialized()) return false; + const mode = condition.params?.transecMode ?? 'hopping'; + return TransecManager.getInstance().isModeSet(mode); + } + + case 'transec-sync-locked': { + // The TRANSEC hop set is keyed and hop-sync is locked + if (!TransecManager.isInitialized()) return false; + return TransecManager.getInstance().isSyncLocked(); + } + + case 'gpsdo-reference-mode-set': { + // The GPSDO reference/discipline mode matches the target (default 'holdover') + if (!GnssThreatManager.isInitialized()) return false; + const mode = condition.params?.referenceMode ?? 'holdover'; + return GnssThreatManager.getInstance().isReferenceModeSet(mode); + } + + case 'gpsdo-time-offset-exceeds': { + // The tell: this station's GNSS-vs-reference offset has walked past the + // threshold. Pair with requiresObservation on gps-timing so the read counts. + if (!GnssThreatManager.isInitialized()) return false; + const minUs = condition.params?.minOffsetUs ?? 20; + const offsetUs = GnssThreatManager.getInstance().timeOffsetUsFor(gs.state.id); + this.observe_({ timeOffsetUs: Number(offsetUs.toFixed(1)) }); + return Math.abs(offsetUs) >= minUs; + } + + case 'campaign-document-reviewed': { + // The campaign record (earlier scenarios' Working Documents) has been opened this run + return CampaignDocumentStore.isReviewed; + } + + case 'telemetry-frames-received': { + if (!TelemetryManager.isInitialized()) return false; + const count = TelemetryManager.getInstance().frameCount; + this.observe_({ frames: count }); + return count >= (condition.params?.minFrames ?? 1); + } + + case 'telemetry-channel-in-band': { + // A channel reads in the target band. Pair with requiresObservation on + // the telemetry tab so the read counts. Stale streams never match. + if (!TelemetryManager.isInitialized()) return false; + const channelId = condition.params?.channelId; + if (!channelId) return false; + const mgr = TelemetryManager.getInstance(); + const reading = mgr.getReading(channelId); + if (!reading || mgr.isStale) return false; + this.observe_({ channelId, value: Number(reading.value.toFixed(reading.decimals)), band: reading.band }); + return reading.band === (condition.params?.telemetryBand ?? 'green'); + } + + case 'telemetry-soh-nominal': { + if (!TelemetryManager.isInitialized()) return false; + return TelemetryManager.getInstance().isSohNominal(); + } + + case 'ranging-measurements': { + if (!CommandingManager.isInitialized()) return false; + const mgr = CommandingManager.getInstance(); + const count = mgr.state.rangingMeasurements.length; + this.observe_({ measurements: count }); + return count >= (condition.params?.minCount ?? mgr.getConfig().ranging?.requiredMeasurements ?? 1); + } + + case 'gpsdo-time-offset-stable': { + // The offset has held still for the hold period: in holdover that is + // always true, so gate it on gpsdo-reference-mode-set 'gnss' to prove + // the spoofer is off the air rather than the reference disconnected. + if (!GnssThreatManager.isInitialized()) return false; + const holdS = condition.params?.holdSeconds ?? 30; + return GnssThreatManager.getInstance().secondsSinceOffsetChange >= holdS; + } + default: console.warn(`Unknown condition type: ${condition.type}`); return false; diff --git a/src/ops-log/event-auto-logger.ts b/src/ops-log/event-auto-logger.ts index c296402d..47a628d8 100644 --- a/src/ops-log/event-auto-logger.ts +++ b/src/ops-log/event-auto-logger.ts @@ -1,3 +1,4 @@ +import { SimClock } from '@app/simulation/sim-clock'; /** * @file EventAutoLogger - Auto-logs equipment events to OpsLogManager * @description Singleton service that subscribes to equipment events and creates @@ -16,14 +17,7 @@ import type { NotchFilterState } from '@app/equipment/rf-front-end/notch-filter- import type { OMTState } from '@app/equipment/rf-front-end/omt-module/omt-module'; import type { RFFrontEndState } from '@app/equipment/rf-front-end/rf-front-end-core'; import { EventBus } from '@app/events/event-bus'; -import { - Events, - RxActiveModemChangedData, - RxConfigChangedData, - TxActiveModemChangedData, - TxConfigChangedData, - TxTransmitChangedData -} from '@app/events/events'; +import { Events, RxActiveModemChangedData, RxConfigChangedData, TxActiveModemChangedData, TxConfigChangedData, TxTransmitChangedData } from '@app/events/events'; import { ScenarioManager } from '@app/scenario-manager'; import { formatAgcEvent, @@ -41,7 +35,7 @@ import { formatRxConfigEvent, formatTxActiveModemEvent, formatTxConfigEvent, - formatTxTransmitEvent + formatTxTransmitEvent, } from './event-formatters'; import { OpsLogManager } from './ops-log-manager'; @@ -161,9 +155,9 @@ export class EventAutoLogger { // Check time-based throttling if (!skipThrottle) { - const now = Date.now(); - const lastTime = this.lastLogTime_.get(event) ?? 0; - if (now - lastTime < EventAutoLogger.THROTTLE_MS) { + const now = SimClock.runMs(); + const lastTime = this.lastLogTime_.get(event) ?? -Infinity; + if (now >= lastTime && now - lastTime < EventAutoLogger.THROTTLE_MS) { return true; } this.lastLogTime_.set(event, now); diff --git a/src/ops-log/event-formatters.ts b/src/ops-log/event-formatters.ts index 26ab2c64..70de33eb 100644 --- a/src/ops-log/event-formatters.ts +++ b/src/ops-log/event-formatters.ts @@ -18,23 +18,17 @@ import type { NotchFilterState } from '@app/equipment/rf-front-end/notch-filter- import type { OMTState, PolarizationType } from '@app/equipment/rf-front-end/omt-module/omt-module'; import type { RFFrontEndState } from '@app/equipment/rf-front-end/rf-front-end-core'; import type { TransmitterModem } from '@app/equipment/transmitter/transmitter'; -import type { - RxActiveModemChangedData, - RxConfigChangedData, - TxActiveModemChangedData, - TxConfigChangedData, - TxTransmitChangedData -} from '@app/events/events'; +import type { RxActiveModemChangedData, RxConfigChangedData, TxActiveModemChangedData, TxConfigChangedData, TxTransmitChangedData } from '@app/events/events'; // ════════════════════════════════════════════════════════════════════════════ // Antenna Formatters // ════════════════════════════════════════════════════════════════════════════ const TRACKING_MODE_LABELS: Record = { - 'stow': 'STOW', - 'maintenance': 'MAINTENANCE', - 'manual': 'MANUAL', - 'program-track': 'PROGRAM-TRACK' + stow: 'STOW', + maintenance: 'MAINTENANCE', + manual: 'MANUAL', + 'program-track': 'PROGRAM-TRACK', }; /** @@ -247,11 +241,11 @@ export function formatFilterEvent(data: Partial): string | nu */ export function formatNotchFilterEvent(data: Partial): string | null { if (data.notches) { - const enabled = data.notches.filter(n => n.enabled); + const enabled = data.notches.filter((n) => n.enabled); if (enabled.length === 0) { return 'Notch filters: all disabled'; } - const freqs = enabled.map(n => `${n.centerFrequency} MHz`).join(', '); + const freqs = enabled.map((n) => `${n.centerFrequency} MHz`).join(', '); return `Notch filter${enabled.length > 1 ? 's' : ''} enabled at ${freqs}`; } return null; diff --git a/src/ops-log/ops-log-manager.ts b/src/ops-log/ops-log-manager.ts index e7801752..ce86a8e9 100644 --- a/src/ops-log/ops-log-manager.ts +++ b/src/ops-log/ops-log-manager.ts @@ -6,12 +6,21 @@ import { EventBus } from '@app/events/event-bus'; import { Events, SimulatedTimeTickData } from '@app/events/events'; +import { SimClock } from '@app/simulation/sim-clock'; import { Milliseconds } from 'ootk'; import { OpsLogEntry, OpsLogState, PreviousShiftLogEntry } from './ops-log-types'; /** Month abbreviations for military datetime format */ const MONTH_ABBREVS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']; +/** Developer/E2E clock hooks installed on window while a scenario is loaded. */ +interface SimClockDevHooks { + advanceClock?: (deltaMs: number) => void; + simClockMs?: () => number; + missionElapsedMs?: () => number; + missionSkippedMs?: () => number; +} + /** * Manages operations logging for scenario-based simulations */ @@ -22,25 +31,16 @@ export class OpsLogManager { private readonly eventBus_: EventBus; private readonly boundUpdateHandler_: (dt: Milliseconds) => void; - /** Current simulated time as Unix timestamp in milliseconds */ - private currentTimestampMs_: number; - - /** Whether simulated time is currently paused (starts paused until scenario unlocks) */ - private isPaused_: boolean = true; - /** Last whole second value for detecting second boundary crossings */ private lastWholeSecond_: number = 0; - private constructor( - startWallTime: string = '12:00:00', - startDate: string = '2026-01-01', - previousShiftLogs: PreviousShiftLogEntry[] = [] - ) { + private constructor(startWallTime: string = '12:00:00', startDate: string = '2026-01-01', previousShiftLogs: PreviousShiftLogEntry[] = []) { this.eventBus_ = EventBus.getInstance(); - // Parse date and time into a timestamp - this.currentTimestampMs_ = this.parseDateTime_(startDate, startWallTime); - this.lastWholeSecond_ = Math.floor(this.currentTimestampMs_ / 1000); + // The scenario clock starts at the authored date/time, paused until the + // scenario unlocks (ObjectivesManager resumes it) + SimClock.startScenario(this.parseDateTime_(startDate, startWallTime)); + this.lastWholeSecond_ = Math.floor(SimClock.nowMs() / 1000); // Load previous shift entries for (const log of previousShiftLogs) { @@ -56,6 +56,16 @@ export class OpsLogManager { this.boundUpdateHandler_ = this.handleUpdate_.bind(this); this.eventBus_.on(Events.UPDATE, this.boundUpdateHandler_); + // Developer/E2E hooks (see advanceClock JSDoc); same pattern as + // window.debugSignalPath in RFFrontEndCore. The readers let a spec assert + // that an operator time skip moved the scenario clock. + const devHooks = window as unknown as SimClockDevHooks; + + devHooks.advanceClock = this.advanceClock.bind(this); + devHooks.simClockMs = () => SimClock.nowMs(); + devHooks.missionElapsedMs = () => SimClock.scenarioElapsedMs(); + devHooks.missionSkippedMs = () => SimClock.skippedMs(); + // Emit initial time tick this.emitTimeTick_(); } @@ -66,11 +76,7 @@ export class OpsLogManager { * @param startDate Fictional start date in "YYYY-MM-DD" format (default "2025-01-01") * @param previousShiftLogs Array of previous shift log entries from scenario */ - static initialize( - startWallTime?: string, - startDate?: string, - previousShiftLogs?: PreviousShiftLogEntry[] - ): OpsLogManager { + static initialize(startWallTime?: string, startDate?: string, previousShiftLogs?: PreviousShiftLogEntry[]): OpsLogManager { if (OpsLogManager.instance_) { console.warn('OpsLogManager already initialized. Destroying previous instance.'); OpsLogManager.destroy(); @@ -101,10 +107,13 @@ export class OpsLogManager { */ static destroy(): void { if (OpsLogManager.instance_) { - OpsLogManager.instance_.eventBus_.off( - Events.UPDATE, - OpsLogManager.instance_.boundUpdateHandler_ - ); + OpsLogManager.instance_.eventBus_.off(Events.UPDATE, OpsLogManager.instance_.boundUpdateHandler_); + const devHooks = window as unknown as SimClockDevHooks; + + delete devHooks.advanceClock; + delete devHooks.simClockMs; + delete devHooks.missionElapsedMs; + delete devHooks.missionSkippedMs; OpsLogManager.instance_ = null; } } @@ -114,7 +123,7 @@ export class OpsLogManager { * Called when quiz is passed, scenario fails, or completes */ pause(): void { - this.isPaused_ = true; + SimClock.pause('scenario'); } /** @@ -122,14 +131,14 @@ export class OpsLogManager { * Called when quiz is completed */ resume(): void { - this.isPaused_ = false; + SimClock.resume('scenario'); } /** * Check if simulated time is currently paused */ isPaused(): boolean { - return this.isPaused_; + return SimClock.isPaused('scenario'); } /** @@ -140,7 +149,7 @@ export class OpsLogManager { */ log(message: string, category: OpsLogEntry['category'] = 'action', source?: string): void { const entry: OpsLogEntry = { - timestamp: this.formatTimeOnly_(this.currentTimestampMs_), + timestamp: this.formatTimeOnly_(SimClock.nowMs()), message, category, source, @@ -163,14 +172,14 @@ export class OpsLogManager { * e.g., "15 MAR 2025 22:05:15" */ getCurrentTimeFormatted(): string { - return this.formatMilitaryDateTime_(this.currentTimestampMs_); + return this.formatMilitaryDateTime_(SimClock.nowMs()); } /** * Get current simulated timestamp in milliseconds */ getCurrentTimestampMs(): number { - return this.currentTimestampMs_; + return SimClock.nowMs(); } /** @@ -179,7 +188,7 @@ export class OpsLogManager { getState(): OpsLogState { return { entries: [...this.entries_], - currentTimestampMs: this.currentTimestampMs_, + currentTimestampMs: SimClock.nowMs(), }; } @@ -190,29 +199,45 @@ export class OpsLogManager { restoreState(state: OpsLogState): void { this.entries_.length = 0; this.entries_.push(...state.entries); - this.currentTimestampMs_ = state.currentTimestampMs; - this.lastWholeSecond_ = Math.floor(this.currentTimestampMs_ / 1000); + SimClock.setScenarioNowMs(state.currentTimestampMs); + this.lastWholeSecond_ = Math.floor(state.currentTimestampMs / 1000); // Reset pause state on restore - ObjectivesManager controls pause state - this.isPaused_ = false; + SimClock.resume('scenario'); // Emit time tick after restore this.emitTimeTick_(); } /** - * Handle simulation update - advance fictional clock + * Skip the scenario clock forward by deltaMs in a single step. + * + * Used by TimeSkipController (in chunks) and exposed as the developer/E2E + * hook window.advanceClock: LEO pass scenarios put objectives many + * sim-minutes apart, so tests jump the clock instead of waiting. Orbits and + * every mission-elapsed schedule move together (one clock); objective + * countdowns do not - they are decremented state, see + * ObjectivesManager.applyTimeSkip(). */ - private handleUpdate_(dt: Milliseconds): void { - // Don't advance time if paused - if (this.isPaused_) { + advanceClock(deltaMs: number): void { + if (!Number.isFinite(deltaMs) || deltaMs <= 0) { return; } - // Advance timestamp by delta time in milliseconds - this.currentTimestampMs_ += dt; + SimClock.skip(deltaMs); - // Check if we've crossed a second boundary - const currentWholeSecond = Math.floor(this.currentTimestampMs_ / 1000); + const currentWholeSecond = Math.floor(SimClock.nowMs() / 1000); + if (currentWholeSecond > this.lastWholeSecond_) { + this.lastWholeSecond_ = currentWholeSecond; + } + this.emitTimeTick_(); + } + + /** + * Handle simulation update - SimulationManager has already stepped the + * clock; emit a tick when scenario time crosses a whole second + */ + private handleUpdate_(_dt: Milliseconds): void { + const currentWholeSecond = Math.floor(SimClock.nowMs() / 1000); if (currentWholeSecond > this.lastWholeSecond_) { this.lastWholeSecond_ = currentWholeSecond; this.emitTimeTick_(); @@ -224,8 +249,8 @@ export class OpsLogManager { */ private emitTimeTick_(): void { const tickData: SimulatedTimeTickData = { - timeFormatted: this.formatMilitaryDateTime_(this.currentTimestampMs_), - timestampMs: this.currentTimestampMs_, + timeFormatted: this.formatMilitaryDateTime_(SimClock.nowMs()), + timestampMs: SimClock.nowMs(), }; this.eventBus_.emit(Events.SIMULATED_TIME_TICK, tickData); } diff --git a/src/ops-log/ops-log-modal.ts b/src/ops-log/ops-log-modal.ts index 1e014f0b..beb54d60 100644 --- a/src/ops-log/ops-log-modal.ts +++ b/src/ops-log/ops-log-modal.ts @@ -37,14 +37,8 @@ export class OpsLogModal extends DraggableModal { static destroy(): void { if (OpsLogModal.instance_) { - EventBus.getInstance().off( - Events.OPS_LOG_ENTRY_ADDED, - OpsLogModal.instance_.boundEntryAddedHandler_ - ); - EventBus.getInstance().off( - Events.SIMULATED_TIME_TICK, - OpsLogModal.instance_.boundTimeTickHandler_ - ); + EventBus.getInstance().off(Events.OPS_LOG_ENTRY_ADDED, OpsLogModal.instance_.boundEntryAddedHandler_); + EventBus.getInstance().off(Events.SIMULATED_TIME_TICK, OpsLogModal.instance_.boundTimeTickHandler_); OpsLogModal.instance_.close(); OpsLogModal.instance_ = null; } @@ -136,7 +130,7 @@ export class OpsLogModal extends DraggableModal { // Render entries newest-first container.innerHTML = [...entries] .reverse() - .map(entry => this.renderEntry_(entry)) + .map((entry) => this.renderEntry_(entry)) .join(''); } catch { container.innerHTML = '

    Operations log not available.

    '; diff --git a/src/pages/base-page.ts b/src/pages/base-page.ts index 3a2e32db..5be80c97 100644 --- a/src/pages/base-page.ts +++ b/src/pages/base-page.ts @@ -1,29 +1,41 @@ -import { BaseElement } from "@app/components/base-element"; -import { EventBus } from "@app/events/event-bus"; -import { DualTransmissionViolationData, Events, HpaNoiseAmplificationData, ObjectiveFailedData, ScenarioTimeExpiredData } from "@app/events/events"; -import { Logger } from "@app/logging/logger"; -import { DialogHistoryManager } from "@app/modal/dialog-history-manager"; -import { DialogManager } from "@app/modal/dialog-manager"; -import { LevelCompleteModal } from "@app/modal/level-complete-modal"; -import { ObjectiveFailedModal } from "@app/modal/objective-failed-modal"; -import { QuizModal } from "@app/modal/quiz-modal"; -import { TimePenaltyToast } from "@app/modal/time-penalty-toast"; -import { ObjectivesManager } from "@app/objectives/objectives-manager"; -import { EventAutoLogger } from "@app/ops-log/event-auto-logger"; -import { OpsLogManager } from "@app/ops-log/ops-log-manager"; -import { NavigationOptions, Router } from "@app/router"; -import { ScenarioManager } from "@app/scenario-manager"; -import { ScenarioDialogManager } from "@app/scenarios/scenario-dialog-manager"; -import { WorkingDocumentManager } from "@app/scenarios/working-document-manager"; -import { InterferenceManager } from "@app/interference/interference-manager"; -import { WeatherManager } from "@app/weather/weather-manager"; -import { ScenarioCompletionHandler } from "@app/scoring/scenario-completion-handler"; -import { ScoreCalculator } from "@app/scoring/score-calculator"; -import { SimulationManager } from "@app/simulation/simulation-manager"; -import { AppState } from "@app/sync/storage"; -import { ProgressSaveManager } from "@app/user-account/progress-save-manager"; -import { ScenarioProgressEntry } from "@app/user-account/types"; -import { getUserDataService } from "@app/user-account/user-data-service"; +import { CommandingManager } from '@app/commanding/commanding-manager'; +import { BaseElement } from '@app/components/base-element'; +import { ContactScheduleManager } from '@app/contact-schedule/contact-schedule-manager'; +import { ElectronicAttackManager } from '@app/electronic-attack/electronic-attack-manager'; +import { GeolocationConsoleCore } from '@app/equipment/geolocation-console/geolocation-console-core'; +import { EventBus } from '@app/events/event-bus'; +import { DualTransmissionViolationData, Events, HpaNoiseAmplificationData, ObjectiveFailedData, ProtectedFreqViolationData, ScenarioTimeExpiredData } from '@app/events/events'; +import { HardwareFaultManager } from '@app/faults/hardware-fault-manager'; +import { GnssThreatManager } from '@app/gnss-threat/gnss-threat-manager'; +import { InterferenceManager } from '@app/interference/interference-manager'; +import { LinkBudgetManager } from '@app/link-budget/link-budget-manager'; +import { Logger } from '@app/logging/logger'; +import { DecisionModal } from '@app/modal/decision-modal'; +import { DialogHistoryManager } from '@app/modal/dialog-history-manager'; +import { DialogManager } from '@app/modal/dialog-manager'; +import { LevelCompleteModal } from '@app/modal/level-complete-modal'; +import { ObjectiveFailedModal } from '@app/modal/objective-failed-modal'; +import { QuizModal } from '@app/modal/quiz-modal'; +import { TimePenaltyToast } from '@app/modal/time-penalty-toast'; +import { ObjectivesManager } from '@app/objectives/objectives-manager'; +import { EventAutoLogger } from '@app/ops-log/event-auto-logger'; +import { OpsLogManager } from '@app/ops-log/ops-log-manager'; +import { NavigationOptions, Router } from '@app/router'; +import { ScenarioManager } from '@app/scenario-manager'; +import { ScenarioDialogManager } from '@app/scenarios/scenario-dialog-manager'; +import { WorkingDocumentManager } from '@app/scenarios/working-document-manager'; +import { ScenarioCompletionHandler } from '@app/scoring/scenario-completion-handler'; +import { ScoreCalculator } from '@app/scoring/score-calculator'; +import { SecurityConsoleCore } from '@app/security-console/security-console-core'; +import { SimulationManager } from '@app/simulation/simulation-manager'; +import { SpaceEventManager } from '@app/space-events/space-event-manager'; +import { AppState } from '@app/sync/storage'; +import { TelemetryManager } from '@app/telemetry/telemetry-manager'; +import { TransecManager } from '@app/transec/transec-manager'; +import { ProgressSaveManager } from '@app/user-account/progress-save-manager'; +import { ScenarioProgressEntry } from '@app/user-account/types'; +import { getUserDataService } from '@app/user-account/user-data-service'; +import { WeatherManager } from '@app/weather/weather-manager'; export abstract class BasePage extends BaseElement { abstract id: string; @@ -74,11 +86,7 @@ export abstract class BasePage extends BaseElement { } // Initialize ops log manager (always, for all scenarios) - OpsLogManager.initialize( - scenario.settings.scenarioStartWallTime, - scenario.settings.scenarioStartDate, - scenario.settings.previousShiftLogs - ); + OpsLogManager.initialize(scenario.settings.scenarioStartWallTime, scenario.settings.scenarioStartDate, scenario.settings.previousShiftLogs); // Initialize event auto-logger (logs equipment events for beginner/intermediate) EventAutoLogger.getInstance().initialize(); @@ -110,8 +118,54 @@ export abstract class BasePage extends BaseElement { InterferenceManager.getInstance(); } + // Start the geolocation console engine (Campaign 5). Must come after + // InterferenceManager so it can query active emitter events. + if (scenario.settings.geolocation) { + GeolocationConsoleCore.getInstance(); + } + + // Start the electronic-attack engine (Campaign 4): a player-driven jam + // injector + J/S assessment + own-force deconfliction interlock. + if (scenario.settings.electronicAttack) { + ElectronicAttackManager.getInstance(); + } + + // Start scheduled transmit-string hardware faults (Campaign 4 redundancy) + if ((scenario.settings.hardwareFaultEvents?.length ?? 0) > 0) { + HardwareFaultManager.getInstance(); + } + + // Start the nats-eu (Campaign 2) opt-in mechanics. Each is gated by its + // own settings block, so no other campaign instantiates them. + if (scenario.settings.linkBudget) { + LinkBudgetManager.getInstance(); + } + if (scenario.settings.commanding) { + CommandingManager.getInstance(); + } + if (scenario.settings.contactSchedule) { + ContactScheduleManager.getInstance(); + } + if ((scenario.settings.spaceEvents?.length ?? 0) > 0) { + SpaceEventManager.getInstance(); + } + if (scenario.settings.security) { + SecurityConsoleCore.getInstance(); + } + if (scenario.settings.transec) { + TransecManager.getInstance(); + } + if (scenario.settings.gnssThreat) { + GnssThreatManager.getInstance(); + } + if (scenario.settings.telemetry) { + TelemetryManager.getInstance(); + } + // Initialize quiz modal for status-check objective conditions QuizModal.getInstance(); + // and the decision modal for decision conditions + DecisionModal.getInstance(); // If we're continuing from a checkpoint, restore objective states if (this.navigationOptions_.continueFromCheckpoint) { @@ -144,13 +198,7 @@ export abstract class BasePage extends BaseElement { // Show intro dialog if available and not continuing from checkpoint const introClip = scenario.data?.dialogClips?.intro; if (introClip && !this.navigationOptions_.continueFromCheckpoint) { - DialogManager.getInstance().show( - introClip.text, - introClip.character, - introClip.audioUrl, - 'Introduction', - introClip.emotion - ); + DialogManager.getInstance().show(introClip.text, introClip.character, introClip.audioUrl, 'Introduction', introClip.emotion); } } @@ -195,6 +243,14 @@ export abstract class BasePage extends BaseElement { isScenarioTimeout: false, }); }); + + eventBus.on(Events.PROTECTED_FREQ_VIOLATION, (data: ProtectedFreqViolationData) => { + ObjectiveFailedModal.getInstance().showFailure({ + title: 'Mission Failed', + message: `FRATRICIDE: your jam waveform at ${(data.jamFrequencyHz / 1e6).toFixed(1)} MHz overlaps the protected ${data.protectedBandLabel}. Deconflict the jam frequency against friendly SATCOM before keying the transmitter - own-force interference is a mission-ending error.`, + isScenarioTimeout: false, + }); + }); } /** @@ -207,7 +263,7 @@ export abstract class BasePage extends BaseElement { try { const scenario = ScenarioManager.getInstance(); - const checkpoint = await this.progressSaveManager_.loadCheckpoint(scenario.data.id) as { + const checkpoint = (await this.progressSaveManager_.loadCheckpoint(scenario.data.id)) as { state: AppState; }; @@ -221,18 +277,11 @@ export abstract class BasePage extends BaseElement { if (checkpoint?.state?.objectiveStates) { const objectivesManager = ObjectivesManager.getInstance(); - objectivesManager.restoreState( - checkpoint.state.objectiveStates, - checkpoint.state.scenarioTimeRemaining - ); + objectivesManager.restoreState(checkpoint.state.objectiveStates, checkpoint.state.scenarioTimeRemaining); Logger.info('Objective states restored from checkpoint'); // Reconstruct dialog history from completed objectives - DialogHistoryManager.getInstance().reconstructFromCompletedObjectives( - scenario.data.dialogClips, - checkpoint.state.objectiveStates, - scenario.data.objectives ?? [] - ); + DialogHistoryManager.getInstance().reconstructFromCompletedObjectives(scenario.data.dialogClips, checkpoint.state.objectiveStates, scenario.data.objectives ?? []); } } catch (error) { Logger.error('Failed to restore objective states from checkpoint:', error); @@ -277,7 +326,9 @@ export abstract class BasePage extends BaseElement { // Swallow the eventual rejection so that when the timeout wins the race // the still-pending request (which keeps retrying in the background) // does not surface as an unhandled promise rejection. - getUserDataService().getScenarioProgress(scenarioId).catch(() => null), + getUserDataService() + .getScenarioProgress(scenarioId) + .catch(() => null), new Promise((resolve) => setTimeout(() => resolve(null), COMPLETION_CHECK_TIMEOUT_MS)), ]); @@ -316,6 +367,7 @@ export abstract class BasePage extends BaseElement { quizPenalties: savedProgress.quizPenalties ?? 0, timePenalties: savedProgress.timePenalties ?? 0, hintPenalties: savedProgress.hintPenalties ?? 0, + decisionPenalties: 0, // folded into quizPenalties in the saved record totalScore: savedProgress.score ?? 0, objectiveBreakdown: [], // Not saved, show empty for replays timeRemainingSeconds: timeBonus * ScoreCalculator.TIME_BONUS_DIVISOR, @@ -338,4 +390,4 @@ export abstract class BasePage extends BaseElement { const match = path.match(/^\/campaigns\/([^/]+)/); return match?.[1] ?? 'nats'; } -} \ No newline at end of file +} diff --git a/src/pages/campaign-selection.css b/src/pages/campaign-selection.css index a4e996d7..a18f6abd 100644 --- a/src/pages/campaign-selection.css +++ b/src/pages/campaign-selection.css @@ -1,8 +1,14 @@ +/* + * Fixed height + own scrollbar, matching .scenario-selection-page. With + * min-height the whole document grew instead, which pushed the footer off the + * bottom of the viewport once there were enough campaign cards to wrap. + */ .campaign-selection-page { display: flex; flex-direction: column; align-items: center; - min-height: calc(100vh - 80px); + height: calc(100vh - 80px); + overflow-y: auto; background: linear-gradient(135deg, #1a1a1a 0%, #0f0f0f 100%); color: #fff; padding: 2rem 1rem; @@ -162,6 +168,13 @@ font-size: 32px; } +.locked-requirement { + font-size: 14px; + font-weight: normal; + text-transform: none; + letter-spacing: 0; +} + .progress-banner { position: absolute; top: 50px; @@ -330,6 +343,68 @@ color: #fff; } +/* + * Release-stage badges. Deliberately not red or blue - those are already spoken + * for by difficulty-advanced and duration, and an alpha campaign is usually + * advanced, so a red-on-red badge row would read as one blob. + */ +.badge.release-beta { + background: rgba(255, 152, 0, 0.18); + border: 1px solid #ff9800; + color: #ffb74d; +} + +/* Darker than difficulty-advanced so white text clears 4.5:1, not just 3.7:1. */ +.badge.release-alpha { + background: linear-gradient(135deg, #bf360c 0%, #8f2800 100%); + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.6); +} + +/* + * Warning strip inside a campaign card. No links in here on purpose - the whole + * card is a click target, so an anchor would fire the link and the navigation. + */ +.release-notice { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.75rem; + padding: 0.5rem 0.75rem; + border-radius: 4px; + font-size: 0.78rem; + line-height: 1.45; + color: #ddd; + text-transform: none; + letter-spacing: normal; +} + +.release-notice-icon { + flex-shrink: 0; + font-size: 0.95rem; + line-height: 1.45; +} + +.release-notice-beta { + background: linear-gradient(135deg, rgba(255, 152, 0, 0.15) 0%, rgba(255, 152, 0, 0.08) 100%); + border: 1px solid rgba(255, 152, 0, 0.4); +} + +.release-notice-beta .release-notice-icon, +.release-notice-beta strong { + color: #ffb74d; +} + +.release-notice-alpha { + background: linear-gradient(135deg, rgba(230, 81, 0, 0.2) 0%, rgba(230, 81, 0, 0.1) 100%); + border: 1px solid rgba(230, 81, 0, 0.55); +} + +.release-notice-alpha .release-notice-icon, +.release-notice-alpha strong { + color: #ff8a3d; +} + /* Card Body */ .campaign-card-body { display: flex; diff --git a/src/pages/campaign-selection.ts b/src/pages/campaign-selection.ts index 1f07e4c4..6f8e84a0 100644 --- a/src/pages/campaign-selection.ts +++ b/src/pages/campaign-selection.ts @@ -1,14 +1,15 @@ -import { CampaignManager } from "@app/campaigns/campaign-manager"; -import { CampaignData } from "@app/campaigns/campaign-types"; -import { qs } from "@app/engine/utils/query-selector"; -import { Logger } from "@app/logging/logger"; -import { Router } from "@app/router"; -import { Auth } from "@app/user-account/auth"; -import { getUserDataService } from "@app/user-account/user-data-service"; -import { getAssetUrl } from "@app/utils/asset-url"; -import { html } from "@app/engine/utils/development/formatter"; -import { BasePage } from "./base-page"; -import "./campaign-selection.css"; +import { CampaignManager } from '@app/campaigns/campaign-manager'; +import { CampaignData } from '@app/campaigns/campaign-types'; +import { getReleaseStage, renderReleaseBadge, renderReleaseCardNotice } from '@app/campaigns/release-stage'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { Logger } from '@app/logging/logger'; +import { Router } from '@app/router'; +import { Auth } from '@app/user-account/auth'; +import { getUserDataService } from '@app/user-account/user-data-service'; +import { getAssetUrl } from '@app/utils/asset-url'; +import { BasePage } from './base-page'; +import './campaign-selection.css'; /** * Campaign selection page implementation @@ -45,7 +46,7 @@ export class CampaignSelectionPage extends BasePage { this.dataLoaded_ = true; // Load user progress asynchronously and update the UI when ready - this.loadUserDataAndUpdate_().catch(error => { + this.loadUserDataAndUpdate_().catch((error) => { Logger.error('Failed to initialize user data loading:', error); }); } @@ -67,9 +68,7 @@ export class CampaignSelectionPage extends BasePage { const progressResponse = await userDataService.getAllScenariosProgress().catch(() => null); // Get completed scenario IDs from progress records - this.completedScenarioIds_ = (progressResponse?.scenarios ?? []) - .filter(s => s.completedAt) - .map(s => s.scenarioId); + this.completedScenarioIds_ = (progressResponse?.scenarios ?? []).filter((s) => s.completedAt).map((s) => s.scenarioId); // Re-render the campaign grid with progress data this.updateCampaignCards_(); @@ -103,7 +102,7 @@ export class CampaignSelectionPage extends BasePage { const completedCampaignIds = campaignManager.getCompletedCampaigns(this.completedScenarioIds_); // Re-render all campaign cards with updated progress data - campaignGrid.innerHTML = campaigns.map(campaign => this.renderCampaignCard_(campaign, completedCampaignIds)).join(''); + campaignGrid.innerHTML = campaigns.map((campaign) => this.renderCampaignCard_(campaign, completedCampaignIds)).join(''); // Re-attach event listeners for the new cards this.attachCampaignCardListeners_(); @@ -114,7 +113,7 @@ export class CampaignSelectionPage extends BasePage { */ private attachCampaignCardListeners_(): void { const campaignCards = this.dom_.querySelectorAll('.campaign-card:not(.disabled):not(.locked)'); - campaignCards.forEach(card => { + campaignCards.forEach((card) => { card.addEventListener('click', this.handleCampaignClick_.bind(this)); }); } @@ -146,7 +145,7 @@ export class CampaignSelectionPage extends BasePage { const campaignManager = CampaignManager.getInstance(); const campaigns = campaignManager.getAllCampaigns(); - return campaigns.map(campaign => this.renderCampaignCard_(campaign, [])).join(''); + return campaigns.map((campaign) => this.renderCampaignCard_(campaign, [])).join(''); } /** @@ -155,9 +154,12 @@ export class CampaignSelectionPage extends BasePage { private renderCampaignCard_(campaign: CampaignData, completedCampaignIds: string[]): string { const campaignManager = CampaignManager.getInstance(); const progress = campaignManager.getCampaignProgress(campaign.id, this.completedScenarioIds_); - const isLocked = campaignManager.isCampaignLocked(campaign, completedCampaignIds); + // Same dev bypass the scenario grid applies, so the dev-menu unlock toggle + // does not leave an unlocked scenario sitting behind a locked campaign card. + const isLocked = window.UNLOCK_ALL_SCENARIOS ? false : campaignManager.isCampaignLocked(campaign, completedCampaignIds, this.completedScenarioIds_); const isDisabledOrLocked = campaign.isDisabled || isLocked || campaign.isLocked; const isCompleted = progress.isCompleted; + const releaseStage = getReleaseStage(campaign); let statusBanner = ''; if (campaign.isDisabled) { @@ -165,11 +167,15 @@ export class CampaignSelectionPage extends BasePage {
    ${campaign.disabledText || 'Coming Soon'}
    `; } else if (isLocked || campaign.isLocked) { + const nextPrereqScenario = campaignManager.getNextPrerequisiteScenarioForCampaign(campaign, this.completedScenarioIds_); statusBanner = `
    ${campaign.lockedText || '🔒 Locked'}
    +
    + ${nextPrereqScenario ? `${nextPrereqScenario.title} must be completed first to unlock this campaign.` : ''} +
    `; } @@ -198,6 +204,7 @@ export class CampaignSelectionPage extends BasePage {
    + ${renderReleaseBadge(releaseStage)} ${campaign.totalDuration} ${campaign.difficulty}
    @@ -212,12 +219,13 @@ export class CampaignSelectionPage extends BasePage {
    + ${renderReleaseCardNotice(releaseStage)}

    ${campaign.description}

    Scenarios
    -
    ${campaign.scenarios.filter(s => s.missionType !== 'Sandbox').length}
    +
    ${campaign.scenarios.filter((s) => s.missionType !== 'Sandbox').length}
    Type
    @@ -243,7 +251,7 @@ export class CampaignSelectionPage extends BasePage { // Only refresh on subsequent shows (after initial load) // to reflect any completion updates from playing scenarios if (this.hasShown_) { - this.loadUserDataAndUpdate_().catch(error => { + this.loadUserDataAndUpdate_().catch((error) => { Logger.error('Failed to refresh campaign data:', error); }); } diff --git a/src/pages/layout/body/body.ts b/src/pages/layout/body/body.ts index 19ac7144..b38a04b9 100644 --- a/src/pages/layout/body/body.ts +++ b/src/pages/layout/body/body.ts @@ -1,5 +1,5 @@ -import { BaseElement } from "@app/components/base-element"; -import { html } from "@app/engine/utils/development/formatter"; +import { BaseElement } from '@app/components/base-element'; +import { html } from '@app/engine/utils/development/formatter'; import './body.css'; /** @@ -17,7 +17,7 @@ export class Body extends BaseElement { public static create(rootElementId: string): Body { if (Body.instance_) { - throw new Error("Body instance already exists."); + throw new Error('Body instance already exists.'); } Body.instance_ = new Body(rootElementId); @@ -27,7 +27,7 @@ export class Body extends BaseElement { public static getInstance(): Body { if (!Body.instance_) { - throw new Error("Body instance does not exist."); + throw new Error('Body instance does not exist.'); } return Body.instance_; @@ -43,4 +43,4 @@ export class Body extends BaseElement { protected addEventListeners_(): void { // No event listeners for now } -} \ No newline at end of file +} diff --git a/src/pages/layout/footer/footer.ts b/src/pages/layout/footer/footer.ts index 63bff394..c761dd9f 100644 --- a/src/pages/layout/footer/footer.ts +++ b/src/pages/layout/footer/footer.ts @@ -1,6 +1,6 @@ -import { BaseElement } from "@app/components/base-element"; -import { qs } from "@app/engine/utils/query-selector"; -import { html } from "@app/engine/utils/development/formatter"; +import { BaseElement } from '@app/components/base-element'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; import './footer.css'; /** @@ -17,7 +17,7 @@ export class Footer extends BaseElement { static create(rootElementId?: string): Footer { if (Footer.instance_) { - throw new Error("Footer instance already exists."); + throw new Error('Footer instance already exists.'); } Footer.instance_ = new Footer(rootElementId); @@ -27,7 +27,7 @@ export class Footer extends BaseElement { static getInstance(): Footer { if (!Footer.instance_) { - throw new Error("Footer instance does not exist."); + throw new Error('Footer instance does not exist.'); } return Footer.instance_; @@ -65,4 +65,4 @@ export class Footer extends BaseElement { protected addEventListeners_(): void { // No event listeners for now } -} \ No newline at end of file +} diff --git a/src/pages/layout/header/header.css b/src/pages/layout/header/header.css index 5343bfcf..68f6b818 100644 --- a/src/pages/layout/header/header.css +++ b/src/pages/layout/header/header.css @@ -71,7 +71,7 @@ .header-logo-section img:hover { transform: scale(1.08) rotate(-2deg); - box-shadow: 0 4px 16px rgba(186, 22, 12, 0.15); + box-shadow: 0 4px 16px rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.15); } .header-title-section { @@ -118,7 +118,7 @@ .header-icon-button:hover { /* subtle hover using a low-opacity primary color */ - background-color: rgba(186, 22, 12, 0.06); + background-color: rgba(var(--mc-accent-red-rgb, 186, 22, 12), 0.06); } .header-icon-button .icon { diff --git a/src/pages/layout/header/header.ts b/src/pages/layout/header/header.ts index 0bcfd335..648bd6ad 100644 --- a/src/pages/layout/header/header.ts +++ b/src/pages/layout/header/header.ts @@ -1,19 +1,19 @@ -import { BaseElement } from "@app/components/base-element"; -import { DevMenuBox } from "@app/dev-menu/dev-menu-box"; -import { DevMenuService } from "@app/dev-menu/dev-menu-service"; -import { qs } from "@app/engine/utils/query-selector"; -import { EngineeringModeService } from "@app/engineering-mode/engineering-mode-service"; -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; -import { Router } from "@app/router"; -import { ScenarioManager } from "@app/scenario-manager"; -import { Auth } from "@app/user-account/auth"; -import { ModalLogin } from "@app/user-account/modal-login"; -import { ModalProfile } from "@app/user-account/modal-profile"; -import { isSupabaseApprovedDomain } from "@app/user-account/supabase-client"; +import { BaseElement } from '@app/components/base-element'; +import { DevMenuBox } from '@app/dev-menu/dev-menu-box'; +import { DevMenuService } from '@app/dev-menu/dev-menu-service'; +import { qs } from '@app/engine/utils/query-selector'; +import { EngineeringModeService } from '@app/engineering-mode/engineering-mode-service'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { Router } from '@app/router'; +import { ScenarioManager } from '@app/scenario-manager'; +import { Auth } from '@app/user-account/auth'; +import { ModalLogin } from '@app/user-account/modal-login'; +import { ModalProfile } from '@app/user-account/modal-profile'; +import { isSupabaseApprovedDomain } from '@app/user-account/supabase-client'; import '@app/user-account/user-account.css'; -import type { User } from "@supabase/supabase-js"; -import { html } from "@app/engine/utils/development/formatter"; +import { html } from '@app/engine/utils/development/formatter'; +import type { User } from '@supabase/supabase-js'; import './header.css'; /** @@ -34,7 +34,7 @@ export class Header extends BaseElement { static create(rootElementId?: string): Header { if (Header.instance_) { - throw new Error("Header instance already exists."); + throw new Error('Header instance already exists.'); } Header.instance_ = new Header(rootElementId); @@ -44,7 +44,7 @@ export class Header extends BaseElement { static getInstance(): Header { if (!Header.instance_) { - throw new Error("Header instance does not exist."); + throw new Error('Header instance does not exist.'); } return Header.instance_; @@ -308,7 +308,7 @@ export class Header extends BaseElement { const displayName = metadata?.full_name || metadata?.name || user.email || '??'; const initials = displayName .split(' ') - .map(word => word.charAt(0).toUpperCase()) + .map((word) => word.charAt(0).toUpperCase()) .join('') .slice(0, 2); @@ -330,4 +330,4 @@ export class Header extends BaseElement { refreshEngButtonVisibility(): void { this.updateEngModeButtonVisibility_(); } -} \ No newline at end of file +} diff --git a/src/pages/mission-control/asset-tree-sidebar.ts b/src/pages/mission-control/asset-tree-sidebar.ts index 8ec2fc22..a2bc3ca9 100644 --- a/src/pages/mission-control/asset-tree-sidebar.ts +++ b/src/pages/mission-control/asset-tree-sidebar.ts @@ -1,31 +1,32 @@ -import { GroundStation } from "@app/assets/ground-station/ground-station"; -import { WorkingDocumentManager } from '@app/scenarios/working-document-manager'; -import { BaseElement } from "@app/components/base-element"; -import { html } from "@app/engine/utils/development/formatter"; -import { qs } from "@app/engine/utils/query-selector"; -import { Satellite } from "@app/equipment/satellite/satellite"; -import { EventBus } from "@app/events/event-bus"; -import { Events } from "@app/events/events"; -import { DialogHistoryBox } from "@app/modal/dialog-history-box"; -import { DraggableHtmlBox } from "@app/modal/draggable-html-box"; -import { HintManager } from "@app/modal/hint-manager"; -import { HintModal } from "@app/modal/hint-modal"; -import { PendingQuizIndicator } from "@app/modal/pending-quiz-indicator"; -import { QuizManager } from "@app/modal/quiz-manager"; -import { ObjectivesManager } from "@app/objectives"; -import { OpsLogModal } from "@app/ops-log/ops-log-modal"; -import { ScenarioManager } from "@app/scenario-manager"; -import { SimulationManager } from "@app/simulation/simulation-manager"; +import { GroundStation } from '@app/assets/ground-station/ground-station'; import activityPng from '@app/assets/icons/activity.png'; import antennaPng from '@app/assets/icons/antenna.png'; -import checklistPng from "@app/assets/icons/checklist.png"; +import checklistPng from '@app/assets/icons/checklist.png'; import dashboardPng from '@app/assets/icons/dashboard.png'; import historyPng from '@app/assets/icons/history.png'; import layoutSidebarLeftCollapsePng from '@app/assets/icons/layout-sidebar-left-collapse.png'; import layoutSidebarLeftExpandPng from '@app/assets/icons/layout-sidebar-left-expand.png'; -import satelliteOffPng from '@app/assets/icons/satellite-off.png'; import satellitePng from '@app/assets/icons/satellite.png'; +import satelliteOffPng from '@app/assets/icons/satellite-off.png'; import targetArrowPng from '@app/assets/icons/target-arrow.png'; +import { BaseElement } from '@app/components/base-element'; +import { html } from '@app/engine/utils/development/formatter'; +import { qs } from '@app/engine/utils/query-selector'; +import { Satellite } from '@app/equipment/satellite/satellite'; +import { EventBus } from '@app/events/event-bus'; +import { Events } from '@app/events/events'; +import { DialogHistoryBox } from '@app/modal/dialog-history-box'; +import { DraggableHtmlBox } from '@app/modal/draggable-html-box'; +import { HintManager } from '@app/modal/hint-manager'; +import { HintModal } from '@app/modal/hint-modal'; +import { PendingQuizIndicator } from '@app/modal/pending-quiz-indicator'; +import { QuizManager } from '@app/modal/quiz-manager'; +import { ObjectivesManager } from '@app/objectives'; +import { OpsLogModal } from '@app/ops-log/ops-log-modal'; +import { ScenarioManager } from '@app/scenario-manager'; +import { CampaignRecordPanel } from '@app/scenarios/campaign-record-panel'; +import { WorkingDocumentManager } from '@app/scenarios/working-document-manager'; +import { SimulationManager } from '@app/simulation/simulation-manager'; import './asset-tree-sidebar.css'; /** @@ -155,7 +156,7 @@ export class AssetTreeSidebar extends BaseElement { // Update UI - remove active from all items and add to the selected one const assetItems = this.dom_.querySelectorAll('.list-group-item-action:not(.placeholder-item):not(.mission-brief-icon):not(.checklist-icon):not(.dialog-icon)'); - assetItems.forEach(item => { + assetItems.forEach((item) => { const itemId = item.getAttribute('data-asset-id'); if (itemId === assetId) { item.classList.add('active'); @@ -200,6 +201,14 @@ export class AssetTreeSidebar extends BaseElement { btn.addEventListener('click', () => { WorkingDocumentManager.getInstance().open(); }); + + // The campaign record: earlier scenarios' documents, read-only. Shown + // when the scenario keeps a working document and there is history to read. + const recordBtn = qs('.campaign-record-icon', this.dom_); + if (recordBtn && WorkingDocumentManager.isEnabled() && CampaignRecordPanel.hasContent()) { + (recordBtn as HTMLElement).style.display = ''; + recordBtn.addEventListener('click', () => CampaignRecordPanel.getInstance().open()); + } } private addMissionBriefListener_(): void { @@ -282,17 +291,12 @@ export class AssetTreeSidebar extends BaseElement { } else { // First time - show confirmation with penalty warning const penaltyPoints = hintManager.getPenaltyPoints(objectiveId); - const objectiveTitle = ObjectivesManager.getInstance() - .getObjectiveStates() - .find(s => s.objective.id === objectiveId)?.objective.title ?? 'Unknown'; - - HintModal.getInstance().showConfirmation( - objectiveId, - conditionIndex, - hint, - penaltyPoints, - objectiveTitle - ); + const objectiveTitle = + ObjectivesManager.getInstance() + .getObjectiveStates() + .find((s) => s.objective.id === objectiveId)?.objective.title ?? 'Unknown'; + + HintModal.getInstance().showConfirmation(objectiveId, conditionIndex, hint, penaltyPoints, objectiveTitle); } } } @@ -355,8 +359,12 @@ export class AssetTreeSidebar extends BaseElement { const isMissionOverviewSelected = this.selectedAssetId_ === null; + // The asset-group-* classes carry no styling in the base theme. They exist + // so a chrome variant can reorder the sections with `order` instead of + // reordering them here - the DOM sequence stays put, which is what keeps + // every existing selector and E2E locator valid. const treeHtml = html` -
    + -