diff --git a/.claude/hooks/block-full-test-suite.sh b/.claude/hooks/block-full-test-suite.sh index fec798221..bfbf886f3 100644 --- a/.claude/hooks/block-full-test-suite.sh +++ b/.claude/hooks/block-full-test-suite.sh @@ -38,6 +38,8 @@ is_scoped() { if printf '%s' "$norm" | grep -Eq '(npm (run )?test|run test:[a-z:]*) +-- +[^ -]'; then return 0; fi # vitest run if printf '%s' "$norm" | grep -Eq 'vitest run +(-t|--testNamePattern|[^ -])'; then return 0; fi + # vitest run --project unit (project flag followed by a file/pattern) + if printf '%s' "$norm" | grep -Eq 'vitest( run)? +--project +[a-z0-9-]+ +(-t|--testNamePattern|[^ -])'; then return 0; fi return 1 } diff --git a/.claude/settings.json b/.claude/settings.json index 1fb68fb24..84bd24ad8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -2,12 +2,6 @@ "permissions": { "allow": [ "mcp__plugin_cds-mcp_cds-mcp__search_model", - "Bash(rm -rf d:/projects/tutorials-poc/approuter/static/*)", - "Bash(cp -r d:/projects/tutorials-poc/hugo/public/* d:/projects/tutorials-poc/approuter/static/)", - "Bash(rm -rf approuter/static/*)", - "Bash(cp -r hugo/public/* approuter/static/)", - "Bash(mkdir -p d:/projects/tutorials-poc/scripts/grammars)", - "Bash(cp \"/c/Users/I809764/.vscode/extensions/sapse.vscode-cds-9.9.0/node_modules/@sap/cds-lsp/syntaxes/cds.tmLanguage.json\" d:/projects/tutorials-poc/scripts/grammars/cds.tmLanguage.json)", "mcp__hana-cli__hana_query_simple" ], "additionalDirectories": [ diff --git a/.claude/skills/event-app-space-report/SKILL.md b/.claude/skills/event-app-space-report/SKILL.md new file mode 100644 index 000000000..ead03a7f8 --- /dev/null +++ b/.claude/skills/event-app-space-report/SKILL.md @@ -0,0 +1,92 @@ +--- +name: event-app-space-report +description: >- + Use when asked for completion statistics / a report for an SAP event (TechEd, + ASUG Tech Connect, DSAG, etc.) on the tutorials-ims / developers.sap.com + platform, given an app-space URL (…/app-space/?eventId=NNN) or a bare eventId. + Produces the four-section report: tutorial completions by Track, abandonment + + step-drop histogram, completer email-domain breakdown, and SAP-vs-external. + Triggers on "report/statistics from the event ", "app-space stats + for …", "event completion numbers". +--- + +# Event App-Space Report + +Produces a point-in-time, read-only completion report for one event, scoped by +the event's **date window** to the tutorials linked via its mission's +CompletionPaths (Tracks). Reuses the server helper `srv/lib/event-path-buckets.js` +so Track numbers match the live app-space page. + +## Input + +An app-space URL (`https://developers.sap.com/app-space/?eventId=10000003&theme=…`) +or a bare `eventId` (= `Events.legacyId`). The script parses either. + +## Steps + +1. **Parse the eventId** from the URL (or accept the bare id). +2. **Target PROD.** The report runs against PROD HANA. Confirm you are logged in + and targeted at the **prod** space first: + ```bash + cf target # org/space must be prod + cf target -s prod + ``` + The script enforces this (`assertCfTargetProd`) and refuses to run on any + other space — it is the only env-separation safeguard (binding follows the + current `cf` login; there is no prod-vs-dev named binding). +3. **Bind PROD HANA** if not already bound in this worktree: + ```bash + cds bind -2 tutorials-hana + ``` + **Do NOT use the `tutorials-hana:tutorials-hana-key` service-key** — per + `docs/developers/operations/gameboard-prod-data-troubleshooting.md` it maps to + a near-empty decoy HDI container, not the live app container. Use the plain + instance binding. +4. **Run the report** (read-only; SELECT/CQL only, no writes): + ```bash + cds bind --exec -- node scripts/event-report.mjs [--json] + ``` + `--json` additionally prints the raw event + bucket aggregates. +5. **Present** the emitted markdown to the user (the four sections). + +## Verification + +Cross-check the **Track** totals against the live app-space page or the admin +`getEventBuckets` OData call for that event when the user wants assurance. The +script reuses the same `computePathBuckets` helper, so a mismatch means a +binding/environment problem (wrong space, decoy container), not a logic +difference. + +## Report sections + +1. **Tutorial completions by track** — total + per-Track count and share + (`CompletionPaths.name`; FK-first tutorial resolution; windowed COMPLETED/ + SUPERSEDED completions). +2. **Abandoned tutorials** — a "Top abandonees by count" list, a computed + stop-pattern summary (minimum steps done before stopping; % of abandoners + who'd done 3+ steps), then per-tutorial median stop-step with "% through" + framing and a per-step drop histogram (stop-step = COMPLETED STEP records at + the user's current attempt vs `Tutorials.stepCount`). +3. **Completer email domains** — distinct completers per domain (with known + multi-domain orgs like NTT Data / EY rolled up via `ORG_DOMAIN_MAP`), + completions, completions/user; a most-engaged table + a prose callout naming + the standout high-engagement companies. +4. **SAP employees vs external** — `@sap.com` vs everyone else. + +## Notes + +- Read-only and prod-guarded. No writes, no deploy. +- `scripts/event-report.mjs` lives in the main repo, so it's runnable outside + this skill too (`cds bind --exec -- node scripts/event-report.mjs `). +- Scoping is by **date window**, never `TaskRecords.event_ID` (NULL on normal + completions). Track = `CompletionPaths.name`, not Mission title. +- **Sections 3–4 reconcile with the §1 total**: domain and SAP-vs-external + counts use the same all-windowed TUTORIAL completion set as the "Total + completions" headline, not only track-member tutorials. Anonymized/null-email + completers show as `(unknown)` in the domain table and are excluded from the + SAP-vs-external split. +- **Abandonment is an approximation, not an exact count.** Stop-step counts a + user's COMPLETED STEP records at their current `attemptNumber` with no + per-step date filter, so for returning/multi-attempt users (steps completed + before the event, or under a different attempt) the step-drop histogram and + median are estimates. Treat them as directional. diff --git a/.claude/skills/tutorial-qa-to-prod/SKILL.md b/.claude/skills/tutorial-qa-to-prod/SKILL.md index 101727bf2..4851d4aab 100644 --- a/.claude/skills/tutorial-qa-to-prod/SKILL.md +++ b/.claude/skills/tutorial-qa-to-prod/SKILL.md @@ -53,6 +53,17 @@ CONTRIB_DEF=$(gh api "repos/$CONTRIB" --jq '.default_branch') # expect: main MAIN_DEF=$(gh api "repos/$MAIN" --jq '.default_branch') # expect: master ``` +### Step 0.5 — Check for authoring aids (author notes + QA-only blocks) + +After fetching the QA source (below), scan the file for leftover authoring aids: + +```bash +NOTE_COUNT=$(grep -c '%%' "$FILE" 2>/dev/null || echo 0) +QA_ONLY_COUNT=$(grep -c '^:::qa-only' "$FILE" 2>/dev/null || echo 0) +``` + +If `NOTE_COUNT > 0` or `QA_ONLY_COUNT > 0`: **report the counts and locations** to the author and ask them to remove author notes (`%% … %%`) and resolve QA-only blocks (`:::qa-only`) before the PROD PR is opened. Authoring aids in PROD source will trigger lint warnings on the PROD build. Do not block on this (the author may have a valid reason to proceed), but make the counts visible. + ### Step 1 — Fetch the QA source (source of truth for the copy) The QA file is what the author previewed and approved — it is authoritative. diff --git a/.deploy/mta.yaml b/.deploy/mta.yaml index 1e06c8326..8448d7eaf 100644 --- a/.deploy/mta.yaml +++ b/.deploy/mta.yaml @@ -10,7 +10,7 @@ ID: tutorials-ims # Bump this on each release you deploy — it's the version shown by `cf mtas` # and in the mtar filename (tutorials-ims_.mtar). Deploy is manual: # `cd .deploy && mbt build && cf deploy mta_archives/tutorials-ims_.mtar -e ../deploy/.mtaext -f`. -version: 1.42.0 +version: 1.43.0 # Top-level parameters (overridable per-env via deploy/.mtaext). parameters: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..1d8a21a51 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,32 @@ +# Copilot instructions + +**Read [CLAUDE.md](../CLAUDE.md) first.** It's the canonical project guide (commands, architecture, gotchas). [AGENTS.md](../AGENTS.md) has the short version. This file only covers what's specific to Copilot and to Windows/PowerShell. + +## Shell (Windows / PowerShell) + +- Most maintainers run PowerShell on Windows. Commands in CLAUDE.md are written in bash. Translate them, don't paste them as-is: + - `export VAR=x` becomes `$env:VAR = 'x'`. + - `&&` only chains native commands. Use `;` before PowerShell statements. + - Use `git --no-pager`. +- Wrap noisy commands (`npm run build:all`, `mbt build`, `cf deploy`, `cf logs`, test runs) in **`scripts/quiet-run.ps1 `**. It's the PowerShell port of `scripts/quiet-run.sh`, with the same env knobs and log dir (`.quiet-logs/`). Open the full log only when the summary isn't enough. + +## Tests + +- Run only **scoped** unit tests locally: `npm test -- ` or `npx vitest run `. A shared `preToolUse` hook ([.github/hooks/block-full-test-suite.json](hooks/block-full-test-suite.json)) denies the full suite. That suite runs in CI. +- Admin UI e2e (`npm run test:e2e`) runs only after a deploy and skips itself when `SMOKE_BASE_URL` is unset. + +## Hard rules (summary, see CLAUDE.md for the details) + +- Never edit generated output: `hugo/content/tutorials/`, `gen/`, `hugo/public/`, `approuter/static/`, `mta_archives/`. +- Never run `publish-content` from a workstation. Use `gh workflow run rebuild-content.yml`. +- Default to a feature branch and a PR (`.github/pull_request_template.md`). Confirm the deploy scope with the maintainer before you deploy. +- Don't stage unrelated working-tree changes. Hugo data files (`hugo/data/*.json`) are often regenerated locally. + +## Tools + +- **CDS/CAP:** Use the CAP MCP server (`search_model`, `search_docs`) before you edit `.cds` or CAP code. +- **UI5/Fiori:** Use the UI5 MCP server (`get_api_reference`, `run_ui5_linter`) for `app/admin*/**`. +- **Browser checks:** Use the `playwright-test` MCP server. +- **SAP / BTP / CF lookups:** Use the `sap-devs` MCP server. The `sap-devs` CLI must be on PATH. +- **MCP setup:** `.mcp.json` is personal and git-ignored because it can hold PATs. Start from `Copy-Item .mcp.json.example .mcp.json`. On Windows, if `npx` won't start, use `"command": "cmd", "args": ["/c", "npx", ...]`. The CAP and UI5 servers come from their VS Code extensions. +- Repo skills live in `.claude/skills/`, for example `tutorial-qa-to-prod`, `provision-tutorial-repo` and `whats-new`. Use one when it matches the task. diff --git a/.github/hooks/block-full-test-suite.cjs b/.github/hooks/block-full-test-suite.cjs new file mode 100644 index 000000000..b68202c90 --- /dev/null +++ b/.github/hooks/block-full-test-suite.cjs @@ -0,0 +1,81 @@ +#!/usr/bin/env node +// Copilot preToolUse hook — port of .claude/hooks/block-full-test-suite.sh. +// +// BLOCK the full, in-process vitest suites locally — they peg the machine (concurrent +// worker pools) and take >10 min. The full suite runs in CI; locally, run a SCOPED test. +// +// BLOCKED (full in-process suites): +// npm test / npm run test / npm run test:all / npm run test:a11y / npm run test:watch +// npx vitest run --project unit|a11y (and bare `vitest run` with no file arg) +// ALLOWED (pass through): +// scoped runs: npm test -- / vitest run +// HTTP/remote: test:smoke, test:e2e, test:hybrid, test:smoke:personalization, etc. +// +// Contract: JSON event on stdin; prints `{}` (no decision) or a deny decision on stdout. +// Keep the regexes in sync with the bash version. +'use strict'; + +const fs = require('node:fs'); + +const SHELL_TOOLS = /^(bash|powershell|shell|run_in_terminal|runInTerminal)$/i; + +function readEvent() { + try { + return JSON.parse(fs.readFileSync(0, 'utf8') || '{}'); + } catch { + return {}; + } +} + +function extractCommand(event) { + const toolName = event.toolName ?? event.tool_name ?? ''; + if (toolName && !SHELL_TOOLS.test(toolName)) return ''; + let args = event.toolArgs ?? event.tool_input ?? {}; + if (typeof args === 'string') { + try { + args = JSON.parse(args); + } catch { + return args; + } + } + return typeof args?.command === 'string' ? args.command : ''; +} + +function isScoped(norm) { + if (/(npm (run )?test|run test:[a-z:]*) +-- +[^ -]/.test(norm)) return true; + if (/vitest run +(-t|--testNamePattern|[^ -])/.test(norm)) return true; + // `vitest run --project unit ` — project flag followed by a file/pattern. + if (/vitest( run)? +--project +[a-z0-9-]+ +(-t|--testNamePattern|[^ -])/.test(norm)) return true; + return false; +} + +function isFullSuite(norm) { + if (/(^|[;&|] *)(npm (run )?test( +--project +(unit|a11y))?|npm run test:all|npm run test:a11y|npm run test:watch)( |$)/.test(norm)) return true; + if (/vitest( run)? +--project +(unit|a11y)( |$)/.test(norm)) return true; + if (/(^|[;&|] *)(npx )?vitest( run)? *$/.test(norm)) return true; + return false; +} + +function decide(cmd) { + // Strip a leading quiet-run wrapper so `quiet-run.ps1 npm test` is judged like `npm test`. + const norm = cmd + .replace(/\s+/g, ' ') + .trim() + .replace(/(^|[;&|] *)(\.?[\\/])?(scripts[\\/])?quiet-run\.(sh|ps1) +/g, '$1'); + if (norm && isFullSuite(norm) && !isScoped(norm)) { + return { + permissionDecision: 'deny', + permissionDecisionReason: + 'BLOCKED: the full local test suite is disallowed on this project — it pegs the machine and already runs in CI. ' + + "Run a SCOPED test instead: 'npm test -- ' or 'npx vitest run '. " + + 'Allowed remote suites: test:smoke / test:e2e / test:hybrid.', + }; + } + return {}; +} + +if (require.main === module) { + process.stdout.write(JSON.stringify(decide(extractCommand(readEvent())))); +} + +module.exports = { decide, extractCommand }; diff --git a/.github/hooks/block-full-test-suite.json b/.github/hooks/block-full-test-suite.json new file mode 100644 index 000000000..039dbb276 --- /dev/null +++ b/.github/hooks/block-full-test-suite.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "command": "node .github/hooks/block-full-test-suite.cjs", + "timeoutSec": 10 + } + ] + } +} diff --git a/.github/instructions/admin-ui.instructions.md b/.github/instructions/admin-ui.instructions.md new file mode 100644 index 000000000..630dfc78b --- /dev/null +++ b/.github/instructions/admin-ui.instructions.md @@ -0,0 +1,9 @@ +--- +applyTo: "app/admin/**,app/admin-shell/**" +--- +# Admin UI (SAPUI5 / Fiori Elements) + +- Use the UI5 MCP server: `get_api_reference` for APIs, `run_ui5_linter` and `run_manifest_validation` before you finish. +- Components load as headless componentUsages inside the `sap.tnt.ToolPage` shell (`app/admin-shell/`). The theme is `sap_horizon`. +- **Deploy gate:** Admin UI changes need a full `npm run deploy -- --env `. Don't use `--skip-build` or `-m` scoping, and never bypass Step 3.5 (`scripts/check-shipped-admin-bundle.cjs`). Without a full deploy, a stale admin UI ships. +- User-facing changes should come with an e2e spec (see `docs/developers/reference/e2e-coverage-pattern.md`). diff --git a/.github/instructions/cds.instructions.md b/.github/instructions/cds.instructions.md new file mode 100644 index 000000000..d8a62c726 --- /dev/null +++ b/.github/instructions/cds.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "db/**/*.cds,srv/**/*.cds,app/*.cds,db-qa/**" +--- +# CDS model + +- Before you edit, look up entities and annotations with the CAP MCP server: `search_model` for the compiled model, `search_docs` for CAP docs. +- Don't select a HANA BLOB in the same CDS QL query as its metadata. LOB locators expire. Use raw `db.run()`, following the pattern in `srv/lib/content-store.js`. +- Slugs on `TutorialMeta`, `Tutorials`, `Missions` and `Groups` are unique and lowercase. New write paths must upsert on slug. +- Don't bypass `@requires` or `@restrict`. Never edit `gen/`; regenerate it with `cds build`. +- Keep `using` side-effect imports, for example the TechEd one in `db/external-content.cds`. diff --git a/.github/instructions/hugo.instructions.md b/.github/instructions/hugo.instructions.md new file mode 100644 index 000000000..d6d2615b8 --- /dev/null +++ b/.github/instructions/hugo.instructions.md @@ -0,0 +1,11 @@ +--- +applyTo: "hugo/**,hugo-apps/**,scripts/parsers/**,scripts/fetch-tutorials.ts" +--- +# Hugo, islands and parsers + +- `hugo/content/tutorials/` is entirely generated, so never edit it. Change `scripts/parsers/` instead, or the source tutorial repos in the `sap-tutorials` org. +- Tutorial HTML is served from HANA BLOBs, not from `hugo/public/`. Publish through `gh workflow run rebuild-content.yml`, never from a workstation. +- Deploys refuse a dirty tree under `hugo/assets/**` or `hugo/layouts/**` (Step 0.5). Commit or stash those files before deploying. +- `ignore-scripts=true` means `pre*` and `post*` hooks never run. The island manifest and page fallback are explicit `build:all` steps. +- Compare tutorial slugs with `.toLowerCase()`. +- Served tutorials render `
` and `

`, not `
`. Keep that in mind when writing e2e selectors. diff --git a/.github/instructions/srv-lib.instructions.md b/.github/instructions/srv-lib.instructions.md new file mode 100644 index 000000000..85111872a --- /dev/null +++ b/.github/instructions/srv-lib.instructions.md @@ -0,0 +1,9 @@ +--- +applyTo: "srv/lib/**" +--- +# srv/lib + +- **srv-qa cp-list audit:** If you change anything here, re-walk the transitive `./` imports from `srv/lib/content-store.js`. Confirm that every dependency is in the `srv-qa` `cp` list in `.deploy/mta.yaml`. A missing dependency crashes QA at boot. +- `srv/lib/completion-rollup.js` is not a dependency of `content-store.js`. Group and mission completions are derived from rollups (see CLAUDE.md, "Completions rollup"). +- Feature flags (`KG_*`, provenance, NGDS) fail open and default to OFF. Keep it that way. +- Run scoped tests for the module you touched: `npm test -- `. diff --git a/.github/workflows/rebuild-content.yml b/.github/workflows/rebuild-content.yml index b89f60590..3e19eed07 100644 --- a/.github/workflows/rebuild-content.yml +++ b/.github/workflows/rebuild-content.yml @@ -383,6 +383,90 @@ jobs: prod) echo "srv_url=${{ secrets.CAP_SRV_URL_PROD }}" >> "$GITHUB_OUTPUT" ;; esac + # Expand _snippets/ file changes to the affected tutorial slugs so a + # single-snippet edit triggers a slug-targeted (~2 min) rebuild instead + # of a full-corpus (~10 min) one. Runs only on repository_dispatch (the + # real author hotfix trigger); workflow_dispatch never carries changed_files. + # + # The manifest is keyed by snippet path → [slug, …] and is published by + # fetch-tutorials under the reserved key `_manifest_snippet-deps`. The + # deployed srv exposes it at GET /content/manifest/snippet-deps. Fail-open: + # any error (missing SRV_URL, network, empty manifest) skips expansion and + # the upstream mode step's effective value is used unchanged. + - name: Expand snippet dependencies to affected slugs + id: snippet-deps + if: github.event_name == 'repository_dispatch' + env: + CHANGED_FILES: ${{ github.event.client_payload.changed_files }} + SRV_URL: ${{ steps.srv.outputs.srv_url }} + run: | + # Only act when _snippets/ files changed + if [ -z "$CHANGED_FILES" ] || ! echo "$CHANGED_FILES" | grep -qE '_snippets/[^/]+\.md$'; then + echo "No snippet file changes detected — skipping dep expansion" + exit 0 + fi + + SNIPPET_FILES=$(echo "$CHANGED_FILES" | grep -E '_snippets/[^/]+\.md$' || true) + echo "Snippet changes detected in: $SNIPPET_FILES" + + # Fetch snippet-deps manifest from deployed srv + if [ -z "$SRV_URL" ]; then + echo "::warning title=snippet-deps::SRV_URL not set — falling back to upstream mode" + exit 0 + fi + + DEPS_JSON=$(curl -sf --max-time 10 "${SRV_URL}/content/manifest/snippet-deps" || echo "") + if [ -z "$DEPS_JSON" ] || [ "$DEPS_JSON" = "null" ] || [ "$DEPS_JSON" = "{}" ]; then + echo "::warning title=snippet-deps::Manifest unavailable or empty — falling back to upstream mode" + exit 0 + fi + + # Map changed snippet paths to affected tutorial slugs using jq. + # The manifest keys are snippet paths (e.g. sap-tutorials/repo/_snippets/foo.md); + # we match by suffix so any path form resolves correctly. + AFFECTED=$(echo "$DEPS_JSON" | jq -r --arg files "$CHANGED_FILES" ' + ($files | gsub("[\\t\\r\\n ]+";" ") | split(" ") | map(select(length > 0))) as $changed | + [to_entries[] | select(.key as $k | $changed[] | test($k | @sh | ltrimstr("'"'"'") | rtrimstr("'"'"'"); "g")) | .value[]] | + unique | join(",") + ' 2>/dev/null || echo "") + + # Validate: only accept slug-clean output (a-z0-9,-) + case "$AFFECTED" in + *[!a-z0-9,-]* | *,,* | ,* | *, ) AFFECTED="" ;; + esac + + if [ -n "$AFFECTED" ]; then + echo "snippet_affected_slugs=$AFFECTED" >> "$GITHUB_OUTPUT" + echo "::notice title=Snippet dep expansion::Snippet changes affect tutorials: $AFFECTED" + else + echo "::notice title=Snippet dep expansion::No tutorial deps found for changed snippets (or jq parse error)" + fi + + # Apply snippet dep expansion to the effective rebuild mode. When the + # snippet-deps step found affected slugs, this step either unions them + # with an existing slug-targeted set or promotes a full/catalog-only run + # to slug-targeted. Downstream steps prefer these outputs when set. + - name: Override mode from snippet deps + id: mode-override + if: steps.snippet-deps.outputs.snippet_affected_slugs != '' + env: + SNIPPET_SLUGS: ${{ steps.snippet-deps.outputs.snippet_affected_slugs }} + CURRENT_EFFECTIVE: ${{ steps.mode.outputs.effective_mode }} + CURRENT_DISPATCH_SLUGS: ${{ steps.mode.outputs.dispatch_slugs || '' }} + run: | + if [ "$CURRENT_EFFECTIVE" = 'slug-targeted' ] && [ -n "$CURRENT_DISPATCH_SLUGS" ]; then + # Union existing dispatch slugs with snippet-affected slugs (deduplicate via sort -u) + MERGED=$(echo "${CURRENT_DISPATCH_SLUGS},${SNIPPET_SLUGS}" | tr ',' '\n' | sort -u | tr '\n' ',' | sed 's/,$//') + echo "effective_mode=slug-targeted" >> "$GITHUB_OUTPUT" + echo "dispatch_slugs=${MERGED}" >> "$GITHUB_OUTPUT" + echo "::notice title=mode-override::Merged snippet slugs with dispatch slugs: $MERGED" + else + # Promote to slug-targeted + echo "effective_mode=slug-targeted" >> "$GITHUB_OUTPUT" + echo "dispatch_slugs=${SNIPPET_SLUGS}" >> "$GITHUB_OUTPUT" + echo "::notice title=mode-override::Promoted to slug-targeted via snippet deps: $SNIPPET_SLUGS" + fi + # Cache the *materialized* node_modules (root + hugo-apps), not just the # npm download cache. `setup-node` `cache: npm` already warms ~/.npm, but # `npm ci` still spends ~48s extracting/linking 1680 packages + running @@ -512,7 +596,7 @@ jobs: CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} CONTENT_API_KEY: ${{ secrets.CONTENT_API_KEY }} TUTORIAL_SLUG: ${{ inputs.slug || steps.mode.outputs.dispatch_slug }} - TUTORIAL_SLUGS: ${{ inputs.slugs || steps.mode.outputs.dispatch_slugs }} + TUTORIAL_SLUGS: ${{ inputs.slugs || steps.mode-override.outputs.dispatch_slugs || steps.mode.outputs.dispatch_slugs }} # [retired-slug-noop] Lets fetch-tutorials distinguish a human manual # dispatch (typo → fail-fast) from an auto-dispatched admin-write / # scheduled run (a retired-slug filter is "nothing to publish", exit 0). @@ -1008,7 +1092,7 @@ jobs: --concurrency "${{ inputs.publish-concurrency || '4' }}" \ --batch-size "${{ inputs.publish-batch-size || '25' }}" \ ${{ inputs.force-publish == true && '--force' || '' }} \ - ${{ (inputs.allow-revert == true && steps.mode.outputs.effective_mode == 'slug-targeted') && '--allow-revert' || '' }} \ + ${{ (inputs.allow-revert == true && (steps.mode-override.outputs.effective_mode || steps.mode.outputs.effective_mode) == 'slug-targeted') && '--allow-revert' || '' }} \ --verbose env: CONTENT_API_KEY: ${{ secrets.CONTENT_API_KEY }} @@ -1019,14 +1103,15 @@ jobs: # so the slug value never lands in the shell string. Empty in every # other mode → whole-catalog delta, unchanged. TUTORIAL_SLUG is the # already-resolved slug (inputs.slug || dispatch_slug). - PUBLISH_SLUG: ${{ steps.mode.outputs.effective_mode == 'slug-targeted' && (inputs.slug || steps.mode.outputs.dispatch_slug) || '' }} + PUBLISH_SLUG: ${{ (steps.mode-override.outputs.effective_mode || steps.mode.outputs.effective_mode) == 'slug-targeted' && (inputs.slug || steps.mode.outputs.dispatch_slug) || '' }} # Multi-slug variant of PUBLISH_SLUG. In slug-targeted mode, scope the # publish to exactly the changed slugs (publish-content.ts reads # PUBLISH_SLUGS as the --slugs fallback and keeps those N files via # filterToSlugs) instead of a whole-catalog delta. Empty in every # other mode. TUTORIAL_SLUGS is the already-resolved list - # (inputs.slugs || dispatch_slugs). - PUBLISH_SLUGS: ${{ steps.mode.outputs.effective_mode == 'slug-targeted' && (inputs.slugs || steps.mode.outputs.dispatch_slugs) || '' }} + # (inputs.slugs || dispatch_slugs). Prefers mode-override slugs (snippet + # dep expansion) over the original mode dispatch_slugs when set. + PUBLISH_SLUGS: ${{ (steps.mode-override.outputs.effective_mode || steps.mode.outputs.effective_mode) == 'slug-targeted' && (inputs.slugs || steps.mode-override.outputs.dispatch_slugs || steps.mode.outputs.dispatch_slugs) || '' }} # #1373 — synthetic watchdog. A green publish step is not proof rows # landed (the #1372 render-concepts 404 aborted AFTER an earlier @@ -1106,14 +1191,14 @@ jobs: # needs its own key. - name: Refresh tutorial images (slug-targeted) - if: ${{ steps.publish.outcome == 'success' && steps.mode.outputs.effective_mode == 'slug-targeted' }} + if: ${{ steps.publish.outcome == 'success' && (steps.mode-override.outputs.effective_mode || steps.mode.outputs.effective_mode) == 'slug-targeted' }} continue-on-error: true env: CAP_BASE_URL: ${{ steps.srv.outputs.srv_url }} CONTENT_API_KEY: ${{ steps.env.outputs.target == 'qa' && secrets.CONTENT_API_KEY_QA || secrets.CONTENT_API_KEY }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token || secrets.TUTORIALS_GITHUB_TOKEN }} REFRESH_SLUG: ${{ inputs.slug || steps.mode.outputs.dispatch_slug }} - REFRESH_SLUGS: ${{ inputs.slugs || steps.mode.outputs.dispatch_slugs }} + REFRESH_SLUGS: ${{ inputs.slugs || steps.mode-override.outputs.dispatch_slugs || steps.mode.outputs.dispatch_slugs }} run: npx tsx scripts/backfill-images.ts --slug "$REFRESH_SLUG" --slugs "$REFRESH_SLUGS" - name: Refresh tutorial images (full catalog) diff --git a/.gitignore b/.gitignore index 65b08b277..bbe5e43c2 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,10 @@ hugo/static-qa/ !hugo/static/images/devtoberfest/avatars/*.png !hugo/static/images/devtoberfest/arcade/**/*.png !hugo/static/images/devtoberfest/selfie/thumbnails/*.png +!hugo/static/images/app-space-game/**/*.png +# Raw Kasimir sprite sheets are build inputs for scripts/build-kasimir-atlas.mjs — hugo/static is +# served publicly, so keep the multi-MB source art out of the repo and the deployed site. +hugo/static/images/app-space-game/sprite-src/ !hugo/static/images/devtoberfest/selfie/stickers/*.png !hugo/static/images/devtoberfest/selfie/backgrounds/*.png !app/display-app-drawings/public/*.png diff --git a/.mcp.json.example b/.mcp.json.example new file mode 100644 index 000000000..6ac79b96e --- /dev/null +++ b/.mcp.json.example @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "playwright-test": { + "command": "npx", + "args": ["playwright", "run-test-mcp-server"] + }, + "sap-devs-server": { + "type": "stdio", + "command": "sap-devs", + "args": ["mcp", "serve"] + } + } +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..b15958d7f --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,12 @@ +{ + "recommendations": [ + "GitHub.copilot-chat", + "SAPSE.vscode-cds", + "SAPSE.sap-ux-fiori-tools-extension-pack", + "Vue.volar", + "vitest.explorer", + "ms-playwright.playwright", + "budparr.language-hugo-vscode", + "redhat.vscode-yaml" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..fd0c55717 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,27 @@ +{ + "files.watcherExclude": { + "**/node_modules/**": true, + "**/gen/**": true, + "**/mta_archives/**": true, + "**/hugo/public/**": true, + "**/hugo/resources/**": true, + "**/hugo/content/tutorials/**": true, + "**/approuter/static/*/**": true, + "**/.tutorial-cache/**": true, + "**/.tutorial-cache-qa/**": true, + "**/.quiet-logs/**": true, + "**/.worktrees/**": true, + "**/.hana-snapshots/**": true, + "**/.migration-data/**": true, + "**/.playwright-mcp/**": true + }, + "search.exclude": { + "hugo/content/tutorials/**": true, + "approuter/static/*/**": true + }, + "files.associations": { + "*.cds": "cds", + "hugo.qa.toml": "toml" + }, + "chat.useAgentsMdFile": true +} diff --git a/AGENTS.md b/AGENTS.md index 97badc0d7..d8ac43171 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,8 @@ This is the AGENTS.md for the SAP Developers Tutorials platform codebase. AI cod ## Quick conventions - Hugo content under `hugo/content/tutorials/` is generated — never hand-edit. Modify parsers in `scripts/parsers/` or upstream tutorials in the `sap-tutorials` GitHub org. - Tutorial HTML is served from HANA BLOBs, not static files. After Hugo build, run `npm run publish-content`. -- Run `npm test` (unit, in-memory SQLite) before committing. `npm run test:hybrid` requires `cf login`. +- Run only **scoped** unit tests locally (`npm test -- ` or `npx vitest run `); the full suite pegs the machine and runs in CI (local agent hooks block it). `npm run test:hybrid` requires `cf login`. +- Wrap noisy commands (build, deploy, tests, `cf logs`) in `scripts/quiet-run.sh` (bash) or `scripts/quiet-run.ps1` (PowerShell) to keep scrollback out of agent context. - Use `cds-mcp` to look up CDS definitions and CAP API docs before editing CDS or CAP code. ## Out of scope for codebase agents diff --git a/CLAUDE.md b/CLAUDE.md index 5524b4750..49dfaa6ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ npm run bind:setup # First-time hybrid env binding setup npm run setup-dev-data # Populate slugs + clean autotest data (needs cds bind) # Tests -npm test # Unit (in-memory SQLite, fast) +npm test # Unit (in-memory SQLite). Agents: scoped only — `npm test -- `; full suite is hook-blocked locally, runs in CI npm run test:hybrid # Hybrid (real HANA via cds bind --exec; requires cf login) npm run test:smoke # Smoke (HTTP against deployed; set SMOKE_BASE_URL/SMOKE_SRV_URL) npm run test:e2e # Admin-UI Playwright smoke (post-deploy; set SMOKE_BASE_URL + SMOKE_TECH_USER/PASSWORD; self-skips when absent) @@ -124,7 +124,7 @@ Subsystem one-liners: These are load-bearing habits for keeping AI-agent token usage down without reducing output. Ignoring them wastes tokens re-sending output the agent never uses. -- **Never paste raw deploy/test/log scrollback into the main context.** Any command whose output can exceed ~50 lines (`mbt build`, `cf deploy`, `npm run build:all`, `npm test`, `cf logs`) MUST be wrapped in `scripts/quiet-run.sh ` — it keeps the full log on disk under `.quiet-logs/` and prints only exit code + matched error lines + last N lines. Drill into the full log path only when the summary is insufficient. +- **Never paste raw deploy/test/log scrollback into the main context.** Any command whose output can exceed ~50 lines (`mbt build`, `cf deploy`, `npm run build:all`, `npm test`, `cf logs`) MUST be wrapped in `scripts/quiet-run.sh ` (bash) or `scripts/quiet-run.ps1 ` (PowerShell, same contract) — it keeps the full log on disk under `.quiet-logs/` and prints only exit code + matched error lines + last N lines. Drill into the full log path only when the summary is insufficient. - **Delegate noisy investigation to a subagent.** Grep sweeps, "which files import X", log trawls, broad multi-file reads — dispatch to `Explore`/`general-purpose` so the raw output stays out of the main context and only the findings return. - **Don't read a whole file for a few lines.** Prefer cds-mcp / hana-cli / `Grep` with line ranges over full-file `Read`; read only the slice you need. - **Short sessions.** One deploy or one debugging thread per session; `/clear` between them so stale build output doesn't ride on every subsequent request. @@ -159,4 +159,4 @@ The load-bearing few. **Full detail for every relocated item → [tutorials-ims- - **External channels subsystem** — `Channels` entity (`db/channels.cds`) is the source of truth; re-ingest via `npm run seed-channels`; directory at `/channels` (baked by `fetch-channels` in `build:all`); verb-lane fill via `npm run promote-channels`; community items never land in `START_HERE`. → [channels.md](docs/developers/reference/channels.md). - **TechEd sessions subsystem (#2312)** — new `TechEd*` entities (`db/external/teched.cds`, NOT reused `CommunityEvents`), loaded via a **`using` side-effect** in `db/external-content.cds` (don't delete the import); RainFocus ingest (`POST https://events.rainfocus.com/api/sessions`, headers `rfapiprofileid`/`rfwidgetid` per venue from `RAINFOCUS_{TE26,TEV26}_PROFILE`/`_WIDGET` env — live-confirmed defaults baked in, IDs may rotate; speakers embedded in `participants[]`, tracks in `attributevalues[attribute=="Track"]`, offset-paginated); idempotent seed via `scripts/seed-teched.cjs`; public `/build/teched` feed; corrected fetcher + delta cron jobs shipped in #2332; `/teched/` page + island, KG projection, embeddings, and Devtoberfest cross-linking are later units. → [teched.md](docs/developers/reference/teched.md). - **Signed provenance envelope (#2245)** — `PROVENANCE_ENVELOPE_ENABLED` (DB config, default OFF, DEV-first); two anonymous Express endpoints (`/content/tutorials/:slug/provenance` and `/.well-known/tutorial-provenance/jwks.json`); signing key = `PROVENANCE_SIGNING_KEY` credstore secret (Ed25519 PKCS8 PEM); rotate via `/admin-ui/#secrets`. Fail-open. → gotchas.md "Signed provenance envelope". -- **Optimized draft deletion `draft_admin_integrity` ON (#2909, CAP 10.2)** — `cds.fiori.draft_admin_integrity: true` in package.json; draft discard cascade-deletes draft `CompletionPathItems`/`GroupPathItems` at the DB level, so rollup reconcile now fires via `after('DISCARD','Missions.drafts'/'Groups.drafts')` (the `.drafts` target is required), NOT the per-row child `after('DELETE')` hooks (those stay for direct programmatic deletes). Deleting an active entity right after `draftActivate` can 403 ("a draft exists") until the residual draft is discarded. Constraint migration deploys to HANA (`.hdbconstraint`, `NOT VALIDATED`) BEFORE the flag serves. → gotchas.md "Optimized draft deletion". Re-enable runbook: docs/developers/operations/draft-integrity-reenable.md. +- **Optimized draft deletion `draft_admin_integrity` is OFF (#2909 / #2993, CAP 10.2)** — `cds.fiori.draft_admin_integrity: false` in package.json (all profiles). It was briefly enabled but shipped to PROD WITHOUT the required HANA `ON DELETE CASCADE` constraints, orphaning draft rows and making missions un-editable (`DRAFT_ALREADY_EXISTS` / `Not Found`, no discard affordance) — the 2026-11-02 PROD outage. Disabled for all profiles; draft deletion uses the pre-10.2 per-row `after('DELETE')` child hooks + `after('DISCARD')` root reconcile. The real draft shadow tables are `ADMINSERVICE_*_DRAFTS` (NOT `com_sap_developers_ims_*_drafts`). Orphan sweep: `scripts/cleanup-draft-orphans.cjs --commit` (deletes draft rows whose `DraftAdministrativeData_DraftUUID` has no admin-data parent; preserves live drafts). Re-enabling the `dad` strategy is blocked on an unresolved HDI make-graph problem for the cascade `.hdbconstraint` (inline FK → error 8250014; standalone constraint → 8212001 on delta deploy) — tracked in #2993; the flag must stay OFF until that deploys cleanly to a real HANA container. → gotchas.md "Optimized draft deletion". Re-enable runbook: docs/developers/operations/draft-integrity-reenable.md. diff --git a/REUSE.toml b/REUSE.toml index 7f553a8a3..40648d946 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -9,3 +9,18 @@ path = "**" precedence = "aggregate" SPDX-FileCopyrightText = "2026 SAP SE or an SAP affiliate company and tutorials-ims contributors" SPDX-License-Identifier = "Apache-2.0" + +[[annotations]] +path = "hugo/static/js/vendor/asciinema-player.min.js" +SPDX-FileCopyrightText = "asciinema contributors" +SPDX-License-Identifier = "Apache-2.0" +# NOTE: This file must be placed before deploying tutorials that use ::cast directives. +# Download from https://github.com/asciinema/asciinema-player/releases + +[[annotations]] +path = "hugo/static/js/vendor/asciinema-player.css" +SPDX-FileCopyrightText = "asciinema contributors" +SPDX-License-Identifier = "Apache-2.0" +# NOTE: This file must be placed before deploying tutorials that use ::cast directives. +# Download from https://github.com/asciinema/asciinema-player/releases + diff --git a/db/last-dev/csn.json b/db/last-dev/csn.json index 3ee337640..86882a78a 100644 --- a/db/last-dev/csn.json +++ b/db/last-dev/csn.json @@ -809,6 +809,11 @@ "type": "cds.Integer", "@cds.persistence.name": "STEPCOUNT" }, + "optionalSteps": { + "type": "cds.String", + "length": 1000, + "@cds.persistence.name": "OPTIONALSTEPS" + }, "redirectTo_ID": { "type": "cds.String", "length": 36, diff --git a/db/schema.cds b/db/schema.cds index 05cd9aa3a..5491c7c97 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -52,6 +52,12 @@ entity Tutorials : TaskBase, LinkHealth { // that step-progress doesn't depend on whichever Step rows happen to exist // in the DB at the moment a user clicks "mark complete" (issue #89). stepCount : Integer; + // [tutorial-step-pacing] Comma-joined step numbers marked `{optional}` in + // the source heading (e.g. "2,5"), set by publish-content alongside + // stepCount. Null/empty when the tutorial has no optional steps. Read by + // the progress-completion calc in srv/developer-service.js so completion + // only requires the remaining (non-optional) steps. + optionalSteps : String(1000); redirectTo : Association to Tutorials; // External redirect target (#2690). Mutually exclusive with redirectTo. // Only an https:// URL on the curated SAP host allowlist is accepted diff --git a/db/src/com.sap.developers.ims.Tutorials.hdbmigrationtable b/db/src/com.sap.developers.ims.Tutorials.hdbmigrationtable index 53cf76944..585c21587 100644 --- a/db/src/com.sap.developers.ims.Tutorials.hdbmigrationtable +++ b/db/src/com.sap.developers.ims.Tutorials.hdbmigrationtable @@ -1,4 +1,4 @@ -== version=6 +== version=7 COLUMN TABLE com_sap_developers_ims_Tutorials ( ID NVARCHAR(36) NOT NULL, createdAt TIMESTAMP, @@ -21,12 +21,17 @@ COLUMN TABLE com_sap_developers_ims_Tutorials ( mdFileUrl NVARCHAR(1000), featuredOrder INTEGER, stepCount INTEGER, + optionalSteps NVARCHAR(1000), redirectTo_ID NVARCHAR(36), redirectUrl NVARCHAR(1000), author_ID NVARCHAR(36), PRIMARY KEY(ID) ) +== migration=7 +-- generated by cds-compiler version 7.0.1 +ALTER TABLE com_sap_developers_ims_Tutorials ADD (optionalSteps NVARCHAR(1000)); + == migration=6 -- generated by cds-compiler version 7.0.1 ALTER TABLE com_sap_developers_ims_Tutorials ADD (linkStatus NVARCHAR(8) DEFAULT 'UNKNOWN', lastChecked TIMESTAMP, linkStatusOverride NVARCHAR(8)); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 97f06e4b1..a3b1e7d36 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -113,6 +113,12 @@ export default defineConfig({ { text: 'Analytics admin', link: '/authors/analytics-admin' }, { text: 'Joule commands', link: '/authors/joule-commands' } ]}, + { text: 'Authoring reference', items: [ + { text: 'Authoring aids', link: '/authors/authoring-aids' }, + { text: 'Content reuse and references', link: '/authors/content-reuse-and-references' }, + { text: 'Rich media and tables', link: '/authors/rich-media-and-tables' }, + { text: 'UI instruction directives', link: '/authors/ui-instruction-directives' } + ]}, { text: 'Branching paths', items: [ { text: 'Branched missions', link: '/authors/branched-missions' }, { text: 'Branched tutorials', link: '/authors/branched-tutorials' }, @@ -179,6 +185,7 @@ export default defineConfig({ { text: 'Deploy checklist', link: '/developers/operations/deploy-checklist' }, { text: 'Deployment', link: '/developers/operations/deployment' }, { text: 'Devtoberfest selfie art', link: '/developers/operations/devtoberfest-selfie-assets' }, + { text: 'Draft integrity re-enable', link: '/developers/operations/draft-integrity-reenable' }, { text: 'Gameboard PROD data troubleshooting', link: '/developers/operations/gameboard-prod-data-troubleshooting' }, { text: 'GitHub App setup', link: '/developers/operations/github-app-setup' }, { text: 'GitHub dispatch PAT rotation', link: '/developers/operations/github-dispatch-pat-rotation' }, diff --git a/docs/authors/authoring-aids.md b/docs/authors/authoring-aids.md new file mode 100644 index 000000000..b8328b032 --- /dev/null +++ b/docs/authors/authoring-aids.md @@ -0,0 +1,146 @@ +# Authoring Aids + +Three lightweight tools for annotating tutorial source during development and review cycles. +They are design to be non-destructive: the annotations are stripped (or rendered as callouts) at +publish time, so readers never see them and they cannot change page structure. + +--- + +## 1. Author Notes (`%% … %%`) + +A way to embed comments, reviewer flags, and working notes directly in tutorial markdown without +affecting the published output. + +### Syntax + +**Inline note** — opens and closes on the same line: + +```markdown +Follow the steps below %% @reviewer: confirm this still works with SDK v4 %% to deploy your app. +``` + +**Block note** — a line containing only `%%` opens the block; the next bare `%%` closes it. + +```markdown +%% +@author: The following screenshot is from the OLD tenant. Needs refresh once #123 is resolved. +%% +``` + +The leading `@name:` attribution prefix is optional. Without it the note body stands on its own. + +### QA vs PROD behavior + +| Channel | What happens | +|---------|--------------| +| QA | Notes render as styled inline `` or block `
` callouts with the author name visible. Unterminated notes throw a parse error so you see the problem immediately. | +| PROD | Notes are stripped entirely before the HTML is built. An unterminated note logs a warning and the content from the opening `%%` to end-of-step is discarded rather than leaking. | + +Author notes are **never visible to end readers** regardless of channel. + +### When to use + +Use author notes to: +- Flag a step that needs a screenshot refresh (`@author: screenshot outdated`) +- Leave a message for a co-author or reviewer (`@reviewer: should this mention X?`) +- Document a pending decision inline so it travels with the content +- Note a dependency on an issue or PR number + +Do **not** use author notes to: +- Write content that readers should see — use a regular callout or note directive instead +- Leave notes permanently — treat them as temporary annotations, remove them before the final PROD promotion +- Mark entire steps as draft — a whole step as a block note will produce a blank step on QA + +--- + +## 2. QA-only blocks (`:::qa-only[…]`) + +A container directive that renders content exclusively on the QA author-preview channel and is +removed entirely before the tutorial reaches production readers. + +### Syntax + +```markdown +:::qa-only[Reason this content is QA-only] +Content that appears only on QA. + +You can include **bold**, lists, and inline code here. + + But not step headings (### Step N) — those alter step numbering on PROD when stripped. +::: +``` + +The label (inside `[…]`) is required and should briefly explain why the block is QA-only. It is +shown as a callout header on QA so reviewers understand the context. + +### QA vs PROD behavior + +| Channel | What happens | +|---------|--------------| +| QA | Renders as a styled `tut-qa-only` callout with the reason label. All body content is visible and fully rendered. | +| PROD | Stripped entirely — the block and its contents produce no HTML output. | + +**Restriction:** step headings (`### Step N — Title`) inside a `:::qa-only` block are rejected at +parse time with an error. Because the block is stripped on PROD, a heading inside it would silently +renumber subsequent steps, causing step-count mismatches between QA and PROD. If you need a +QA-only step, restructure the tutorial so the step exists conditionally at a higher level. + +### When to use + +Use `:::qa-only` for: +- Reviewer instructions that must not appear in the published tutorial +- Placeholder content for a section not yet ready for PROD +- Author-facing checklists or sign-off criteria embedded in the document +- Temporary context about why a tutorial is in a certain state + +Do **not** use `:::qa-only` for: +- Content that readers need on PROD — use a regular callout directive instead +- Hiding steps that should just be removed — delete the step instead +- Large structural sections; prefer targeted notes for small annotations + +--- + +## 3. CLI Freshness Lint + +An automated rule in `lint-tutorial-markdown` that scans bash/sh/shell/console fences for +deprecated CLI commands and flags. + +The rule checks each shell code block against a versioned catalog in +`scripts/lint-rules/data/cli-deprecations.json`. When a command matches a deprecated pattern, it +emits a `warning` finding with the deprecated flag, the version it was removed in, the recommended +replacement, and a link to the relevant documentation. + +### What it catches + +| Pattern | Removed in | Replacement | +|---------|-----------|-------------| +| `cf push … --hostname ` | cf CLI v8 | `routes:` block in `manifest.yml` | +| `cf push … --no-hostname` | cf CLI v8 | `no-route: true` in `manifest.yml` | +| `cds serve --in-memory` | `@sap/cds` 7 | `cds watch` | +| `npm install --save` | npm 5 | `npm install ` (flag is now the default) | + +Shell fences with these language identifiers are scanned: `bash`, `sh`, `shell`, `console`, +`terminal`, `powershell`, `cmd`. Prompt prefixes (`$ `, `PS> `, `> `, `# `) are stripped before +matching so findings are not missed. + +### How to respond to a finding + +A finding looks like: + +``` +[deprecated-cli] tutorial-deploy-cf.md:42 + The `--hostname` flag was removed in cf CLI v8. Use `routes:` in the application manifest instead. + Replacement: Add `routes:\n - route: .` to manifest.yml + Docs: https://docs.cloudfoundry.org/devguide/deploy-apps/manifest-attributes.html#routes + Excerpt: cf push myapp --hostname myhost +``` + +To resolve: +1. Update the command in the tutorial to use the recommended replacement. +2. Re-run `npx tsx scripts/lint-tutorial-markdown.ts ` to confirm the finding clears. +3. If the deprecated form is intentional (e.g., demonstrating migration from an old version), use + a non-shell fence language (`text` or `diff`) so the rule does not scan it, and add an inline + author note explaining the exception. + +The lint runs automatically as part of `npm run build:all`. Pass `--strict` to make findings +fail the build with a non-zero exit code. diff --git a/docs/authors/content-reuse-and-references.md b/docs/authors/content-reuse-and-references.md new file mode 100644 index 000000000..99fd5aa9a --- /dev/null +++ b/docs/authors/content-reuse-and-references.md @@ -0,0 +1,118 @@ +# Content Reuse and References + +This guide explains how to use the four content-reuse features in tutorial markdown. + +## ::include — Inline shared snippets + +Snippets are reusable markdown fragments stored in a repo's `_snippets/` directory or in the shared `tutorial-snippets` repo. Use `::include` to inline them at build time. + +**Syntax:** +``` +::include[snippet-name] +::include[snippet-name]{param1=value1 param2=value2} +::include[shared:install-cf-cli] +``` + +**Rules:** +- Snippets live at `/_snippets/.md` (or `shared/` for the shared repo prefix `shared:`) +- Snippets must NOT contain step headings (`### Step N`) — this would corrupt step numbering +- Maximum include depth is 3 (A includes B includes C, but C cannot include further) +- Circular includes are rejected with `directive/include-cycle` + +**Parameters:** +Declare `{{param:name}}` placeholders in the snippet. Pass values as `{name=value}` in the directive: +``` +::include[btp-trial-setup]{region=eu10} +``` + +**To enable for your repo:** Contact the platform team to add your repo to `SNIPPETS_ENABLED_REPOS`. + +--- + +## Smart reference links + +Inline links to SAP resources use `:directive[label]` syntax (colon prefix, no `::`). + +### :tutorial — Cross-reference another tutorial + +``` +:tutorial[hana-cloud-tutorial-1] +:tutorial[hana-cloud-tutorial-1]{label=Get Started with HANA} +``` + +Links to `/tutorials/`. In the rendered page, shows completion status if the user is authenticated. + +### :note — SAP Note link + +``` +:note[2471802] +``` + +Renders as "SAP Note 2471802" linking to `https://me.sap.com/notes/2471802`. The label must be digits only. + +### :help — SAP Help Portal link + +``` +:help[cap/docs/about-cap] +``` + +Links to `https://help.sap.com/docs/`. Title is resolved at build time when available (falls back to the path). + +### :api — API documentation link + +``` +:api[cds.ql.SELECT]{lib=cap-node} +``` + +Supported `lib` values: `cap-node`, `cap-java`, `ui5`, `cloud-sdk-js`, `cloud-sdk-java`. + +### :since — Version badge + +``` +:since[CAP 10] +``` + +Renders a version tag badge. Use for "Added in version X" callouts. + +--- + +## testedWith — Declare tested tool versions + +Add a `testedWith` map to your frontmatter to declare the tool/library versions your tutorial was written and tested with: + +```yaml +testedWith: + "@sap/cds": "10.x" + node: "22" + "SAP BTP CLI": "2.x" +``` + +This renders a collapsible "Tested with" panel at the top of your tutorial. The freshness detector also uses this to flag tutorials whose declared versions are behind the current known major. + +**Constraints:** +- Maximum 10 entries +- Keys and values must be non-empty strings +- Use version strings like `"10.x"`, `"22"`, `"2.6.1"` (any format with a leading digit) + +--- + +## ::requires — Prerequisite cards + +Show a prerequisite card linking to another tutorial: + +``` +::requires[hana-cloud-tutorial-1] +::requires[optional-tutorial-slug]{optional} +``` + +The card displays the tutorial title, estimated time, and (for authenticated users) completion status. Mark optional prerequisites with `{optional}`. + +**Alternative:** List prerequisites in frontmatter using the `requires:` key: + +```yaml +requires: + - hana-cloud-tutorial-1 + - hana-cloud-tutorial-2 +``` + +Note: the `requires:` frontmatter list is stored in the tutorial record. The `::requires` directive adds visual cards in the tutorial body. diff --git a/docs/authors/rich-media-and-tables.md b/docs/authors/rich-media-and-tables.md new file mode 100644 index 000000000..caea7999d --- /dev/null +++ b/docs/authors/rich-media-and-tables.md @@ -0,0 +1,203 @@ +# Rich Media and Tables + +Tutorial authors can use these directives and attributes to embed rich visual content and interactive tables. All features are additive — existing tutorial markdown continues to work without any changes. + +--- + +## Overview — when to use each feature + +| Feature | When to use | +|---------|-------------| +| [Figures](#figures) | Grouping related images together under a single auto-numbered caption | +| [Inline images](#inline-images) | Sized icons, badges, or small images inside a sentence | +| [Before/after compare](#beforeafter-compare) | Showing a UI change, a refactoring, or a before/after state side by side | +| [Hotspot images](#hotspot-images) | Annotating a screenshot with numbered callouts tied to a description list | +| [Terminal recordings](#terminal-recordings) | Demonstrating a CLI workflow with selectable, copyable terminal text | +| [Diagrams](#diagrams) | Embedding draw.io or Excalidraw SVG exports, with zoom and dark-mode support | +| [Interactive tables](#interactive-tables) | Large reference tables (service plans, parameters) that benefit from sorting or filtering | + +--- + +## Figures + +Use `:::figure[Caption]` to group one or more images under an auto-numbered, linkable caption ("Figure 1", "Figure 2", …). When a figure contains more than one image the images are displayed in a grid (up to three columns by default). + +**Syntax:** + +```markdown +:::figure[The SAP BTP Cockpit overview]{width=80% align=center} +![BTP Cockpit main screen](cockpit-main.png) +![BTP Cockpit subaccount detail](cockpit-subaccount.png) +::: +``` + +**Attributes:** + +| Attribute | Default | Notes | +|-----------|---------|-------| +| `width` | `100%` | CSS width applied to the figure block | +| `align` | `left` | `left`, `center`, or `right` | +| `columns` | image count (max 3) | Force a specific grid column count | +| `id` | _(auto)_ | Override the anchor; default is `figure-N` | + +**Captions** accept inline Markdown (bold, links, inline code). Figure numbers are assigned in source order across the whole tutorial. + +Each image inside a figure still opens in the lightbox, and multiple images are grouped as a gallery. + +--- + +## Inline Images + +Standard Markdown inline image syntax (`![alt](src)`) does not support sizing attributes in most parsers, including Goldmark. Use the `:image[alt]{src=…}` inline directive to embed a sized inline image — for icons, badges, or small UI elements that appear mid-sentence. + +**Syntax:** + +```markdown +Click :image[settings icon]{src=gear.png width=16} to open the settings panel. +``` + +**Attributes:** + +| Attribute | Notes | +|-----------|-------| +| `src` | Required. Relative path (resolved the same way as block images). | +| `width` | CSS width in px (unitless) or any CSS length (`16`, `2rem`, `24px`). | +| `height` | CSS height; omit to preserve aspect ratio. | + +Relative `src` values are resolved to raw GitHub URLs by the same rules as block images — no extra configuration needed. + +--- + +## Before/After Compare + +Use `::compare{before=… after=…}` to render a drag-handle slider that reveals one image on the left and another on the right. For the best result, both images should have the same intrinsic dimensions. + +**Syntax:** + +```markdown +::compare{before=before-refactor.png after=after-refactor.png labels="Before refactoring|After refactoring"} +``` + +**Attributes:** + +| Attribute | Notes | +|-----------|-------| +| `before` | Required. Path to the "before" image. | +| `after` | Required. Path to the "after" image. | +| `labels` | Optional. Pipe-separated label pair, e.g. `"Before|After"`. Defaults to none. | + +**Accessibility:** the slider is keyboard-accessible (arrow keys move the divider). Without JavaScript, both images are shown side by side with their labels. + +--- + +## Hotspot Images + +Add a `{hotspots="N:x%,y% …"}` attribute to a standalone block image to overlay numbered circular markers at percentage-based positions. Each marker is linked to the corresponding item in the **immediately following ordered list** — hovering or focusing either the marker or the list item highlights the other. + +**Syntax:** + +```markdown +![System architecture diagram](architecture.png){hotspots="1:15%,30% 2:65%,45% 3:45%,75%"} + +1. The **Application Router** handles authentication and routes requests. +2. The **CAP service** provides OData endpoints for the frontend. +3. The **SAP HANA Cloud** database persists business data. +``` + +**Hotspot coordinate format:** `N:x%,y%` where `N` is the marker number (1-based), `x%` is the horizontal position from the left, and `y%` is the vertical position from the top. + +**Important constraints:** + +- The ordered list **must immediately follow** the image — no blank lines, no other content between them. +- If the number of hotspot markers and the number of list items do not match, markers render but cross-highlighting is skipped. A lint rule for this mismatch is planned for a future release. +- When you replace a screenshot, update the `hotspots` coordinates — they are percentages tied to the image composition. + +**Accessibility fallback:** the image `alt` text is automatically appended with "(numbered callouts 1–N)". The ordered list stands on its own and is always readable without JavaScript. + +--- + +## Terminal Recordings + +Use `::cast[file.cast]{…}` to embed an interactive asciinema terminal recording. Text is **selectable and copyable** by the reader. The player loads lazily (only pages that include a `::cast` directive load the player bundle, and even then only when the recording is visible or interacted with). + +**Syntax:** + +```markdown +::cast[deploy-demo.cast]{rows=24 cols=80 speed=1.2 poster="0:03"} +``` + +**Attributes:** + +| Attribute | Default | Notes | +|-----------|---------|-------| +| `rows` | `24` | Player height in terminal rows | +| `cols` | `80` | Player width in terminal columns | +| `speed` | `1.0` | Playback speed multiplier (`1.5` = 50% faster) | +| `poster` | `"0:0"` | Time (mm:ss) of the frame shown before play | +| `idle-limit` | _(none)_ | Collapse gaps longer than this many seconds | + +**Recording tools:** use `asciinema rec` or `agg` to produce `.cast` files (asciicast v2 format). Keep files small — very large recordings will slow page load. **Recommended maximum: 2 MB.** + +**Accessibility fallback:** a static placeholder is shown before the player loads. For screen-reader accessibility, describe the recording's content in the surrounding text. + +--- + +## Diagrams + +Use `::diagram[file.svg]{alt="…"}` to embed an SVG diagram. The diagram opens in the lightbox with zoom/pan support. Dark-mode adaptation is applied automatically (CSS invert + hue-rotate) for diagrams that have a white background, which covers most draw.io and Excalidraw exports. + +**Syntax:** + +```markdown +::diagram[cap-architecture.svg]{alt="CAP application architecture showing the three-tier structure" caption="CAP three-tier architecture"} +``` + +**Attributes:** + +| Attribute | Required | Notes | +|-----------|----------|-------| +| `alt` | **Yes** | Accessible description; the build **fails** if `alt` is missing | +| `caption` | No | Visible caption below the diagram | +| `src-dark` | No | Path to a dark-mode variant (preferred over the auto-invert filter for colourful diagrams) | +| `theme` | `auto` | `auto` = apply CSS invert for white-background SVGs; `none` = no dark-mode adaptation | + +**Recommended source formats:** draw.io SVG (export as SVG from draw.io), Excalidraw SVG. Plain SVG is also accepted. + +**SVG security:** SVGs are served through the image proxy and rendered via `` (scripts are never executed in ``-embedded SVGs). Do not attempt to embed SVGs with ` + + + + + diff --git a/hugo-apps/src/app-space-game/__tests__/AppSpaceGame.test.ts b/hugo-apps/src/app-space-game/__tests__/AppSpaceGame.test.ts new file mode 100644 index 000000000..02824c6f4 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/AppSpaceGame.test.ts @@ -0,0 +1,474 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +// -- Shared mock data -------------------------------------------------------- + +const MOCK_DATA = { + eventId: 1, + eventName: 'Test', + type: 'DEVTOBERFEST', + appSpaceGameEnabled: true, + paths: [ + { + id: 'track-alpha', + title: 'Alpha Track', + description: 'desc', + items: [], + segments: [ + { + order: 0, + kind: 'linear' as const, + items: [ + { + imsId: 1, + title: 'Tutorial 1', + type: 'TUTORIAL' as const, + status: 'OPEN', + progress: 0, + experience: '', + timeToComplete: 5, + url: '/tutorials/test', + description: 'desc', + }, + ], + }, + ], + }, + ], +} + +// -- Game handle factory ----------------------------------------------------- + +function makeGameHandle() { + return { + start: vi.fn(), + stop: vi.fn(), + setPlayerControl: vi.fn(), + dispose: vi.fn(), + onRingCount: vi.fn(), + onEvent: vi.fn(), + onZone: vi.fn(), + onGoalPrompt: vi.fn(), + activateGoal: vi.fn(() => true), + } +} + +// -- Module mocks (hoisted so vi.mock applies before imports) ---------------- + +vi.mock('../feature', () => ({ + isWebGLAvailable: vi.fn(), + loadAppSpaceData: vi.fn(), + isGameDisabled: vi.fn(), +})) + +vi.mock('../renderer', () => ({ + createGame: vi.fn(), +})) + +// -- Helpers ----------------------------------------------------------------- + +async function mountWithGatePass() { + const { isWebGLAvailable, loadAppSpaceData, isGameDisabled } = await import('../feature') + const { createGame } = await import('../renderer') + const gameHandle = makeGameHandle() + + vi.mocked(isWebGLAvailable).mockReturnValue(true) + vi.mocked(loadAppSpaceData).mockResolvedValue(MOCK_DATA) + vi.mocked(isGameDisabled).mockReturnValue(false) + vi.mocked(createGame).mockReturnValue(gameHandle) + + const AppSpaceGame = (await import('../AppSpaceGame.vue')).default + const wrapper = mount(AppSpaceGame) + // Flush all promises to complete the async onMounted gate check + await flushPromises() + + return { wrapper, gameHandle, createGame: vi.mocked(createGame) } +} + +async function mountWithGateFail() { + const { isWebGLAvailable, loadAppSpaceData, isGameDisabled } = await import('../feature') + const { createGame } = await import('../renderer') + + vi.mocked(isWebGLAvailable).mockReturnValue(false) + vi.mocked(loadAppSpaceData).mockResolvedValue(null) + vi.mocked(isGameDisabled).mockReturnValue(false) + vi.mocked(createGame).mockReset() + + const AppSpaceGame = (await import('../AppSpaceGame.vue')).default + const wrapper = mount(AppSpaceGame) + await flushPromises() + + return { wrapper, createGame: vi.mocked(createGame) } +} + +async function mountWithFlagOff() { + const { isWebGLAvailable, loadAppSpaceData, isGameDisabled } = await import('../feature') + const { createGame } = await import('../renderer') + + vi.mocked(isWebGLAvailable).mockReturnValue(true) + vi.mocked(loadAppSpaceData).mockResolvedValue({ ...MOCK_DATA, appSpaceGameEnabled: false }) + vi.mocked(isGameDisabled).mockReturnValue(false) + vi.mocked(createGame).mockReset() + + const AppSpaceGame = (await import('../AppSpaceGame.vue')).default + const wrapper = mount(AppSpaceGame) + await flushPromises() + + return { wrapper, createGame: vi.mocked(createGame) } +} + +// -- Test suites ------------------------------------------------------------- + +describe('AppSpaceGame attract state machine', () => { + beforeEach(() => { + // Fake only macrotask timers (setTimeout/clearTimeout) so the idle SM is + // controllable. Exclude setImmediate so that flushPromises() (which + // internally uses setImmediate) continues to drain microtasks normally. + vi.useFakeTimers({ + toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'], + }) + vi.resetAllMocks() + // Default matchMedia: no reduced motion preference + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + // (a) Idle timer fires after 60000ms -> overlay visible + game.start() called + it('(a) shows overlay and calls game.start() after idle timeout', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + + // Verify gate passed (Demo button present) + expect(wrapper.find('[data-demo-trigger]').exists()).toBe(true) + + // Before timeout: overlay should not be visible + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + + // Advance timers past the idle timeout + await vi.advanceTimersByTimeAsync(60000) + + // Overlay should now be visible + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + // game.start() should have been called + expect(gameHandle.start).toHaveBeenCalled() + }) + + // (b) A deliberate key press while attract is showing hands control; mouse movement alone does not + it('(b) key press while attract is showing hands control but keeps overlay; mousemove does not', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + + // Trigger idle timeout to show attract mode + await vi.advanceTimersByTimeAsync(60000) + + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + + // A passer-by brushing the mouse must not stop the demo + window.dispatchEvent(new MouseEvent('mousemove')) + await flushPromises() + expect(gameHandle.setPlayerControl).not.toHaveBeenCalled() + expect(wrapper.find('.asg-attract-cta').exists()).toBe(true) + + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + await flushPromises() + + expect(gameHandle.setPlayerControl).toHaveBeenCalledWith(true) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + expect(wrapper.find('[data-player-hint]').exists()).toBe(true) + }) + + it('(b2) tap (pointerdown) also hands control to the player', async () => { + const { gameHandle } = await mountWithGatePass() + await vi.advanceTimersByTimeAsync(60000) + window.dispatchEvent(new Event('pointerdown')) + await flushPromises() + expect(gameHandle.setPlayerControl).toHaveBeenCalledWith(true) + }) + + it('(b3) a visitor who walks away hands control back to the autopilot after 30s', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + await vi.advanceTimersByTimeAsync(60000) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' })) + await flushPromises() + expect(gameHandle.setPlayerControl).toHaveBeenLastCalledWith(true) + + await vi.advanceTimersByTimeAsync(20000) + window.dispatchEvent(new KeyboardEvent('keydown', { key: ' ' })) + await vi.advanceTimersByTimeAsync(20000) + expect(gameHandle.setPlayerControl).toHaveBeenLastCalledWith(true) + + await vi.advanceTimersByTimeAsync(11000) + await flushPromises() + expect(gameHandle.setPlayerControl).toHaveBeenLastCalledWith(false) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + expect(wrapper.find('.asg-attract-cta').exists()).toBe(true) + }) + + it('(k) goal prompt appears when the renderer reports a goal and activates it on click', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + await vi.advanceTimersByTimeAsync(60000) + const promptCb = gameHandle.onGoalPrompt.mock.calls[0]?.[0] as (g: unknown) => void + expect(typeof promptCb).toBe('function') + + promptCb({ trackId: 'track-alpha', title: 'Alpha Track', label: 'Complete Track', kind: 'track', locked: false }) + await flushPromises() + const btn = wrapper.find('button[data-goal-prompt]') + expect(btn.exists()).toBe(true) + expect(btn.text()).toContain('Alpha Track') + await btn.trigger('click') + expect(gameHandle.activateGoal).toHaveBeenCalled() + + promptCb({ trackId: 'track-beta', title: 'Beta Track', label: 'x', kind: 'track', locked: true }) + await flushPromises() + expect(wrapper.find('button[data-goal-prompt]').exists()).toBe(false) + expect(wrapper.find('[data-goal-prompt]').text()).toContain('locked') + }) + + it('(l) zone banner shows the current zone', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + await vi.advanceTimersByTimeAsync(60000) + const zoneCb = gameHandle.onZone.mock.calls[0]?.[0] as (z: unknown) => void + zoneCb({ index: 1, total: 4, title: 'Beta Track', color: '#123456', locked: false }) + await flushPromises() + const banner = wrapper.find('.asg-zone') + expect(banner.text()).toContain('Zone 2 / 4') + expect(banner.text()).toContain('Beta Track') + }) + + it('(m) renderer receives the label root and reduced-motion option', async () => { + const { createGame } = await mountWithGatePass() + const opts = createGame.mock.calls[0][2] as { labelRoot: HTMLElement | null; reducedMotion: boolean } + expect(opts.labelRoot).toBeInstanceOf(HTMLElement) + expect(opts.reducedMotion).toBe(false) + }) + + // (c) "Demo" button is present; clicking it starts the game immediately + it('(c) Demo button is present and clicking it starts the game immediately', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + + const demoBtn = wrapper.find('[data-demo-trigger]') + expect(demoBtn.exists()).toBe(true) + + // Click Demo button - should start attract mode without waiting for timer + await demoBtn.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + expect(gameHandle.start).toHaveBeenCalled() + }) + + it('(c2) using the Demo button does not leave a stale idle timer that restarts the demo', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + window.dispatchEvent(new Event('pointerdown')) + await wrapper.find('[data-demo-trigger]').trigger('click') + await flushPromises() + expect(gameHandle.start).toHaveBeenCalledTimes(1) + await vi.advanceTimersByTimeAsync(61000) + await flushPromises() + expect(gameHandle.start).toHaveBeenCalledTimes(1) + }) + + // (d) onPickTrack navigates via replaceState (NOT pushState) + dispatches popstate + hides overlay + it('(d) onPickTrack uses replaceState, dispatches popstate, and hides overlay', async () => { + const { wrapper, createGame } = await mountWithGatePass() + + // Spy on history methods + const replaceStateSpy = vi.spyOn(window.history, 'replaceState') + const pushStateSpy = vi.spyOn(window.history, 'pushState') + const dispatchEventSpy = vi.spyOn(window, 'dispatchEvent') + + // Trigger attract to get overlay visible first + await vi.advanceTimersByTimeAsync(60000) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + + // Extract onPickTrack from the createGame call args + expect(createGame).toHaveBeenCalled() + const callArgs = createGame.mock.calls[0] + const opts = callArgs[2] as { onPickTrack: (id: string) => void } + const onPickTrack = opts.onPickTrack + + // Invoke onPickTrack with a track id + onPickTrack('track-alpha') + await flushPromises() + + // replaceState should have been called (NOT pushState) + expect(replaceStateSpy).toHaveBeenCalled() + expect(pushStateSpy).not.toHaveBeenCalled() + + // The URL passed to replaceState should contain track=track-alpha + const replaceCall = replaceStateSpy.mock.calls[0] + const urlArg = replaceCall[2] as string + expect(urlArg).toContain('track=track-alpha') + + // popstate event should have been dispatched + const popstateDispatched = dispatchEventSpy.mock.calls.some( + ([evt]) => evt instanceof PopStateEvent && evt.type === 'popstate' + ) + expect(popstateDispatched).toBe(true) + + // Overlay should be hidden after handoff + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + }) + + // (e) Gate fails -> no overlay, no Demo button, no timer, createGame never called + it('(e) gate failure: no overlay, no Demo button, createGame never called', async () => { + const { wrapper, createGame } = await mountWithGateFail() + + // No overlay + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + // No demo button + expect(wrapper.find('[data-demo-trigger]').exists()).toBe(false) + // createGame never called + expect(createGame).not.toHaveBeenCalled() + + // Advance timers - nothing should happen + await vi.advanceTimersByTimeAsync(120000) + await flushPromises() + + // Still no overlay + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + expect(createGame).not.toHaveBeenCalled() + }) + + // (f) Server flag OFF (appSpaceGameEnabled: false) -> gate does not pass + it('(f) server flag OFF: no overlay, no Demo button, createGame never called', async () => { + const { wrapper, createGame } = await mountWithFlagOff() + + // No overlay + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + // No demo button + expect(wrapper.find('[data-demo-trigger]').exists()).toBe(false) + // createGame never called + expect(createGame).not.toHaveBeenCalled() + + // Advance timers - nothing should happen + await vi.advanceTimersByTimeAsync(120000) + await flushPromises() + + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + expect(createGame).not.toHaveBeenCalled() + }) + + // (g) Close button is visible when overlay is showing; clicking it hides overlay + it('(g) close button hides overlay without picking a track', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + + // Trigger attract mode + await vi.advanceTimersByTimeAsync(60000) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + + const closeBtn = wrapper.find('[data-game-close]') + expect(closeBtn.exists()).toBe(true) + + await closeBtn.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + expect(gameHandle.stop).toHaveBeenCalled() + }) + + // (h) Escape key closes the overlay + it('(h) Escape key closes the game overlay', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + + // Trigger attract mode + await vi.advanceTimersByTimeAsync(60000) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + + // Dispatch Escape keydown on window + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await flushPromises() + + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + expect(gameHandle.stop).toHaveBeenCalled() + }) + + // (i) onRingCount callback updates the ring-count HUD + it('(i) ring HUD shows count when onRingCount fires', async () => { + const { wrapper, gameHandle } = await mountWithGatePass() + + // Trigger attract mode so the overlay (and HUD) is visible + await vi.advanceTimersByTimeAsync(60000) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + + // Capture the onRingCount callback registered on the game handle + // It is set up during onMounted after createGame is called + const onRingCountCb = gameHandle.onRingCount.mock.calls[0]?.[0] as ((n: number) => void) | undefined + expect(typeof onRingCountCb).toBe('function') + + // Fire the callback with a count of 3 + onRingCountCb!(3) + await flushPromises() + + // The HUD div should be in the DOM showing the count + const hud = wrapper.find('.asg-rings') + expect(hud.exists()).toBe(true) + expect(hud.text()).toContain('3') + }) + + // (j) Mute toggle control is present in the mounted DOM + it('(j) mute toggle control is present in the mounted DOM', async () => { + const { wrapper } = await mountWithGatePass() + + // Show the overlay so the mute toggle is visible + await vi.advanceTimersByTimeAsync(60000) + + const muteBtn = wrapper.find('[data-mute-toggle]') + expect(muteBtn.exists()).toBe(true) + }) + + // prefers-reduced-motion: idle auto-start disabled, Demo button still works + it('prefers-reduced-motion: idle timer does NOT auto-start, Demo button still works', async () => { + // Override matchMedia to report reduced motion preference + Object.defineProperty(window, 'matchMedia', { + writable: true, + configurable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: query === '(prefers-reduced-motion: reduce)', + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }) + + const { wrapper, gameHandle } = await mountWithGatePass() + + // Gate should pass - Demo button present + expect(wrapper.find('[data-demo-trigger]').exists()).toBe(true) + + // Advance past idle timeout + await vi.advanceTimersByTimeAsync(60000) + + // Overlay must NOT have appeared (reduced motion blocks idle auto-start) + expect(wrapper.find('[data-game-overlay]').exists()).toBe(false) + expect(gameHandle.start).not.toHaveBeenCalled() + + // Demo button should still work + const demoBtn = wrapper.find('[data-demo-trigger]') + await demoBtn.trigger('click') + await flushPromises() + + expect(wrapper.find('[data-game-overlay]').exists()).toBe(true) + expect(gameHandle.start).toHaveBeenCalled() + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/anim.test.ts b/hugo-apps/src/app-space-game/__tests__/anim.test.ts new file mode 100644 index 000000000..70ecb5d4a --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/anim.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from 'vitest' +import { selectClip, frameAt, jumpFrame, playbackRate } from '../anim' +import type { AnimContext } from '../anim' +import { JUMP, SPEED } from '../constants' + +const ctx = (p: Partial = {}): AnimContext => ({ + onGround: true, vx: 0, vy: 0, lastEvent: 'none', sinceEventMs: 10_000, pose: null, ...p, +}) + +describe('selectClip', () => { + it('idle → walk → trot → run as speed rises', () => { + expect(selectClip(ctx({ vx: 0 }))).toBe('idle') + expect(selectClip(ctx({ vx: SPEED * 0.3 }))).toBe('walk') + expect(selectClip(ctx({ vx: -SPEED * 0.6 }))).toBe('trot') + expect(selectClip(ctx({ vx: SPEED }))).toBe('run') + }) + + it('airborne → jump', () => { + expect(selectClip(ctx({ onGround: false, vy: 3, vx: SPEED }))).toBe('jump') + }) + + it('holds the landing crouch briefly after touching down', () => { + expect(selectClip(ctx({ vx: SPEED, lastEvent: 'land', sinceEventMs: 40 }))).toBe('jump') + expect(selectClip(ctx({ vx: SPEED, lastEvent: 'land', sinceEventMs: 200 }))).toBe('run') + }) + + it('fell → fall pose; hit → dizzy on ground, flail in the air', () => { + expect(selectClip(ctx({ lastEvent: 'fell', onGround: false }))).toBe('fall') + expect(selectClip(ctx({ lastEvent: 'hit', sinceEventMs: 100, onGround: true }))).toBe('hit') + expect(selectClip(ctx({ lastEvent: 'hit', sinceEventMs: 100, onGround: false }))).toBe('fall') + expect(selectClip(ctx({ lastEvent: 'hit', sinceEventMs: 900, vx: SPEED }))).toBe('run') + }) + + it('a scripted pose overrides everything', () => { + expect(selectClip(ctx({ pose: 'reach', lastEvent: 'hit', sinceEventMs: 0 }))).toBe('reach') + expect(selectClip(ctx({ pose: 'celebrate', onGround: false }))).toBe('celebrate') + }) +}) + +describe('jumpFrame', () => { + it('follows the arc: rise → apex → descend, crouch on the ground', () => { + const f = (vy: number, extra: Partial = {}) => jumpFrame(ctx({ onGround: false, vy, ...extra }), 10) + expect(f(JUMP)).toBe(2) + expect(f(0)).toBe(5) + expect(f(-JUMP)).toBe(7) + expect(f(JUMP, { lastEvent: 'jump', sinceEventMs: 10 })).toBe(1) + expect(jumpFrame(ctx({ onGround: true }), 10)).toBe(8) + const seq = [JUMP, JUMP * 0.5, JUMP * 0.2, 0, -JUMP * 0.4, -JUMP].map(v => f(v)) + expect([...seq].sort((a, b) => a - b)).toEqual(seq) + }) + + it('never exceeds the clip length', () => { + expect(jumpFrame(ctx({ onGround: true }), 3)).toBe(2) + }) +}) + +describe('frameAt', () => { + const clips = { + idle: { frames: [1, 2, 3, 4], fps: 4, loop: true }, + taunt: { frames: [1, 2, 3], fps: 10, loop: false }, + } + it('loops looping clips', () => { + expect(frameAt('idle', 0, clips)).toBe(0) + expect(frameAt('idle', 1250, clips)).toBe(1) + }) + it('clamps one-shot clips on the last frame', () => { + expect(frameAt('taunt', 5000, clips)).toBe(2) + }) + it('returns 0 for unknown clips', () => { + expect(frameAt('run', 500, clips)).toBe(0) + }) +}) + +describe('playbackRate', () => { + it('scales locomotion with speed, clamped', () => { + expect(playbackRate('run', SPEED)).toBeCloseTo(1) + expect(playbackRate('run', 0.1)).toBe(0.6) + expect(playbackRate('idle', 0)).toBe(1) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/autopilot.test.ts b/hugo-apps/src/app-space-game/__tests__/autopilot.test.ts new file mode 100644 index 000000000..1ab34a78c --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/autopilot.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { autopilotInput, watchdog } from '../autopilot' +import type { ProgressTrack } from '../autopilot' +import { initialState } from '../physics' +import type { WorldHazards } from '../layout' + +const world = (h: Partial = {}): WorldHazards => ({ + crates: [], spikes: [], pits: [], minX: 0, maxX: 200, ...h, +}) + +describe('autopilotInput', () => { + it('runs right without jumping on clear ground', () => { + expect(autopilotInput({ ...initialState(5), vx: 7 }, world())).toEqual({ moveDir: 1, jump: false }) + }) + + it('jumps when a pit edge is within launch distance', () => { + const r = autopilotInput({ ...initialState(5), vx: 7 }, world({ pits: [{ x0: 6.2, x1: 8.5 }] })) + expect(r).toEqual({ moveDir: 1, jump: true }) + }) + + it('does not jump while the hazard is still far away', () => { + const r = autopilotInput({ ...initialState(5), vx: 7 }, world({ crates: [{ x0: 15, x1: 16.3, top: 1.2 }] })) + expect(r.jump).toBe(false) + }) + + it('keeps holding jump while rising (full-height jump), releases while falling', () => { + const rising = { ...initialState(5), y: 1, vy: 5, onGround: false } + const falling = { ...rising, vy: -5 } + expect(autopilotInput(rising, world()).jump).toBe(true) + expect(autopilotInput(falling, world()).jump).toBe(false) + }) + + it('ignores hazards already passed', () => { + const r = autopilotInput({ ...initialState(10), vx: 7 }, world({ spikes: [{ x0: 7, x1: 8.5, top: 0.55 }] })) + expect(r.jump).toBe(false) + }) +}) + +describe('watchdog', () => { + const stalled: ProgressTrack = { lastX: 5, lastAdvanceT: 0, nowT: 4 } + + it('stays quiet while progress is recent', () => { + const s = initialState(5) + const r = watchdog({ lastX: 5, lastAdvanceT: 0, nowT: 2 }, s, world()) + expect(r.forced).toBe(false) + expect(r.next).toBe(s) + }) + + it('frees a wedge past the blocking hazard', () => { + const r = watchdog(stalled, initialState(5), world({ crates: [{ x0: 5.5, x1: 6.8, top: 1.4 }] })) + expect(r.forced).toBe(true) + expect(r.next.x).toBeGreaterThan(6.8) + expect(r.next.onGround).toBe(true) + expect(r.next.lastSafeX).toBe(r.next.x) + }) + + it('nudges forward when nothing is ahead', () => { + const r = watchdog(stalled, initialState(5), world()) + expect(r.forced).toBe(true) + expect(r.next.x).toBe(11) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/disable-gate.test.ts b/hugo-apps/src/app-space-game/__tests__/disable-gate.test.ts new file mode 100644 index 000000000..73de2771e --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/disable-gate.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' + +describe('isGameDisabled', () => { + /** Create a fake storage backed by a Map */ + function createFakeStorage(): Pick { + const store = new Map() + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + } + } + + /** Create a storage that throws on all operations */ + function createThrowingStorage(): Pick { + return { + getItem: () => { throw new Error('Storage error') }, + setItem: () => { throw new Error('Storage error') }, + removeItem: () => { throw new Error('Storage error') }, + } + } + + it('returns true and sets storage when nogame=1', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + const result = isGameDisabled('?nogame=1', storage) + expect(result).toBe(true) + expect(storage.getItem('appSpaceGameDisabled')).toBe('1') + }) + + it('returns false and removes storage when nogame=0', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + storage.setItem('appSpaceGameDisabled', '1') + const result = isGameDisabled('?nogame=0', storage) + expect(result).toBe(false) + expect(storage.getItem('appSpaceGameDisabled')).toBeNull() + }) + + it('handles case-insensitive NoGame=1', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + const result = isGameDisabled('?NoGame=1', storage) + expect(result).toBe(true) + expect(storage.getItem('appSpaceGameDisabled')).toBe('1') + }) + + it('returns true when stored value exists and no param', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + storage.setItem('appSpaceGameDisabled', '1') + const result = isGameDisabled('', storage) + expect(result).toBe(true) + }) + + it('returns false when no param and empty storage', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + const result = isGameDisabled('', storage) + expect(result).toBe(false) + }) + + it('returns true when nogame=1 and storage is null', async () => { + const { isGameDisabled } = await import('../feature') + const result = isGameDisabled('?nogame=1', null) + expect(result).toBe(true) + }) + + it('does not throw when storage throws on nogame=1', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createThrowingStorage() + expect(() => isGameDisabled('?nogame=1', storage)).not.toThrow() + const result = isGameDisabled('?nogame=1', storage) + expect(result).toBe(true) + }) + + it('returns false and does not throw when storage throws with no param', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createThrowingStorage() + expect(() => isGameDisabled('', storage)).not.toThrow() + const result = isGameDisabled('', storage) + expect(result).toBe(false) + }) + + it('handles nogame=true', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + const result = isGameDisabled('?nogame=true', storage) + expect(result).toBe(true) + expect(storage.getItem('appSpaceGameDisabled')).toBe('1') + }) + + it('handles nogame=false', async () => { + const { isGameDisabled } = await import('../feature') + const storage = createFakeStorage() + storage.setItem('appSpaceGameDisabled', '1') + const result = isGameDisabled('?nogame=false', storage) + expect(result).toBe(false) + expect(storage.getItem('appSpaceGameDisabled')).toBeNull() + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/feature.test.ts b/hugo-apps/src/app-space-game/__tests__/feature.test.ts new file mode 100644 index 000000000..c91fc8be7 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/feature.test.ts @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Note: isGameEnabled was removed in C2 fix (#3009). The game enable signal is +// now carried by data.appSpaceGameEnabled from loadAppSpaceData(), which reads +// the server-side ImsConfig flag APP_SPACE_GAME_ENABLED (default OFF). + +// --- isWebGLAvailable --- +describe('isWebGLAvailable', () => { + let originalCreateElement: typeof document.createElement + + beforeEach(() => { + originalCreateElement = document.createElement.bind(document) + }) + + afterEach(() => { + vi.restoreAllMocks() + // Restore original createElement + vi.spyOn(document, 'createElement').mockRestore() + }) + + it('returns false when getContext returns null for both webgl2 and webgl', async () => { + vi.spyOn(document, 'createElement').mockReturnValue({ + getContext: vi.fn().mockReturnValue(null), + } as unknown as HTMLCanvasElement) + const { isWebGLAvailable } = await import('../feature') + expect(isWebGLAvailable()).toBe(false) + }) + + it('returns true when getContext returns a non-null context for webgl2', async () => { + const mockCtx = { drawingBufferWidth: 0 } + vi.spyOn(document, 'createElement').mockReturnValue({ + getContext: vi.fn().mockImplementation((type: string) => + type === 'webgl2' ? mockCtx : null, + ), + } as unknown as HTMLCanvasElement) + const { isWebGLAvailable } = await import('../feature') + expect(isWebGLAvailable()).toBe(true) + }) + + it('returns true when getContext returns a non-null context for webgl (fallback)', async () => { + const mockCtx = { drawingBufferWidth: 0 } + vi.spyOn(document, 'createElement').mockReturnValue({ + getContext: vi.fn().mockImplementation((type: string) => + type === 'webgl' ? mockCtx : null, + ), + } as unknown as HTMLCanvasElement) + const { isWebGLAvailable } = await import('../feature') + expect(isWebGLAvailable()).toBe(true) + }) + + it('returns false when getContext throws', async () => { + vi.spyOn(document, 'createElement').mockReturnValue({ + getContext: vi.fn().mockImplementation(() => { + throw new Error('WebGL not supported') + }), + } as unknown as HTMLCanvasElement) + const { isWebGLAvailable } = await import('../feature') + expect(isWebGLAvailable()).toBe(false) + }) +}) + +// --- loadAppSpaceData --- +describe('loadAppSpaceData', () => { + const mockData = { + eventId: 42, + eventName: 'Test Event', + type: 'DEVTOBERFEST', + paths: [], + } + + const mockFallbackData = { + eventId: 0, + eventName: 'Fallback Event', + type: 'DEFAULT', + paths: [], + } + + beforeEach(() => { + vi.resetAllMocks() + // Reset location to default (no eventid param) + Object.defineProperty(window, 'location', { + value: { search: '' }, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns parsed data on a successful primary fetch', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(mockData), + })) + const { loadAppSpaceData } = await import('../feature') + const result = await loadAppSpaceData() + expect(result).toEqual(mockData) + }) + + it('falls back to /app-space-data.json when primary response.ok=true but .json() rejects', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockRejectedValueOnce(new Error('bad json')), + }) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(mockFallbackData), + }), + ) + const { loadAppSpaceData } = await import('../feature') + const result = await loadAppSpaceData() + expect(result).toEqual(mockFallbackData) + }) + + it('falls back to /app-space-data.json when the primary fetch fails (non-ok response)', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 404 }) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(mockFallbackData), + }), + ) + const { loadAppSpaceData } = await import('../feature') + const result = await loadAppSpaceData() + expect(result).toEqual(mockFallbackData) + }) + + it('falls back to /app-space-data.json when the primary fetch throws', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockRejectedValueOnce(new Error('Network fail')) + .mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce(mockFallbackData), + }), + ) + const { loadAppSpaceData } = await import('../feature') + const result = await loadAppSpaceData() + expect(result).toEqual(mockFallbackData) + }) + + it('returns null when both primary and fallback fetches fail', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockRejectedValueOnce(new Error('Primary fail')) + .mockRejectedValueOnce(new Error('Fallback fail')), + ) + const { loadAppSpaceData } = await import('../feature') + const result = await loadAppSpaceData() + expect(result).toBeNull() + }) + + it('returns null when both primary and fallback respond non-ok', async () => { + vi.stubGlobal('fetch', vi.fn() + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ ok: false, status: 404 }), + ) + const { loadAppSpaceData } = await import('../feature') + const result = await loadAppSpaceData() + expect(result).toBeNull() + }) + + it('uses eventid from URL search params (case-insensitive) in primary fetch URL', async () => { + Object.defineProperty(window, 'location', { + value: { search: '?eventId=99' }, + writable: true, + configurable: true, + }) + const fetchMock = vi.fn().mockResolvedValueOnce({ + ok: true, + json: vi.fn().mockResolvedValueOnce({ ...mockData, eventId: 99 }), + }) + vi.stubGlobal('fetch', fetchMock) + const { loadAppSpaceData } = await import('../feature') + await loadAppSpaceData() + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('eventLegacyId=99'), + ) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/frame-map.test.ts b/hugo-apps/src/app-space-game/__tests__/frame-map.test.ts new file mode 100644 index 000000000..3ed18a486 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/frame-map.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { readFileSync } from 'fs' +import { fileURLToPath } from 'url' +import { createRequire } from 'module' +import path from 'path' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const ROOT = path.resolve(__dirname, '../../../..') +const FRAMES_PATH = path.resolve(__dirname, '../kasimir-frames.json') +const ATLAS_PATH = path.join(ROOT, 'hugo/static/images/app-space-game/kasimir-atlas.png') + +interface FrameMap { + image: string + atlas: { w: number; h: number; cols: number; rows: number } + cell: { w: number; h: number } + anchor: { x: number; y: number } + charH: number + clips: Record +} + +const fm = JSON.parse(readFileSync(FRAMES_PATH, 'utf8')) as FrameMap +const REQUIRED = ['idle', 'walk', 'trot', 'run', 'jump', 'hit', 'fall', 'ko', 'reach', 'chest', 'celebrate', 'taunt'] + +describe('kasimir-frames.json', () => { + it('has every clip the game uses, with frames, fps and loop', () => { + for (const k of REQUIRED) { + const c = fm.clips[k] + expect(c, k).toBeTruthy() + expect(c.frames.length, k).toBeGreaterThan(0) + expect(c.fps, k).toBeGreaterThan(0) + expect(typeof c.loop, k).toBe('boolean') + } + expect(fm.clips.jump.frames).toHaveLength(10) + expect(fm.clips.run.frames.length).toBeGreaterThanOrEqual(8) + }) + + it('uses a uniform grid inside the atlas, no larger than 2048px', () => { + expect(fm.atlas.w).toBe(fm.atlas.cols * fm.cell.w) + expect(fm.atlas.h).toBe(fm.atlas.rows * fm.cell.h) + expect(Math.max(fm.atlas.w, fm.atlas.h)).toBeLessThanOrEqual(2048) + for (const [name, c] of Object.entries(fm.clips)) { + for (const f of c.frames) { + expect(f.w, name).toBe(fm.cell.w) + expect(f.h, name).toBe(fm.cell.h) + expect(f.x % fm.cell.w, name).toBe(0) + expect(f.y % fm.cell.h, name).toBe(0) + expect(f.x + f.w).toBeLessThanOrEqual(fm.atlas.w) + expect(f.y + f.h).toBeLessThanOrEqual(fm.atlas.h) + } + } + }) + + it('anchors feet at the same point of every cell, horizontally centred (mirror-safe)', () => { + expect(fm.anchor.x).toBe(fm.cell.w / 2) + expect(fm.anchor.y).toBeGreaterThan(fm.cell.h * 0.9) + expect(fm.charH).toBeGreaterThan(100) + }) +}) + +describe('kasimir-atlas.png pixels', () => { + type Img = { data: Buffer; width: number } + let img: Img + + beforeAll(async () => { + const sharp = createRequire(import.meta.url)('sharp') + const { data, info } = await sharp(ATLAS_PATH).ensureAlpha().raw().toBuffer({ resolveWithObject: true }) + img = { data, width: info.width } + }) + + function bbox(f: { x: number; y: number; w: number; h: number }) { + let top = Infinity, bottom = -1 + for (let y = f.y; y < f.y + f.h; y++) { + for (let x = f.x; x < f.x + f.w; x++) { + if (img.data[(y * img.width + x) * 4 + 3] > 128) { + if (y < top) top = y + bottom = y + } + } + } + return { top: top - f.y, bottom: bottom - f.y } + } + + it('every frame stands on the shared baseline (no floating or sinking between clips)', () => { + for (const [name, c] of Object.entries(fm.clips)) { + c.frames.forEach((f, i) => { + const { bottom } = bbox(f) + expect(Math.abs(bottom + 1 - fm.anchor.y), `${name}[${i}]`).toBeLessThanOrEqual(3) + }) + } + }) + + it('standing poses from both source sheets render at the same character height', () => { + const standing = [...fm.clips.idle.frames, fm.clips.celebrate.frames[0], fm.clips.chest.frames[0]] + const heights = standing.map(f => { const b = bbox(f); return b.bottom - b.top }) + const min = Math.min(...heights), max = Math.max(...heights) + expect(max / min).toBeLessThan(1.3) + for (const h of fm.clips.idle.frames.map(f => { const b = bbox(f); return b.bottom - b.top })) { + expect(Math.abs(h - fm.charH) / fm.charH).toBeLessThan(0.05) + } + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/labels.test.ts b/hugo-apps/src/app-space-game/__tests__/labels.test.ts new file mode 100644 index 000000000..e96aec35e --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/labels.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { labelModes, stackRects, tidyTitle } from '../labels' + +describe('labelModes', () => { + const xs = [0, 6, 12, 18, 24, 30, 90] + + it('shows full titles only for the nearest coins to Kasimir', () => { + const m = labelModes(xs, 13, 15, 20) + expect(m.filter(x => x === 'full')).toHaveLength(2) + expect(m[2]).toBe('full') + expect(m[1] === 'full' || m[3] === 'full').toBe(true) + }) + + it('shows badges for other on-screen coins and hides off-screen ones', () => { + const m = labelModes(xs, 13, 15, 20) + expect(m[5]).toBe('badge') + expect(m[6]).toBe('hidden') + }) + + it('shows no full titles when no coin is near Kasimir', () => { + const m = labelModes([0, 40], 20, 20, 30) + expect(m).toEqual(['badge', 'badge']) + }) +}) + +describe('stackRects', () => { + it('leaves non-overlapping rects untouched', () => { + expect(stackRects([{ x: 100, y: 200, w: 80, h: 30 }, { x: 300, y: 200, w: 80, h: 30 }])).toEqual([0, 0]) + }) + + it('lifts a colliding rect above its neighbour', () => { + const rects = [{ x: 100, y: 200, w: 160, h: 40 }, { x: 180, y: 200, w: 160, h: 40 }] + const off = stackRects(rects, 6) + expect(off[0]).toBe(0) + expect(off[1]).toBe(46) + }) + + it('produces a layout with no overlaps for a dense row', () => { + const rects = Array.from({ length: 6 }, (_, i) => ({ x: 100 + i * 50, y: 300, w: 200, h: 44 })) + const off = stackRects(rects, 4) + const boxes = rects.map((r, i) => ({ l: r.x - r.w / 2, r: r.x + r.w / 2, b: r.y - off[i], t: r.y - off[i] - r.h })) + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i], b = boxes[j] + const overlap = a.l < b.r && a.r > b.l && a.t < b.b && a.b > b.t + expect(overlap).toBe(false) + } + } + }) +}) + +describe('tidyTitle', () => { + it('keeps short titles and collapses whitespace', () => { + expect(tidyTitle(' Build an App ')).toBe('Build an App') + }) + it('cuts long titles at a word boundary with an ellipsis', () => { + const t = tidyTitle('Create a SAP Fiori elements application with SAP Business Application Studio and deploy it', 50) + expect(t.endsWith('…')).toBe(true) + expect(t.length).toBeLessThanOrEqual(51) + expect(t).not.toMatch(/\s…$/) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/layout.test.ts b/hugo-apps/src/app-space-game/__tests__/layout.test.ts new file mode 100644 index 000000000..7bc041a30 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/layout.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest' +import { buildWorldLayout, goalAt, zoneIndexAt, COIN_Y, GOAL_REACH } from '../layout' +import { COIN_SPACING } from '../obstacles' +import type { LevelModel, Zone } from '../types' + +function zone(id: string, coins: number, extra: Partial = {}): Zone { + return { + id, title: id.toUpperCase(), colorIndex: 0, cleared: false, locked: false, + coins: Array.from({ length: coins }, (_, i) => ({ title: `T${i}`, done: false, available: true })), + gates: [], goal: { kind: 'track', label: 'Complete Track', prizeId: null, trackId: id }, + obstacles: [], pits: [], ...extra, + } +} + +describe('buildWorldLayout', () => { + const model: LevelModel = { + zones: [ + zone('a', 3, { gates: [{ afterCoinIndex: 0 }], obstacles: [{ x: 1.5, w: 1.3, h: 1, kind: 'crate' }] }), + zone('b', 2, { pits: [{ x: 0.5, w: 2 }], locked: true }), + ], + } + const L = buildWorldLayout(model) + + it('places coins at regular spacing on the walking line', () => { + const a = L.coins.filter(c => c.zoneIndex === 0) + expect(a).toHaveLength(3) + expect(a[1].x - a[0].x).toBe(COIN_SPACING) + expect(a.every(c => c.y === COIN_Y)).toBe(true) + }) + + it('zones are contiguous and the goal sits after the last coin', () => { + expect(L.zones[1].x0).toBe(L.zones[0].x1) + const lastA = L.coins.filter(c => c.zoneIndex === 0).at(-1)! + expect(L.zones[0].goal.x).toBeGreaterThan(lastA.x) + expect(L.zones[0].goal.x).toBeLessThan(L.zones[0].x1) + }) + + it('converts hazards to world edges between coins', () => { + const coins = L.coins.filter(c => c.zoneIndex === 0) + const crate = L.hazards.crates[0] + expect((crate.x0 + crate.x1) / 2).toBeCloseTo((coins[1].x + coins[2].x) / 2) + expect(crate.top).toBe(1) + expect(L.hazards.pits).toHaveLength(1) + }) + + it('carries locked state and track id onto goals', () => { + expect(L.zones[1].goal.locked).toBe(true) + expect(L.zones[1].goal.trackId).toBe('b') + }) + + it('zoneIndexAt / goalAt find the right zone and goal', () => { + expect(zoneIndexAt(L, 0)).toBe(0) + expect(zoneIndexAt(L, L.zones[1].x0 + 1)).toBe(1) + expect(goalAt(L, L.zones[0].goal.x + GOAL_REACH * 0.5)?.zoneId).toBe('a') + expect(goalAt(L, L.zones[0].goal.x - GOAL_REACH - 1)).toBeNull() + }) + + it('the level has walls just outside the first and last zone', () => { + expect(L.hazards.minX).toBe(0) + expect(L.hazards.maxX).toBe(L.length) + expect(L.length).toBeGreaterThan(L.zones[1].x1) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/level-model.test.ts b/hugo-apps/src/app-space-game/__tests__/level-model.test.ts new file mode 100644 index 000000000..867ef391d --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/level-model.test.ts @@ -0,0 +1,535 @@ +import { describe, it, expect } from 'vitest' +import { buildLevelModel, zoneColor } from '../level-model' +import type { AppSpaceData } from '../types' + +describe('buildLevelModel', () => { + it('empty paths -> zones.length === 0', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [], + } + const result = buildLevelModel(data) + expect(result.zones).toHaveLength(0) + }) + + it('one linear path, 3 TUTORIAL items -> 1 zone, 3 coins in order, titles preserved', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track1', + title: 'Track One', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial A', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: 2, + title: 'Tutorial B', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: 3, + title: 'Tutorial C', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones).toHaveLength(1) + expect(result.zones[0].coins).toHaveLength(3) + expect(result.zones[0].coins[0].title).toBe('Tutorial A') + expect(result.zones[0].coins[1].title).toBe('Tutorial B') + expect(result.zones[0].coins[2].title).toBe('Tutorial C') + }) + + it('fork + parallel-any segments -> coins flattened to one ordered list', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track2', + title: 'Track Two', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Linear 1', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + { + order: 1, + kind: 'parallel-any', + items: [ + { + imsId: 2, + title: 'Cluster 1', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: 3, + title: 'Cluster 2', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + { + order: 2, + kind: 'fork', + lanes: [ + { + label: 'Lane A', + items: [ + { + imsId: 4, + title: 'Fork Lane A 1', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + { + label: 'Lane B', + items: [ + { + imsId: 5, + title: 'Fork Lane B 1', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones).toHaveLength(1) + expect(result.zones[0].coins).toHaveLength(5) + expect(result.zones[0].coins[0].title).toBe('Linear 1') + expect(result.zones[0].coins[1].title).toBe('Cluster 1') + expect(result.zones[0].coins[2].title).toBe('Cluster 2') + expect(result.zones[0].coins[3].title).toBe('Fork Lane A 1') + expect(result.zones[0].coins[4].title).toBe('Fork Lane B 1') + }) + + it('path with a literal type:PRIZE item -> goal.kind==="prize"', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track3', + title: 'Track Three', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: null, + title: 'The Prize', + type: 'PRIZE', + status: 'NEW', + progress: 0, + experience: '0', + timeToComplete: 0, + url: '', + description: '', + prizeId: 42, + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].goal.kind).toBe('prize') + expect(result.zones[0].goal.label).toBe('The Prize') + expect(result.zones[0].goal.prizeId).toBe(42) + }) + + it('path whose only prize is type:CHECKPOINT + prizeId -> goal.kind==="checkpoint", prizeId preserved', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track4', + title: 'Track Four', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: 2, + title: 'Checkpoint Prize', + type: 'CHECKPOINT', + status: 'NEW', + progress: 0, + experience: '50', + timeToComplete: 0, + url: '', + description: '', + prizeId: 99, + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].goal.kind).toBe('checkpoint') + expect(result.zones[0].goal.label).toBe('Checkpoint Prize') + expect(result.zones[0].goal.prizeId).toBe(99) + }) + + it('path with neither PRIZE nor prizeId CHECKPOINT -> goal.kind==="track", trackId set', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track5', + title: 'Track Five', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].goal.kind).toBe('track') + expect(result.zones[0].goal.trackId).toBe('track5') + }) + + it('authenticated + some status:COMPLETED -> those coins done:true; zone with all tutorials COMPLETED -> cleared:true', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + authenticated: true, + type: 'test', + paths: [ + { + id: 'track6', + title: 'Track Six', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial 1', + type: 'TUTORIAL', + status: 'COMPLETED', + progress: 100, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: 2, + title: 'Tutorial 2', + type: 'TUTORIAL', + status: 'COMPLETED', + progress: 100, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].coins[0].done).toBe(true) + expect(result.zones[0].coins[1].done).toBe(true) + expect(result.zones[0].cleared).toBe(true) + }) + + it('anonymous payload (no authenticated, items lack status) -> all coins available:true, done:false; no throw', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track7', + title: 'Track Seven', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial', + type: 'TUTORIAL', + // no status property + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].coins[0].done).toBe(false) + expect(result.zones[0].coins[0].available).toBe(true) + }) + + it('unlocked:false items -> zone locked:true, coins available:false', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track8', + title: 'Track Eight', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Locked Tutorial', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + unlocked: false, + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].locked).toBe(true) + expect(result.zones[0].coins[0].available).toBe(false) + }) + + it('CHECKPOINT item mid-list -> a Gate with correct afterCoinIndex', () => { + const data: AppSpaceData = { + eventId: 1, + eventName: 'Test Event', + type: 'test', + paths: [ + { + id: 'track9', + title: 'Track Nine', + description: '', + items: [], + segments: [ + { + order: 0, + kind: 'linear', + items: [ + { + imsId: 1, + title: 'Tutorial 1', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + { + imsId: 2, + title: 'Checkpoint', + type: 'CHECKPOINT', + status: 'NEW', + progress: 0, + experience: '50', + timeToComplete: 0, + url: '', + description: '', + // no prizeId, so becomes a gate + }, + { + imsId: 3, + title: 'Tutorial 2', + type: 'TUTORIAL', + status: 'NEW', + progress: 0, + experience: '100', + timeToComplete: 30, + url: '', + description: '', + }, + ], + }, + ], + }, + ], + } + const result = buildLevelModel(data) + expect(result.zones[0].coins).toHaveLength(2) // only tutorials + expect(result.zones[0].gates).toHaveLength(1) + expect(result.zones[0].gates[0].afterCoinIndex).toBe(0) + }) + + it('zoneColor(0..11) returns distinct hex strings, wraps at palette length', () => { + const colors = Array.from({ length: 12 }, (_, i) => zoneColor(i)) + // Check that the first 11 are distinct + const uniqueColors = new Set(colors.slice(0, 11)) + expect(uniqueColors.size).toBe(11) + + // Check wraparound: index 11 should equal index 0 (mod palette length) + expect(zoneColor(11)).toBe(zoneColor(11 % 11)) + // And verify palette length is 11 + expect(zoneColor(0)).toBe(zoneColor(11)) + + // Verify all are valid hex colors + colors.forEach(color => { + expect(color).toMatch(/^#[0-9a-fA-F]{6}$/) + }) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/mount.test.ts b/hugo-apps/src/app-space-game/__tests__/mount.test.ts new file mode 100644 index 000000000..b4a8b0481 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/mount.test.ts @@ -0,0 +1,23 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' + +describe('AppSpaceGame mount scaffold', () => { + it('mounts without throwing and renders data-app-space-game root', async () => { + const AppSpaceGame = (await import('../AppSpaceGame.vue')).default + + const wrapper = mount(AppSpaceGame) + + // Component mounts successfully + expect(wrapper.exists()).toBe(true) + + // Root element has the data attribute + expect(wrapper.element.getAttribute('data-app-space-game')).toBe('') + + // Task 4: canvas is present in the template (renderer mounts into it when gate passes) + expect(wrapper.find('canvas').exists()).toBe(true) + + // No text content - the game canvas has no visible text nodes + expect(wrapper.text()).toBe('') + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/obstacles.test.ts b/hugo-apps/src/app-space-game/__tests__/obstacles.test.ts new file mode 100644 index 000000000..35cfd3b81 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/obstacles.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from 'vitest' +import { placeHazards, COIN_SPACING, MIN_LANDING } from '../obstacles' +import { JUMP_ARC, HAZARD_LIMITS } from '../constants' + +describe('placeHazards', () => { + it('is deterministic', () => { + expect(placeHazards(9, 3)).toEqual(placeHazards(9, 3)) + }) + + it('returns nothing for tiny zones', () => { + for (const n of [0, 1, 2]) expect(placeHazards(n, 1)).toEqual({ obstacles: [], pits: [] }) + }) + + it('keeps every hazard comfortably inside the jump arc (not frame-perfect)', () => { + for (let seed = 0; seed < 30; seed++) { + const { obstacles, pits } = placeHazards(14, seed) + for (const p of pits) expect(p.w).toBeLessThanOrEqual(JUMP_ARC.maxGap * 0.5 + 1e-9) + for (const o of obstacles) expect(o.h).toBeLessThanOrEqual(JUMP_ARC.maxHeight * 0.55 + 1e-9) + for (const o of obstacles) if (o.kind === 'spike') expect(o.h).toBe(HAZARD_LIMITS.spikeH) + } + }) + + it('never sits on a coin, in the first gap, or in a gate gap', () => { + for (let seed = 0; seed < 30; seed++) { + const { obstacles, pits } = placeHazards(12, seed, [4, 7]) + for (const h of [...obstacles, ...pits]) { + expect(h.x % 1).toBe(0.5) + expect(h.x).toBeGreaterThan(1) + expect([4.5, 7.5]).not.toContain(h.x) + } + } + }) + + it('leaves at least MIN_LANDING of ground between hazards', () => { + for (let seed = 0; seed <= 20; seed++) { + for (let n = 3; n <= 15; n++) { + const { obstacles, pits } = placeHazards(n, seed) + const edges = [...obstacles, ...pits] + .map(h => ({ l: h.x * COIN_SPACING - h.w / 2, r: h.x * COIN_SPACING + h.w / 2 })) + .sort((a, b) => a.l - b.l) + for (let i = 1; i < edges.length; i++) expect(edges[i].l - edges[i - 1].r).toBeGreaterThanOrEqual(MIN_LANDING) + } + } + }) + + it('uses a mix of pits, crates and spikes across zones', () => { + const kinds = new Set() + for (let seed = 0; seed < 20; seed++) { + const { obstacles, pits } = placeHazards(10, seed) + if (pits.length) kinds.add('pit') + for (const o of obstacles) kinds.add(o.kind) + } + expect(kinds).toEqual(new Set(['pit', 'crate', 'spike'])) + }) + + it('caps hazard count at ceil(coins / 2.5)', () => { + for (const n of [3, 6, 9, 12, 15]) { + const { obstacles, pits } = placeHazards(n, n) + expect(obstacles.length + pits.length).toBeLessThanOrEqual(Math.ceil(n / 2.5)) + } + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/physics.test.ts b/hugo-apps/src/app-space-game/__tests__/physics.test.ts new file mode 100644 index 000000000..4a2168bd9 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/physics.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect } from 'vitest' +import { stepPhysics, respawn, initialState, GROUND_Y } from '../physics' +import type { PhysicsState, PhysicsInput } from '../physics' +import type { WorldHazards } from '../layout' +import { FIXED_DT, JUMP_ARC, BODY_HW, INVULN_S } from '../constants' + +const world = (h: Partial = {}): WorldHazards => ({ + crates: [], spikes: [], pits: [], minX: 0, maxX: 200, ...h, +}) +const idle: PhysicsInput = { moveDir: 0, jump: false } + +function run(s: PhysicsState, input: PhysicsInput | ((s: PhysicsState, i: number) => PhysicsInput), h: WorldHazards, seconds: number) { + const events: string[] = [] + const n = Math.round(seconds / FIXED_DT) + for (let i = 0; i < n; i++) { + const inp = typeof input === 'function' ? input(s, i) : input + const r = stepPhysics(s, inp, h, FIXED_DT) + if (r.event !== 'none') events.push(r.event) + s = r.next + } + return { s, events } +} + +describe('stepPhysics', () => { + it('feet rest on the ground at y = 0', () => { + const { s } = run({ ...initialState(5), y: 4, onGround: false }, idle, world(), 2) + expect(s.y).toBe(GROUND_Y) + expect(s.onGround).toBe(true) + }) + + it('accelerates smoothly instead of snapping to full speed', () => { + const first = stepPhysics(initialState(5), { moveDir: 1, jump: false }, world(), FIXED_DT).next + expect(first.vx).toBeGreaterThan(0) + expect(first.vx).toBeLessThan(1) + const { s } = run(initialState(5), { moveDir: 1, jump: false }, world(), 1) + expect(s.vx).toBeCloseTo(7, 5) + }) + + it('moves left and faces left', () => { + const { s } = run(initialState(50), { moveDir: -1, jump: false }, world(), 0.5) + expect(s.x).toBeLessThan(50) + expect(s.facing).toBe(-1) + }) + + it('a full held jump reaches JUMP_ARC.maxHeight; a tap is lower (variable jump)', () => { + const peak = (hold: number) => { + let s = initialState(5) + let max = 0 + for (let i = 0; i < 200; i++) { + s = stepPhysics(s, { moveDir: 0, jump: i < hold }, world(), FIXED_DT).next + max = Math.max(max, s.y) + } + return max + } + const full = peak(200) + expect(full).toBeGreaterThan(JUMP_ARC.maxHeight * 0.95) + expect(full).toBeLessThanOrEqual(JUMP_ARC.maxHeight + 1e-6) + expect(peak(6)).toBeLessThan(JUMP_ARC.maxHeight * 0.6) + }) + + it('holding jump does not re-jump on landing (needs a fresh press)', () => { + const { events } = run(initialState(5), { moveDir: 0, jump: true }, world(), 2) + expect(events.filter(e => e === 'jump')).toHaveLength(1) + }) + + it('jump buffer: a press shortly before landing still jumps', () => { + let s: PhysicsState = { ...initialState(5), y: 0.3, vy: -6, onGround: false } + s = stepPhysics(s, { moveDir: 0, jump: true }, world(), FIXED_DT).next + const { events } = run(s, { moveDir: 0, jump: true }, world(), 0.1) + expect(events).toContain('jump') + }) + + it('coyote time: can still jump just after walking off a ledge', () => { + const pit = { x0: 10, x1: 14 } + let s: PhysicsState = { ...initialState(10.1), vx: 7 } + s = stepPhysics(s, { moveDir: 1, jump: false }, world({ pits: [pit] }), FIXED_DT).next + expect(s.onGround).toBe(false) + const r = stepPhysics(s, { moveDir: 1, jump: true }, world({ pits: [pit] }), FIXED_DT) + expect(r.event).toBe('jump') + }) + + it('crates block from the side with NO penalty event', () => { + const crate = { x0: 8, x1: 9.3, top: 1.2 } + const { s, events } = run(initialState(5), { moveDir: 1, jump: false }, world({ crates: [crate] }), 2) + expect(s.x).toBeCloseTo(crate.x0 - BODY_HW, 5) + expect(events).not.toContain('hit') + }) + + it('can land on top of a crate', () => { + const crate = { x0: 8, x1: 9.3, top: 1.2 } + const s0: PhysicsState = { ...initialState(8.6), y: 3, onGround: false } + const { s } = run(s0, idle, world({ crates: [crate] }), 1) + expect(s.y).toBeCloseTo(1.2, 5) + expect(s.onGround).toBe(true) + }) + + it('falls into a pit and reports fell', () => { + const pit = { x0: 10, x1: 13 } + const { s, events } = run(initialState(9), { moveDir: 1, jump: false }, world({ pits: [pit] }), 2) + expect(events).toContain('fell') + expect(s.x).toBeGreaterThan(pit.x0) + expect(s.x).toBeLessThan(pit.x1) + }) + + it('clears a max-width pit with a running jump', () => { + const pit = { x0: 12, x1: 12 + JUMP_ARC.maxGap * 0.5 } + const { s, events } = run( + { ...initialState(5), vx: 7 }, + (st) => ({ moveDir: 1, jump: st.x > pit.x0 - 1.4 && st.x < pit.x0 }), + world({ pits: [pit] }), + 2, + ) + expect(events).not.toContain('fell') + expect(s.x).toBeGreaterThan(pit.x1) + }) + + it('spikes cost a hit with knock-back and brief invulnerability', () => { + const spike = { x0: 8, x1: 9.5, top: 0.55 } + let s: PhysicsState = { ...initialState(6), vx: 7 } + let hit = false + for (let i = 0; i < 120 && !hit; i++) { + const r = stepPhysics(s, { moveDir: 1, jump: false }, world({ spikes: [spike] }), FIXED_DT) + s = r.next + hit = r.event === 'hit' + } + expect(hit).toBe(true) + expect(s.vx).toBeLessThan(0) + expect(s.vy).toBeGreaterThan(0) + expect(s.invuln).toBeCloseTo(INVULN_S, 5) + }) + + it('level walls stop Kasimir at both ends', () => { + const { s } = run(initialState(2), { moveDir: -1, jump: false }, world({ minX: 0, maxX: 30 }), 2) + expect(s.x).toBeCloseTo(BODY_HW, 5) + const r = run(initialState(28), { moveDir: 1, jump: false }, world({ minX: 0, maxX: 30 }), 2) + expect(r.s.x).toBeCloseTo(30 - BODY_HW, 5) + }) + + it('lastSafeX stays away from hazard edges', () => { + const pit = { x0: 10, x1: 13 } + const { s } = run(initialState(5), { moveDir: 1, jump: false }, world({ pits: [pit] }), 2) + expect(s.lastSafeX).toBeLessThanOrEqual(pit.x0 - 1.2) + }) +}) + +describe('respawn', () => { + it('returns to lastSafeX on the ground, stopped and briefly invulnerable', () => { + const s: PhysicsState = { ...initialState(8), y: -4, vy: -10, vx: 3, onGround: false, lastSafeX: 4 } + const r = respawn(s) + expect(r).toMatchObject({ x: 4, y: 0, vx: 0, vy: 0, onGround: true }) + expect(r.invuln).toBeGreaterThan(0) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/renderer.guard.test.ts b/hugo-apps/src/app-space-game/__tests__/renderer.guard.test.ts new file mode 100644 index 000000000..889d4f5e5 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/renderer.guard.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment happy-dom +// Module-contract guard for renderer.ts - NO real WebGL. +// Three.js WebGLRenderer is mocked so this test runs in jsdom/node without a GPU. +// Only asserts the exported API shape and that stop() before start() is safe. +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock `three` before importing renderer so WebGLRenderer never hits the real WebGL path. +vi.mock('three', async () => { + const actual = await vi.importActual('three') + const MockWebGLRenderer = vi.fn().mockImplementation(() => ({ + setPixelRatio: vi.fn(), + setSize: vi.fn(), + render: vi.fn(), + shadowMap: { enabled: false, type: null }, + domElement: document.createElement('canvas'), + dispose: vi.fn(), + })) + return { ...actual, WebGLRenderer: MockWebGLRenderer } +}) + +describe('renderer module contract', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('createGame is a function', async () => { + const { createGame } = await import('../renderer') + expect(typeof createGame).toBe('function') + }) + + it('createGame returns an object with start/stop/setPlayerControl methods', async () => { + const { createGame } = await import('../renderer') + const canvas = document.createElement('canvas') + const model = { zones: [] } + const game = createGame(canvas, model, { onPickTrack: () => {} }) + expect(typeof game.start).toBe('function') + expect(typeof game.stop).toBe('function') + expect(typeof game.setPlayerControl).toBe('function') + }) + + it('stop() before start() does not throw', async () => { + const { createGame } = await import('../renderer') + const canvas = document.createElement('canvas') + const model = { zones: [] } + const game = createGame(canvas, model, { onPickTrack: () => {} }) + expect(() => game.stop()).not.toThrow() + }) + + it('setPlayerControl() is callable without start()', async () => { + const { createGame } = await import('../renderer') + const canvas = document.createElement('canvas') + const model = { zones: [] } + const game = createGame(canvas, model, { onPickTrack: () => {} }) + expect(() => game.setPlayerControl(true)).not.toThrow() + expect(() => game.setPlayerControl(false)).not.toThrow() + }) + + it('createGame is safe when WebGLRenderer throws (defensive gate)', async () => { + const three = await import('three') + vi.mocked(three.WebGLRenderer).mockImplementationOnce(() => { + throw new Error('WebGL not supported') + }) + const { createGame } = await import('../renderer') + const canvas = document.createElement('canvas') + const model = { zones: [] } + // Should not throw - returns a safe no-op object + expect(() => { + const game = createGame(canvas, model, { onPickTrack: () => {} }) + game.stop() + }).not.toThrow() + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/rings.test.ts b/hugo-apps/src/app-space-game/__tests__/rings.test.ts new file mode 100644 index 000000000..b9e660b79 --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/rings.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest' +import { reset, collect, onHit } from '../rings' + +describe('rings state', () => { + it('reset() returns { count: 0 }', () => { + const state = reset() + expect(state.count).toBe(0) + }) + + it('collect({count: 3}) returns { count: 4 } without mutating input', () => { + const original = { count: 3 } + const collected = collect(original) + expect(collected.count).toBe(4) + expect(original.count).toBe(3) + }) + + it('onHit({count: 7}) returns next.count === 0 and scattered === 7', () => { + const state = { count: 7 } + const { next, scattered } = onHit(state) + expect(next.count).toBe(0) + expect(scattered).toBe(7) + }) + + it('onHit({count: 0}) returns next.count === 0 and scattered === 0 (harmless no-op)', () => { + const state = { count: 0 } + const { next, scattered } = onHit(state) + expect(next.count).toBe(0) + expect(scattered).toBe(0) + }) + + it('collect(collect(reset())) chains to count 2', () => { + const chained = collect(collect(reset())) + expect(chained.count).toBe(2) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/solvability.test.ts b/hugo-apps/src/app-space-game/__tests__/solvability.test.ts new file mode 100644 index 000000000..c883de7af --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/solvability.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from 'vitest' +import { placeHazards } from '../obstacles' +import { buildWorldLayout } from '../layout' +import { initialState, stepPhysics } from '../physics' +import { autopilotInput } from '../autopilot' +import { FIXED_DT } from '../constants' +import type { LevelModel, Zone } from '../types' + +function zone(coinCount: number, seed: number, gateAfter: number[] = []): Zone { + const { obstacles, pits } = placeHazards(coinCount, seed, gateAfter) + return { + id: `z${seed}-${coinCount}`, + title: 'Zone', + colorIndex: seed, + cleared: false, + locked: false, + coins: Array.from({ length: coinCount }, (_, i) => ({ title: `T${i}`, done: false, available: true })), + gates: gateAfter.map(a => ({ afterCoinIndex: a })), + goal: { kind: 'track', label: 'Complete Track', prizeId: null, trackId: 't' }, + obstacles, + pits, + } +} + +function runAutopilot(model: LevelModel) { + const layout = buildWorldLayout(model) + let s = initialState(layout.spawnX) + const events: string[] = [] + let t = 0 + let lastAdvance = 0 + let bestX = s.x + let wedged = false + while (s.x < layout.length - 3 && t < 300) { + const { next, event } = stepPhysics(s, autopilotInput(s, layout.hazards), layout.hazards, FIXED_DT) + if (event === 'hit' || event === 'fell') events.push(`${event}@${s.x.toFixed(1)}`) + s = next + t += FIXED_DT + if (s.x > bestX + 0.5) { bestX = s.x; lastAdvance = t } + if (t - lastAdvance > 3) { wedged = true; break } + if (event === 'fell') break + } + return { events, wedged, finished: s.x >= layout.length - 3, layout } +} + +describe('level solvability (autopilot never needs the watchdog)', () => { + it('clears every seed 0..40 × coin counts 3..15 without a hit, fall or wedge', () => { + const failures: string[] = [] + let hazardsSeen = 0 + for (let seed = 0; seed <= 40; seed++) { + for (let n = 3; n <= 15; n++) { + const r = runAutopilot({ zones: [zone(n, seed)] }) + hazardsSeen += r.layout.hazards.crates.length + r.layout.hazards.spikes.length + r.layout.hazards.pits.length + if (!r.finished || r.wedged || r.events.length) { + failures.push(`seed ${seed} n ${n}: finished=${r.finished} wedged=${r.wedged} ${r.events.join(',')}`) + } + } + } + expect(hazardsSeen).toBeGreaterThan(500) + expect(failures).toEqual([]) + }) + + it('clears a multi-zone level with gates', () => { + const model: LevelModel = { zones: [zone(8, 1, [3]), zone(12, 2, [5]), zone(5, 3)] } + const r = runAutopilot(model) + expect(r.events).toEqual([]) + expect(r.wedged).toBe(false) + expect(r.finished).toBe(true) + }) +}) diff --git a/hugo-apps/src/app-space-game/__tests__/sound.test.ts b/hugo-apps/src/app-space-game/__tests__/sound.test.ts new file mode 100644 index 000000000..f4f0a325e --- /dev/null +++ b/hugo-apps/src/app-space-game/__tests__/sound.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach } from 'vitest' + +// These tests validate sound.ts behavior: +// 1. starts disabled +// 2. blip() does not throw when AudioContext is undefined +// 3. toggle() flips enabled +// 4. blip() is a no-op (no throw) while disabled + +describe('createSound', () => { + beforeEach(() => { + // Ensure AudioContext is absent for safe testing + // happy-dom may expose a stub — delete it so we test the no-AudioContext path + if ('AudioContext' in window) { + // @ts-expect-error deleting global + delete (window as Record).AudioContext + } + if ('webkitAudioContext' in window) { + // @ts-expect-error deleting global + delete (window as Record).webkitAudioContext + } + }) + + it('createSound().enabled is false (starts disabled)', async () => { + // Re-import each time so module state is fresh + const { createSound } = await import('../sound') + const s = createSound() + expect(s.enabled).toBe(false) + }) + + it('blip() does not throw when window.AudioContext is undefined', async () => { + const { createSound } = await import('../sound') + const s = createSound() + // AudioContext is absent (deleted in beforeEach); blip must not throw + expect(() => s.blip()).not.toThrow() + }) + + it('toggle() flips enabled false→true→false', async () => { + const { createSound } = await import('../sound') + const s = createSound() + expect(s.enabled).toBe(false) + s.toggle() + expect(s.enabled).toBe(true) + s.toggle() + expect(s.enabled).toBe(false) + }) + + it('blip() is a no-op (no throw) while disabled', async () => { + const { createSound } = await import('../sound') + const s = createSound() + // disabled by default; blip must not throw even if somehow AudioContext existed + expect(() => s.blip()).not.toThrow() + // ensure still disabled + expect(s.enabled).toBe(false) + // blip again still no throw + expect(() => s.blip()).not.toThrow() + }) +}) diff --git a/hugo-apps/src/app-space-game/anim.ts b/hugo-apps/src/app-space-game/anim.ts new file mode 100644 index 000000000..bcce1a8bb --- /dev/null +++ b/hugo-apps/src/app-space-game/anim.ts @@ -0,0 +1,74 @@ +// Pure animation state machine for the Kasimir sprite. No DOM, no Three.js. + +import { JUMP, SPEED } from './constants' + +export type ClipName = + | 'idle' | 'walk' | 'trot' | 'run' | 'jump' + | 'hit' | 'fall' | 'ko' | 'reach' | 'chest' | 'celebrate' | 'taunt' + +export type AnimEvent = 'none' | 'hit' | 'fell' | 'land' | 'jump' | 'respawn' + +export interface AnimContext { + onGround: boolean + vx: number + vy: number + lastEvent: AnimEvent + sinceEventMs: number + /** Scripted pose (standing at a goal, celebrating) — overrides locomotion. */ + pose?: 'reach' | 'chest' | 'celebrate' | 'taunt' | null +} + +/** Strict priority: scripted pose > fall > hit > airborne > ground locomotion by speed. */ +export function selectClip(ctx: AnimContext): ClipName { + const { onGround, vx, lastEvent, sinceEventMs } = ctx + if (ctx.pose) return ctx.pose + if (lastEvent === 'fell') return 'fall' + if (lastEvent === 'respawn' && sinceEventMs < 700) return 'hit' + if (lastEvent === 'hit' && sinceEventMs < 700) return onGround ? 'hit' : 'fall' + if (!onGround) return 'jump' + if (lastEvent === 'land' && sinceEventMs < 90) return 'jump' + const speed = Math.abs(vx) + if (speed < 0.35) return 'idle' + if (speed < SPEED * 0.45) return 'walk' + if (speed < SPEED * 0.8) return 'trot' + return 'run' +} + +/** + * Jump frames are chosen by the arc, not by time, so the pose always matches the motion. + * Sheet order: 0 crouch · 1 push-off · 2–4 rise · 5 apex stretch · 6–7 descend · 8 land · 9 stand. + */ +export function jumpFrame(ctx: AnimContext, frameCount: number): number { + const last = frameCount - 1 + if (ctx.onGround) return Math.min(8, last) + if (ctx.lastEvent === 'jump' && ctx.sinceEventMs < 60) return Math.min(1, last) + const v = ctx.vy / JUMP + let f: number + if (v > 0.7) f = 2 + else if (v > 0.4) f = 3 + else if (v > 0.12) f = 4 + else if (v > -0.2) f = 5 + else if (v > -0.6) f = 6 + else f = 7 + return Math.min(f, last) +} + +/** Frame index within a time-driven clip. Loops wrap; one-shots hold the last frame. */ +export function frameAt( + clipName: ClipName, + elapsedMs: number, + clips: Partial>, +): number { + const clip = clips[clipName] + if (!clip || clip.frames.length === 0) return 0 + const raw = Math.floor((elapsedMs * clip.fps) / 1000) + return clip.loop ? raw % clip.frames.length : Math.min(raw, clip.frames.length - 1) +} + +/** Locomotion clips play faster the faster Kasimir moves, so feet don't skate. */ +export function playbackRate(clip: ClipName, vx: number): number { + const ref: Partial> = { walk: SPEED * 0.3, trot: SPEED * 0.62, run: SPEED } + const r = ref[clip] + if (!r) return 1 + return Math.min(1.4, Math.max(0.6, Math.abs(vx) / r)) +} diff --git a/hugo-apps/src/app-space-game/autopilot.ts b/hugo-apps/src/app-space-game/autopilot.ts new file mode 100644 index 000000000..bc4230eaf --- /dev/null +++ b/hugo-apps/src/app-space-game/autopilot.ts @@ -0,0 +1,62 @@ +// Attract-mode driver: runs right and times jumps from the hazard geometry and the +// current speed so Kasimir clears every crate, spike and pit. Pure and deterministic. + +import { GRAV, JUMP, BODY_HW } from './constants' +import type { PhysicsState, PhysicsInput } from './physics' +import type { WorldHazards } from './layout' + +const WEDGE_TIMEOUT = 3 +const RISE_T = JUMP / Math.abs(GRAV) + +export interface ProgressTrack { + lastX: number + lastAdvanceT: number + nowT: number +} + +interface Ahead { x0: number; x1: number } + +/** Nearest hazard whose far edge is still ahead of Kasimir's centre. */ +function nextHazard(x: number, h: WorldHazards): Ahead | null { + let best: Ahead | null = null + const consider = (x0: number, x1: number) => { + if (x1 <= x) return + if (!best || x0 < best.x0) best = { x0, x1 } + } + for (const c of h.crates) consider(c.x0, c.x1) + for (const s of h.spikes) consider(s.x0, s.x1) + for (const p of h.pits) consider(p.x0, p.x1) + return best +} + +/** + * Jump so the apex lands over the hazard's middle. Holds the button while rising + * (full-height jump), releases on the way down so the next press registers. + */ +export function autopilotInput(s: PhysicsState, h: WorldHazards): PhysicsInput { + const holding = !s.onGround && s.vy > 0 + const hz = nextHazard(s.x, h) + if (!hz || !s.onGround) return { moveDir: 1, jump: holding } + const front = s.x + BODY_HW + const d = hz.x0 - front + const speed = Math.max(Math.abs(s.vx), 3) + const halfW = (hz.x1 - hz.x0) / 2 + const launchAt = Math.min(2.6, Math.max(0.25, speed * RISE_T - (halfW + BODY_HW))) + const launch = d <= launchAt + return { moveDir: 1, jump: launch || holding } +} + +/** Furthest-right x Kasimir has safely reached is tracked by the caller; this frees a wedge. */ +export function watchdog( + progress: ProgressTrack, + s: PhysicsState, + h: WorldHazards, +): { forced: boolean; next: PhysicsState } { + if (progress.nowT - progress.lastAdvanceT <= WEDGE_TIMEOUT) return { forced: false, next: s } + const hz = nextHazard(s.x, h) + const x = hz ? hz.x1 + 1.5 : s.x + 6 + return { + forced: true, + next: { ...s, x: Math.min(x, h.maxX - 1), y: 0, vx: 0, vy: 0, onGround: true, lastSafeX: Math.min(x, h.maxX - 1) }, + } +} diff --git a/hugo-apps/src/app-space-game/constants.ts b/hugo-apps/src/app-space-game/constants.ts new file mode 100644 index 000000000..33559f7b3 --- /dev/null +++ b/hugo-apps/src/app-space-game/constants.ts @@ -0,0 +1,45 @@ +// World units: ground top is y = 0 and physics y is Kasimir's FEET. + +export const SPEED = 7 // max horizontal speed (units/s) +export const ACCEL = 38 // ground acceleration toward target speed +export const DECEL = 46 // ground deceleration when no input +export const TURN_ACCEL = 60 // ground acceleration when reversing direction +export const AIR_ACCEL = 26 // air control +export const GRAV = -34 // gravity (units/s²) +export const JUMP = 13.5 // jump launch speed (units/s) +export const JUMP_CUT_GRAV = 2.4 // gravity multiplier while rising with jump released (variable jump) +export const FALL_GRAV = 1.25 // gravity multiplier while falling (snappier arcs) +export const MAX_FALL = -28 +export const COYOTE_S = 0.1 // can still jump this long after walking off a ledge +export const JUMP_BUFFER_S = 0.12 // a jump pressed this long before landing still fires +export const INVULN_S = 1.4 // invulnerability after a spike hit +export const FALL_LIMIT_Y = -5 // below this (inside a pit) Kasimir has fallen + +export const BODY_HW = 0.45 // collision half-width +export const BODY_H = 2.1 // collision height +export const KASIMIR_H = 2.5 // rendered character height (feet → ear tips) + +export const FIXED_DT = 1 / 120 // physics step + +const RISE_T = JUMP / Math.abs(GRAV) +const MAX_H = (JUMP * JUMP) / (2 * Math.abs(GRAV)) +const FALL_T = Math.sqrt((2 * MAX_H) / (Math.abs(GRAV) * FALL_GRAV)) + +// Full-height jump with the button held, at full run speed. +export const JUMP_ARC = { + airtime: RISE_T + FALL_T, + maxHeight: MAX_H, + maxGap: SPEED * (RISE_T + FALL_T), +} + +// Hazards are tuned well inside the arc so ordinary, non-frame-perfect jumps clear them. +export const HAZARD_LIMITS = { + crateMaxH: MAX_H * 0.55, + crateMinH: 0.9, + crateW: 1.3, + pitMaxW: SPEED * (RISE_T + FALL_T) * 0.5, + pitMinW: 1.8, + spikeH: 0.55, + spikeMinW: 1.2, + spikeMaxW: 1.8, +} diff --git a/hugo-apps/src/app-space-game/feature.ts b/hugo-apps/src/app-space-game/feature.ts new file mode 100644 index 000000000..2841ddaf6 --- /dev/null +++ b/hugo-apps/src/app-space-game/feature.ts @@ -0,0 +1,141 @@ +/** + * Feature gate for the Kasimir Arcade App-Space game island. + * All checks are best-effort - no function here throws into the UI. + * + * Pattern modelled on hugo-apps/src/ktt/lib/server.ts (isKttEnabled). + * Uses native fetch (Node/browser built-in - no axios or other HTTP libs). + */ + +import type { AppSpaceData } from './types' + +/** + * Synchronously detect WebGL support by creating a throwaway canvas. + * Returns true if either webgl2 or webgl contexts are available. + * Wraps ALL operations in try/catch so any throw -> false. + */ +export function isWebGLAvailable(): boolean { + try { + const canvas = document.createElement('canvas') + return !!(canvas.getContext('webgl2') || canvas.getContext('webgl')) + } catch { + return false + } +} + +/** + * Per-machine disable gate for the Kasimir Arcade game. + * Checks the `nogame` URL parameter and persists the choice to localStorage. + * + * - `nogame=1` or `nogame=true` → disable and persist (return true) + * - `nogame=0` or `nogame=false` → enable and clear persistence (return false) + * - No param → return the persisted state, or false if nothing stored + * - storage is null → check only the param, never throw + * - All storage access wrapped in try/catch; never propagates + * + * @param search - window.location.search (e.g. "?nogame=1") + * @param storage - localStorage-like object or null + * @returns true if the game is disabled, false otherwise + */ +export function isGameDisabled( + search: string, + storage: Pick | null, +): boolean { + const STORAGE_KEY = 'appSpaceGameDisabled' + const STORAGE_VALUE = '1' + + // Parse nogame param case-insensitively + let noGameValue: string | null = null + try { + const params = new URLSearchParams(search) + for (const [k, v] of params) { + if (k.toLowerCase() === 'nogame') { + noGameValue = v + break + } + } + } catch { + // URL parsing failed - proceed with no param + } + + // If param is set, handle it + if (noGameValue !== null) { + const disabled = noGameValue === '1' || noGameValue === 'true' + + if (storage !== null) { + try { + if (disabled) { + storage.setItem(STORAGE_KEY, STORAGE_VALUE) + } else { + storage.removeItem(STORAGE_KEY) + } + } catch { + // Storage access failed - still return the param value + } + } + + return disabled + } + + // No param - check stored state + if (storage === null) { + return false + } + + try { + const stored = storage.getItem(STORAGE_KEY) + return stored === STORAGE_VALUE + } catch { + // Storage access failed - default to enabled (not disabled) + return false + } +} + +/** + * Load App-Space progress data for the current page's event. + * + * Resolves the eventId from the URL search params case-insensitively + * (matching the pattern in hugo-apps/src/app-space/AppSpace.vue lines 194-198). + * Falls back to `/app-space-data.json` if the primary endpoint fails. + * Returns null if both fetches fail. Never throws. + * + * The returned payload now includes `appSpaceGameEnabled` (Boolean) from the + * server's ImsConfig-backed feature flag (APP_SPACE_GAME_ENABLED, default OFF). + * The caller gates the game on `data.appSpaceGameEnabled === true`. + */ +export async function loadAppSpaceData(): Promise { + // Resolve eventId from URL params case-insensitively (same as AppSpace.vue) + let eventId: number | null = null + try { + const params = new URLSearchParams(window.location.search) + for (const [k, v] of params) { + if (k.toLowerCase() === 'eventid') { + eventId = Number(v) + break + } + } + } catch { + // URL parsing failed - proceed with no eventId + } + + const param = eventId ? `eventLegacyId=${eventId}` : `eventLegacyId=0` + + // Primary: fetch from CAP + try { + const res = await fetch(`/api/getAppSpaceProgress(${param})`) + if (res.ok) { + return (await res.json()) as AppSpaceData + } + } catch { + // Primary failed - fall through to fallback + } + + // Fallback: static JSON file + try { + const res = await fetch('/app-space-data.json') + if (res.ok) return (await res.json()) as AppSpaceData + } catch { + // Fallback also failed + } + + return null +} diff --git a/hugo-apps/src/app-space-game/fx.ts b/hugo-apps/src/app-space-game/fx.ts new file mode 100644 index 000000000..2d7b54101 --- /dev/null +++ b/hugo-apps/src/app-space-game/fx.ts @@ -0,0 +1,158 @@ +// Lightweight pooled particle effects: pickup sparkles, scattered rings, dust, confetti. + +import * as THREE from 'three' +import { sparkleTexture, softDotTexture } from './textures' + +interface Particle { + obj: THREE.Object3D + mat: THREE.Material & { opacity: number } + vx: number + vy: number + vz: number + life: number + max: number + gravity: number + spin: number + bounce: boolean + grow: number + active: boolean +} + +const CONFETTI = [0xff5a5f, 0xffc23a, 0x4cd97b, 0x3fa9ff, 0xb276ff, 0xffffff] + +export class Effects { + private pool: Particle[] = [] + private sparkleTex = sparkleTexture() + private dustTex = softDotTexture('rgba(230,220,200,0.8)', 'rgba(230,220,200,0)') + private ringGeo = new THREE.TorusGeometry(0.32, 0.1, 8, 20) + private confettiGeo = new THREE.PlaneGeometry(0.16, 0.26) + + constructor(private scene: THREE.Scene) {} + + private spawn(kind: 'sprite' | 'ring' | 'confetti', make: () => Particle): Particle { + const free = this.pool.find(p => !p.active && p.obj.userData.kind === kind) + if (free) { + free.active = true + free.obj.visible = true + return free + } + const p = make() + p.obj.userData.kind = kind + this.scene.add(p.obj) + this.pool.push(p) + return p + } + + private sprite(tex: THREE.Texture, additive: boolean): Particle { + const p = this.spawn('sprite', () => { + const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false }) + return this.blank(new THREE.Sprite(mat), mat) + }) + const m = p.mat as THREE.SpriteMaterial + const blending = additive ? THREE.AdditiveBlending : THREE.NormalBlending + if (m.map !== tex || m.blending !== blending) { + m.map = tex + m.blending = blending + m.needsUpdate = true + } + return p + } + + private blank(obj: THREE.Object3D, mat: THREE.Material & { opacity: number }): Particle { + return { obj, mat, vx: 0, vy: 0, vz: 0, life: 0, max: 1, gravity: 0, spin: 0, bounce: false, grow: 0, active: true } + } + + sparkle(x: number, y: number): void { + for (let i = 0; i < 10; i++) { + const a = (i / 10) * Math.PI * 2 + const p = this.sprite(this.sparkleTex, true) + p.obj.position.set(x, y, 0.4) + const s = 0.5 + Math.random() * 0.4 + p.obj.scale.set(s, s, 1) + Object.assign(p, { vx: Math.cos(a) * 4, vy: Math.sin(a) * 4 + 1, vz: 0, life: 0, max: 0.45, gravity: -6, spin: 0, bounce: false, grow: -1 }) + p.mat.opacity = 1 + } + } + + dust(x: number, y: number, dir: number): void { + for (let i = 0; i < 6; i++) { + const p = this.sprite(this.dustTex, false) + p.obj.position.set(x + (Math.random() - 0.5) * 0.6, y + 0.1, 0.5) + p.obj.scale.set(0.5, 0.5, 1) + Object.assign(p, { vx: (Math.random() - 0.5) * 3 - dir * 1.2, vy: 0.6 + Math.random(), vz: 0, life: 0, max: 0.5, gravity: 0, spin: 0, bounce: false, grow: 1.6 }) + p.mat.opacity = 0.7 + } + } + + scatterRings(x: number, y: number, count: number): void { + const n = Math.min(count, 16) + for (let i = 0; i < n; i++) { + const p = this.spawn('ring', () => { + const mat = new THREE.MeshStandardMaterial({ color: 0xffc23a, metalness: 0.9, roughness: 0.2, emissive: 0x8a5a00, emissiveIntensity: 0.6, transparent: true }) + return this.blank(new THREE.Mesh(this.ringGeo, mat), mat) + }) + const a = Math.PI * (0.15 + 0.7 * (i / Math.max(1, n - 1))) + p.obj.position.set(x, y + 1, 0.3) + Object.assign(p, { vx: Math.cos(a) * 6, vy: Math.sin(a) * 8, vz: (Math.random() - 0.5) * 2, life: 0, max: 1.3, gravity: -22, spin: 8, bounce: true, grow: 0 }) + p.obj.scale.setScalar(1) + p.mat.opacity = 1 + } + } + + confetti(x: number, y: number): void { + for (let i = 0; i < 60; i++) { + const p = this.spawn('confetti', () => { + const mat = new THREE.MeshBasicMaterial({ color: 0xffffff, side: THREE.DoubleSide, transparent: true }) + return this.blank(new THREE.Mesh(this.confettiGeo, mat), mat) + }) + ;(p.mat as THREE.MeshBasicMaterial).color.setHex(CONFETTI[i % CONFETTI.length]) + p.obj.position.set(x + (Math.random() - 0.5) * 1.5, y, 0.6) + p.obj.rotation.set(Math.random() * 3, Math.random() * 3, 0) + Object.assign(p, { + vx: (Math.random() - 0.5) * 9, vy: 7 + Math.random() * 7, vz: (Math.random() - 0.5) * 3, + life: 0, max: 2.2, gravity: -12, spin: 6 + Math.random() * 6, bounce: false, grow: 0, + }) + p.mat.opacity = 1 + } + } + + update(dt: number): void { + for (const p of this.pool) { + if (!p.active) continue + p.life += dt + if (p.life >= p.max) { + p.active = false + p.obj.visible = false + continue + } + p.vy += p.gravity * dt + p.obj.position.x += p.vx * dt + p.obj.position.y += p.vy * dt + p.obj.position.z += p.vz * dt + if (p.bounce && p.obj.position.y < 0.35 && p.vy < 0) { + p.obj.position.y = 0.35 + p.vy *= -0.55 + p.vx *= 0.8 + } + if (p.spin) { + p.obj.rotation.x += p.spin * dt + p.obj.rotation.y += p.spin * 0.7 * dt + } + if (p.grow) p.obj.scale.multiplyScalar(1 + p.grow * dt) + const k = p.life / p.max + p.mat.opacity = k < 0.6 ? p.mat.opacity : Math.max(0, (1 - k) / 0.4) + } + } + + dispose(): void { + for (const p of this.pool) { + this.scene.remove(p.obj) + p.mat.dispose() + } + this.pool = [] + this.sparkleTex.dispose() + this.dustTex.dispose() + this.ringGeo.dispose() + this.confettiGeo.dispose() + } +} diff --git a/hugo-apps/src/app-space-game/kasimir-frames.json b/hugo-apps/src/app-space-game/kasimir-frames.json new file mode 100644 index 000000000..6e34bede3 --- /dev/null +++ b/hugo-apps/src/app-space-game/kasimir-frames.json @@ -0,0 +1,452 @@ +{ + "image": "/images/app-space-game/kasimir-atlas.png", + "atlas": { + "w": 2044, + "h": 1944, + "cols": 7, + "rows": 9 + }, + "cell": { + "w": 292, + "h": 216 + }, + "anchor": { + "x": 146, + "y": 212 + }, + "charH": 172, + "clips": { + "idle": { + "frames": [ + { + "x": 0, + "y": 0, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 0, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 0, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 0, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 0, + "w": 292, + "h": 216 + } + ], + "fps": 6, + "loop": true + }, + "walk": { + "frames": [ + { + "x": 1460, + "y": 0, + "w": 292, + "h": 216 + }, + { + "x": 1752, + "y": 0, + "w": 292, + "h": 216 + }, + { + "x": 0, + "y": 216, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 216, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 216, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 216, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 216, + "w": 292, + "h": 216 + }, + { + "x": 1460, + "y": 216, + "w": 292, + "h": 216 + }, + { + "x": 1752, + "y": 216, + "w": 292, + "h": 216 + } + ], + "fps": 12, + "loop": true + }, + "trot": { + "frames": [ + { + "x": 0, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 1460, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 1752, + "y": 432, + "w": 292, + "h": 216 + }, + { + "x": 0, + "y": 648, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 648, + "w": 292, + "h": 216 + } + ], + "fps": 14, + "loop": true + }, + "jump": { + "frames": [ + { + "x": 584, + "y": 648, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 648, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 648, + "w": 292, + "h": 216 + }, + { + "x": 1460, + "y": 648, + "w": 292, + "h": 216 + }, + { + "x": 1752, + "y": 648, + "w": 292, + "h": 216 + }, + { + "x": 0, + "y": 864, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 864, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 864, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 864, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 864, + "w": 292, + "h": 216 + } + ], + "fps": 12, + "loop": false + }, + "run": { + "frames": [ + { + "x": 1460, + "y": 864, + "w": 292, + "h": 216 + }, + { + "x": 1752, + "y": 864, + "w": 292, + "h": 216 + }, + { + "x": 0, + "y": 1080, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 1080, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 1080, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 1080, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 1080, + "w": 292, + "h": 216 + }, + { + "x": 1460, + "y": 1080, + "w": 292, + "h": 216 + } + ], + "fps": 14, + "loop": true + }, + "taunt": { + "frames": [ + { + "x": 1752, + "y": 1080, + "w": 292, + "h": 216 + }, + { + "x": 0, + "y": 1296, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 1296, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 1296, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 1296, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 1296, + "w": 292, + "h": 216 + } + ], + "fps": 10, + "loop": false + }, + "hit": { + "frames": [ + { + "x": 1460, + "y": 1296, + "w": 292, + "h": 216 + }, + { + "x": 1752, + "y": 1296, + "w": 292, + "h": 216 + } + ], + "fps": 4, + "loop": false + }, + "fall": { + "frames": [ + { + "x": 0, + "y": 1512, + "w": 292, + "h": 216 + } + ], + "fps": 1, + "loop": false + }, + "reach": { + "frames": [ + { + "x": 292, + "y": 1512, + "w": 292, + "h": 216 + }, + { + "x": 584, + "y": 1512, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 1512, + "w": 292, + "h": 216 + }, + { + "x": 1168, + "y": 1512, + "w": 292, + "h": 216 + }, + { + "x": 1460, + "y": 1512, + "w": 292, + "h": 216 + } + ], + "fps": 8, + "loop": true + }, + "ko": { + "frames": [ + { + "x": 1752, + "y": 1512, + "w": 292, + "h": 216 + } + ], + "fps": 1, + "loop": false + }, + "chest": { + "frames": [ + { + "x": 0, + "y": 1728, + "w": 292, + "h": 216 + }, + { + "x": 292, + "y": 1728, + "w": 292, + "h": 216 + } + ], + "fps": 4, + "loop": true + }, + "celebrate": { + "frames": [ + { + "x": 584, + "y": 1728, + "w": 292, + "h": 216 + }, + { + "x": 876, + "y": 1728, + "w": 292, + "h": 216 + } + ], + "fps": 5, + "loop": true + } + } +} diff --git a/hugo-apps/src/app-space-game/label-layer.ts b/hugo-apps/src/app-space-game/label-layer.ts new file mode 100644 index 000000000..610b09d4e --- /dev/null +++ b/hugo-apps/src/app-space-game/label-layer.ts @@ -0,0 +1,151 @@ +// DOM overlay for readable labels. World anchors are projected to screen each frame; +// text is real HTML (crisp at any DPR, wraps with CSS) instead of canvas sprites. + +import * as THREE from 'three' +import type { WorldLayout } from './layout' +import { zoneColor } from './level-model' +import { labelModes, stackRects, tidyTitle } from './labels' +import type { LabelMode } from './labels' +import { KASIMIR_H } from './constants' + +const COIN_LABEL_Y = 3.4 // above Kasimir's head so titles never cover him +const GOAL_LABEL_Y = { track: 6.1, chest: 3.9 } +const TOP_SAFE_PX = 76 // keep clear of the HUD row (zone banner, rings, close) +const EDGE_PX = 8 + +interface Item { + el: HTMLDivElement + x: number + y: number + mode: LabelMode | 'goal' + w: number + h: number +} + +function el(cls: string, text?: string): HTMLDivElement { + const d = document.createElement('div') + d.className = cls + if (text !== undefined) d.textContent = text + return d +} + +export class LabelLayer { + private root: HTMLDivElement + private coins: Item[] = [] + private goals: Item[] = [] + private v = new THREE.Vector3() + + constructor(host: HTMLElement, private layout: WorldLayout) { + this.root = el('asg-labels') + this.root.setAttribute('aria-hidden', 'true') + host.appendChild(this.root) + + layout.coins.forEach(c => { + const item = el('asg-label is-hidden') + if (!c.available) item.classList.add('is-locked') + else if (c.done) item.classList.add('is-done') + const badge = el('asg-label__badge', c.done ? '✓' : String(c.index + 1)) + const title = el('asg-label__title', tidyTitle(c.title)) + item.append(badge, title) + this.root.appendChild(item) + this.coins.push({ el: item, x: c.x, y: COIN_LABEL_Y, mode: 'hidden', w: 0, h: 0 }) + }) + + layout.zones.forEach((z, i) => { + const g = z.goal + const item = el('asg-goal is-hidden') + item.style.setProperty('--asg-accent', zoneColor(z.colorIndex)) + const icon = g.locked ? '🔒' : g.kind === 'track' ? '🏁' : '🎁' + const head = el('asg-goal__head', `${icon} ${i + 1}. ${tidyTitle(z.title, 60)}`) + const sub = el( + 'asg-goal__sub', + g.locked ? 'Locked — complete the prerequisites first' : g.kind === 'track' ? 'Open this track' : tidyTitle(g.label, 60), + ) + if (g.locked) item.classList.add('is-locked') + item.append(head, sub) + this.root.appendChild(item) + this.goals.push({ el: item, x: g.x, y: g.kind === 'track' ? GOAL_LABEL_Y.track : GOAL_LABEL_Y.chest, mode: 'hidden', w: 0, h: 0 }) + }) + } + + private toScreen(x: number, y: number, camera: THREE.Camera, w: number, h: number): { sx: number; sy: number; front: boolean } { + this.v.set(x, y, 0).project(camera) + return { sx: (this.v.x * 0.5 + 0.5) * w, sy: (-this.v.y * 0.5 + 0.5) * h, front: this.v.z < 1 } + } + + private setMode(it: Item, mode: Item['mode']): void { + if (it.mode === mode) return + it.mode = mode + it.el.classList.toggle('is-hidden', mode === 'hidden') + it.el.classList.toggle('is-full', mode === 'full' || mode === 'goal') + it.el.classList.toggle('is-badge', mode === 'badge') + it.w = it.el.offsetWidth + it.h = it.el.offsetHeight + } + + update(camera: THREE.PerspectiveCamera, camX: number, kasX: number, kasY: number, width: number, height: number): void { + const viewHalfW = Math.tan((camera.fov * Math.PI) / 360) * camera.position.z * camera.aspect + 2 + const modes = labelModes(this.coins.map(c => c.x), kasX, camX, viewHalfW) + const visible: Array<{ it: Item; sx: number; sy: number }> = [] + // Keep cards fully on screen and below the HUD row before resolving overlaps. + const push = (it: Item) => { + const p = this.toScreen(it.x, it.y, camera, width, height) + const halfW = it.w / 2 + visible.push({ + it, + sx: Math.min(width - halfW - EDGE_PX, Math.max(halfW + EDGE_PX, p.sx)), + sy: Math.max(it.h + TOP_SAFE_PX, p.sy), + }) + } + this.coins.forEach((it, i) => { + this.setMode(it, modes[i]) + if (modes[i] !== 'hidden') push(it) + }) + for (const it of this.goals) { + const show = Math.abs(it.x - camX) <= viewHalfW + this.setMode(it, show ? 'goal' : 'hidden') + if (show) push(it) + } + // Only full titles and goal cards are big enough to collide; badges stay on their coin. + // Place goal cards first, then coin titles nearest Kasimir. A coin title that would have + // to climb into the HUD row falls back to its badge instead of overlapping. + const prio = (v: { it: Item }) => (v.it.mode === 'goal' ? -1 : Math.abs(v.it.x - kasX)) + const rect = (v: { it: Item; sx: number; sy: number }) => ({ x: v.sx, y: v.sy, w: v.it.w || 160, h: v.it.h || 40 }) + let big = visible.filter(v => v.it.mode === 'full' || v.it.mode === 'goal').sort((a, b) => prio(a) - prio(b)) + let lift = stackRects(big.map(rect)) + const demoted = big.filter((v, i) => v.it.mode === 'full' && v.sy - lift[i] - v.it.h < TOP_SAFE_PX) + if (demoted.length) { + for (const v of demoted) this.setMode(v.it, 'badge') + big = big.filter(v => !demoted.includes(v)) + lift = stackRects(big.map(rect)) + } + const liftOf = new Map(big.map((v, i) => [v.it, lift[i]])) + // Cards that cover Kasimir (e.g. mid-jump) fade so he always stays visible. + const a = this.toScreen(kasX - 0.9, kasY, camera, width, height) + const b = this.toScreen(kasX + 0.9, kasY + KASIMIR_H, camera, width, height) + const kas = { l: a.sx, r: b.sx, t: b.sy, btm: a.sy } + for (const v of visible) { + const dy = liftOf.get(v.it) ?? 0 + const y = v.sy - dy + const covers = v.sx - v.it.w / 2 < kas.r && v.sx + v.it.w / 2 > kas.l && y - v.it.h < kas.btm && y > kas.t + v.it.el.classList.toggle('is-dim', covers) + v.it.el.style.transform = `translate3d(${v.sx.toFixed(1)}px, ${y.toFixed(1)}px, 0) translate(-50%, -100%)` + } + } + + /** Short-lived text that rises and fades at a world position (e.g. "+1"). */ + floater(x: number, y: number, text: string, kind: 'gain' | 'loss', camera: THREE.Camera, width: number, height: number): void { + const p = this.toScreen(x, y, camera, width, height) + const f = el(`asg-floater asg-floater--${kind}`, text) + f.style.left = `${p.sx}px` + f.style.top = `${p.sy}px` + this.root.appendChild(f) + setTimeout(() => f.remove(), 950) + } + + dispose(): void { + this.root.remove() + this.coins = [] + this.goals = [] + } +} diff --git a/hugo-apps/src/app-space-game/labels.ts b/hugo-apps/src/app-space-game/labels.ts new file mode 100644 index 000000000..6d2577679 --- /dev/null +++ b/hugo-apps/src/app-space-game/labels.ts @@ -0,0 +1,71 @@ +// Pure label helpers: which coin titles are shown in full, and vertical stacking so +// visible labels never overlap. No DOM — the LabelLayer applies the results. + +export type LabelMode = 'full' | 'badge' | 'hidden' + +export const FULL_RADIUS = 8 // world units around Kasimir where titles are shown in full +export const MAX_FULL = 2 // at most this many full coin titles at once + +/** + * Coins within `viewHalfW` of the camera are visible. The `maxFull` coins nearest to + * Kasimir (and inside FULL_RADIUS) show their full title; the rest show a compact badge. + */ +export function labelModes( + coinXs: number[], + kasX: number, + camX: number, + viewHalfW: number, + opts: { fullRadius?: number; maxFull?: number } = {}, +): LabelMode[] { + const fullRadius = opts.fullRadius ?? FULL_RADIUS + const maxFull = opts.maxFull ?? MAX_FULL + const modes: LabelMode[] = coinXs.map(x => (Math.abs(x - camX) <= viewHalfW ? 'badge' : 'hidden')) + const near = coinXs + .map((x, i) => ({ i, d: Math.abs(x - kasX) })) + .filter(c => c.d <= fullRadius && modes[c.i] !== 'hidden') + .sort((a, b) => a.d - b.d) + .slice(0, maxFull) + for (const c of near) modes[c.i] = 'full' + return modes +} + +export interface LabelRect { x: number; y: number; w: number; h: number } // x = centre, y = bottom (screen px) + +/** + * Returns upward offsets (px) so no two rects overlap. Rects are placed in array order + * (callers pass highest priority first); each is lifted just above any earlier rect it + * would collide with. + */ +export function stackRects(rects: LabelRect[], gap = 6): number[] { + const offsets = new Array(rects.length).fill(0) + const placed: Array<{ l: number; r: number; top: number; bottom: number }> = [] + rects.forEach((r, i) => { + const l = r.x - r.w / 2 + const rr = r.x + r.w / 2 + let bottom = r.y + let moved = true + while (moved) { + moved = false + for (const p of placed) { + const overlapX = l < p.r + gap && rr > p.l - gap + const overlapY = bottom > p.top - gap && bottom - r.h < p.bottom + gap + if (overlapX && overlapY) { + bottom = p.top - gap + moved = true + } + } + } + offsets[i] = r.y - bottom + placed.push({ l, r: rr, top: bottom - r.h, bottom }) + }) + return offsets +} + +/** Trim very long titles at a word boundary; CSS line-clamp handles the rest. */ +export function tidyTitle(title: string, max = 90): string { + const t = (title ?? '').replace(/\s+/g, ' ').trim() + if (t.length <= max) return t + const cut = t.slice(0, max) + const sp = cut.lastIndexOf(' ') + return (sp > max * 0.6 ? cut.slice(0, sp) : cut).replace(/[\s,;:–-]+$/, '') + '…' +} diff --git a/hugo-apps/src/app-space-game/layout.ts b/hugo-apps/src/app-space-game/layout.ts new file mode 100644 index 000000000..c5a5c2f94 --- /dev/null +++ b/hugo-apps/src/app-space-game/layout.ts @@ -0,0 +1,143 @@ +// Pure world layout: converts the LevelModel into world-space positions shared by +// physics, autopilot, labels and the renderer. No Three.js, no DOM. + +import type { LevelModel, Goal } from './types' +import { COIN_SPACING } from './obstacles' + +export const ZONE_LEAD = 6 // ground before a zone's first coin (zone sign lives here) +export const GOAL_TAIL = 6 // ground after a zone's goal before the next zone starts +export const COIN_Y = 1.3 // coin centre height — collected by walking past +export const LEVEL_PAD = 4 // solid ground before the first and after the last zone +export const GOAL_REACH = 1.8 // |kasimir.x − goal.x| within which the goal can be opened + +export interface Box { x0: number; x1: number; top: number } +export interface Span { x0: number; x1: number } + +export interface WorldHazards { + crates: Box[] + spikes: Box[] + pits: Span[] + minX: number + maxX: number +} + +export interface WorldCoin { + x: number + y: number + title: string + done: boolean + available: boolean + zoneIndex: number + index: number // 0-based within the zone +} + +export interface WorldGoal extends Goal { + x: number + zoneId: string + zoneTitle: string + zoneIndex: number + locked: boolean +} + +export interface WorldZone { + id: string + title: string + colorIndex: number + locked: boolean + cleared: boolean + x0: number + x1: number + coinCount: number + goal: WorldGoal +} + +export interface WorldLayout { + zones: WorldZone[] + coins: WorldCoin[] + gates: { x: number; zoneIndex: number }[] + hazards: WorldHazards + spawnX: number + length: number +} + +export function buildWorldLayout(model: LevelModel): WorldLayout { + const zones: WorldZone[] = [] + const coins: WorldCoin[] = [] + const gates: WorldLayout['gates'] = [] + const crates: Box[] = [] + const spikes: Box[] = [] + const pits: Span[] = [] + let cursor = LEVEL_PAD + + model.zones.forEach((zone, zi) => { + const x0 = cursor + const firstCoin = x0 + ZONE_LEAD + zone.coins.forEach((coin, k) => { + coins.push({ + x: firstCoin + k * COIN_SPACING, + y: COIN_Y, + title: coin.title, + done: coin.done, + available: coin.available, + zoneIndex: zi, + index: k, + }) + }) + for (const g of zone.gates) { + gates.push({ x: firstCoin + (g.afterCoinIndex + 0.5) * COIN_SPACING, zoneIndex: zi }) + } + for (const o of zone.obstacles) { + const cx = firstCoin + o.x * COIN_SPACING + const box = { x0: cx - o.w / 2, x1: cx + o.w / 2, top: o.h } + if (o.kind === 'spike') spikes.push(box) + else crates.push(box) + } + for (const p of zone.pits) { + const cx = firstCoin + p.x * COIN_SPACING + pits.push({ x0: cx - p.w / 2, x1: cx + p.w / 2 }) + } + const lastCoinX = zone.coins.length ? firstCoin + (zone.coins.length - 1) * COIN_SPACING : x0 + 2 + const goalX = lastCoinX + COIN_SPACING + const x1 = goalX + GOAL_TAIL + zones.push({ + id: zone.id, + title: zone.title, + colorIndex: zone.colorIndex, + locked: zone.locked, + cleared: zone.cleared, + x0, + x1, + coinCount: zone.coins.length, + goal: { ...zone.goal, x: goalX, zoneId: zone.id, zoneTitle: zone.title, zoneIndex: zi, locked: zone.locked }, + }) + cursor = x1 + }) + + const length = cursor + LEVEL_PAD + return { + zones, + coins, + gates, + hazards: { crates, spikes, pits, minX: 0, maxX: length }, + spawnX: 2, + length, + } +} + +/** Index of the zone containing world x (clamped to first/last). */ +export function zoneIndexAt(layout: WorldLayout, x: number): number { + const zs = layout.zones + if (!zs.length) return -1 + for (let i = 0; i < zs.length; i++) { + if (x < zs[i].x1) return i + } + return zs.length - 1 +} + +/** The goal Kasimir is standing at, if any. */ +export function goalAt(layout: WorldLayout, x: number): WorldGoal | null { + for (const z of layout.zones) { + if (Math.abs(x - z.goal.x) <= GOAL_REACH) return z.goal + } + return null +} diff --git a/hugo-apps/src/app-space-game/level-model.ts b/hugo-apps/src/app-space-game/level-model.ts new file mode 100644 index 000000000..4edaeb4ab --- /dev/null +++ b/hugo-apps/src/app-space-game/level-model.ts @@ -0,0 +1,189 @@ +import type { AppSpaceData, AppSpaceSegment, AppSpaceItem } from './types' +import type { LevelModel, Zone, Coin, Gate, Goal } from './types' +import { placeHazards } from './obstacles' + +// Palette of 11 distinct hex colors (SAP Horizon-inspired, on-brand-ish) +const ZONE_COLOR_PALETTE = [ + '#0B5394', // SAP Blue + '#1F70C1', // Bright Blue + '#0F5133', // Dark Green + '#107C10', // Accent Green + '#6E3932', // Warm Brown + '#A4373A', // Red Accent + '#6B3FA0', // Purple + '#004B87', // Navy + '#C85E1E', // Orange + '#5E4C3C', // Taupe + '#2D3B45', // Slate +] + +export function zoneColor(colorIndex: number): string { + const index = colorIndex % ZONE_COLOR_PALETTE.length + return ZONE_COLOR_PALETTE[index] +} + +function flattenSegments(segments: AppSpaceSegment[]): { + items: AppSpaceItem[] + checkpointIndices: Map +} { + const items: AppSpaceItem[] = [] + const checkpointIndices = new Map() + + segments.forEach(segment => { + if (segment.kind === 'linear' || segment.kind === 'parallel-any') { + segment.items.forEach(item => { + if (item.type === 'TUTORIAL') { + items.push(item) + } else if (item.type === 'CHECKPOINT') { + checkpointIndices.set(items.length - 1, item) + } else if (item.type === 'PRIZE') { + items.push(item) + } + }) + } else if (segment.kind === 'fork') { + // Process lanes in order, then items within each lane + segment.lanes.forEach(lane => { + lane.items.forEach(item => { + if (item.type === 'TUTORIAL') { + items.push(item) + } else if (item.type === 'CHECKPOINT') { + checkpointIndices.set(items.length - 1, item) + } else if (item.type === 'PRIZE') { + items.push(item) + } + }) + }) + } + }) + + return { items, checkpointIndices } +} + +function buildCoinsAndGates( + items: AppSpaceItem[], + checkpointIndices: Map, + isAuthenticated: boolean, + isLocked: boolean +): { coins: Coin[]; gates: Gate[] } { + const coins: Coin[] = [] + const gates: Gate[] = [] + // Coins are tutorials only; a PRIZE is the zone's goal (the chest), not a ring to collect. + // Track each item's position in the coin list so checkpoint gates stay aligned. + const coinIndexOfItem: number[] = [] + + items.forEach(item => { + if (item.type === 'TUTORIAL') { + coins.push({ + title: item.title, + done: isAuthenticated && item.status === 'COMPLETED', + available: !isLocked, + }) + } + coinIndexOfItem.push(coins.length - 1) + }) + + // Process checkpoints: if they have prizeId, they become goals (handled elsewhere) + // Otherwise they become gates + checkpointIndices.forEach((checkpointItem, itemIndex) => { + if (!checkpointItem.prizeId) { + gates.push({ + afterCoinIndex: itemIndex >= 0 ? coinIndexOfItem[itemIndex] : -1, + }) + } + }) + + return { coins, gates } +} + +function findGoal( + items: AppSpaceItem[], + checkpointIndices: Map, + trackId: string +): Goal { + // First, look for a literal PRIZE item + const prizeItem = items.find(item => item.type === 'PRIZE') + if (prizeItem) { + return { + kind: 'prize', + label: prizeItem.title, + prizeId: prizeItem.prizeId ?? null, + trackId, + } + } + + // Next, look for a CHECKPOINT with prizeId + let checkpointWithPrize: AppSpaceItem | undefined + checkpointIndices.forEach(checkpointItem => { + if (checkpointItem.prizeId) { + checkpointWithPrize = checkpointItem + } + }) + + if (checkpointWithPrize) { + return { + kind: 'checkpoint', + label: checkpointWithPrize.title, + prizeId: checkpointWithPrize.prizeId ?? null, + trackId, + } + } + + // Default: track goal + return { + kind: 'track', + label: 'Complete Track', + prizeId: null, + trackId, + } +} + +export function buildLevelModel(data: AppSpaceData): LevelModel { + const zones: Zone[] = [] + const isAuthenticated = data.authenticated ?? false + + data.paths.forEach((track, trackIndex) => { + const segments = track.segments ?? [] + + if (segments.length === 0) { + return // Skip tracks with no segments + } + + const { items, checkpointIndices } = flattenSegments(segments) + + // Determine if zone is locked (any item is unlocked:false) + const isLocked = items.some(item => item.unlocked === false) + + // Filter out PRIZE items from coin list, keep only TUTORIAL + const tutorialItems = items.filter(item => item.type === 'TUTORIAL') + + // Build coins and gates + const { coins, gates } = buildCoinsAndGates(items, checkpointIndices, isAuthenticated, isLocked) + + // Determine if zone is cleared: at least 1 tutorial AND all tutorials COMPLETED + const allTutorialsCompleted = + isAuthenticated && tutorialItems.length > 0 && tutorialItems.every(item => item.status === 'COMPLETED') + const cleared = allTutorialsCompleted + + // Build goal + const goal = findGoal(items, checkpointIndices, track.id) + + const { obstacles, pits } = placeHazards(coins.length, trackIndex, gates.map(g => g.afterCoinIndex)) + + const zone: Zone = { + id: track.id, + title: track.title, + colorIndex: trackIndex, + cleared, + locked: isLocked, + coins, + gates, + goal, + obstacles, + pits, + } + + zones.push(zone) + }) + + return { zones } +} diff --git a/hugo-apps/src/app-space-game/main.ts b/hugo-apps/src/app-space-game/main.ts new file mode 100644 index 000000000..b7ecd66b6 --- /dev/null +++ b/hugo-apps/src/app-space-game/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import AppSpaceGame from './AppSpaceGame.vue' + +const el = document.getElementById('app-space-game') +if (el) createApp(AppSpaceGame).mount(el) diff --git a/hugo-apps/src/app-space-game/obstacles.ts b/hugo-apps/src/app-space-game/obstacles.ts new file mode 100644 index 000000000..aee6d18f8 --- /dev/null +++ b/hugo-apps/src/app-space-game/obstacles.ts @@ -0,0 +1,79 @@ +import type { Obstacle, Pit } from './types' +import { HAZARD_LIMITS } from './constants' + +export const COIN_SPACING = 6 // 1 coin-index unit = 6 world units +export const MIN_LANDING = 2.5 // world units — minimum clear ground between hazards + +/** Mulberry32 seeded PRNG. Returns a function that produces values in [0, 1). */ +function mulberry32(seed: number): () => number { + let s = seed >>> 0 + return function () { + s |= 0 + s = (s + 0x6d2b79f5) | 0 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** + * Place hazards deterministically for one zone. + * - Hazards sit midway between coins (x = i + 0.5, coin-index units) so they never overlap a coin. + * - The first gap of every zone is kept clear (breathing room after the zone banner), as are + * gaps occupied by checkpoint gates (`blockedGaps` = coin indices a gate follows). + * - At most ceil(coinCount / 2.5) hazards; every hazard is comfortably clearable (see HAZARD_LIMITS). + * - Mix: pits, crates and spikes, roughly 40/35/25. + */ +export function placeHazards( + coinCount: number, + zoneSeed: number, + blockedGaps: number[] = [], +): { obstacles: Obstacle[]; pits: Pit[] } { + if (coinCount <= 2) return { obstacles: [], pits: [] } + + const rand = mulberry32(zoneSeed * 7919 + 17) + const maxHazards = Math.ceil(coinCount / 2.5) + const blocked = new Set(blockedGaps) + + const candidates: number[] = [] + for (let i = 1; i < coinCount - 1; i++) { + if (!blocked.has(i)) candidates.push(i + 0.5) + } + for (let i = candidates.length - 1; i > 0; i--) { + const j = Math.floor(rand() * (i + 1)) + ;[candidates[i], candidates[j]] = [candidates[j], candidates[i]] + } + + const obstacles: Obstacle[] = [] + const pits: Pit[] = [] + const placed: Array<{ left: number; right: number }> = [] + const L = HAZARD_LIMITS + + for (const cx of candidates) { + if (obstacles.length + pits.length >= maxHazards) break + const r = rand() + const kind: 'pit' | 'crate' | 'spike' = r < 0.4 ? 'pit' : r < 0.75 ? 'crate' : 'spike' + const w = + kind === 'pit' ? L.pitMinW + rand() * (L.pitMaxW - L.pitMinW) + : kind === 'crate' ? L.crateW + : L.spikeMinW + rand() * (L.spikeMaxW - L.spikeMinW) + const worldX = cx * COIN_SPACING + const left = worldX - w / 2 + const right = worldX + w / 2 + if (placed.some(e => Math.max(left - e.right, e.left - right) < MIN_LANDING)) continue + + if (kind === 'pit') { + pits.push({ x: cx, w }) + } else if (kind === 'crate') { + const h = L.crateMinH + rand() * (L.crateMaxH - L.crateMinH) + obstacles.push({ x: cx, w, h, kind: 'crate' }) + } else { + obstacles.push({ x: cx, w, h: L.spikeH, kind: 'spike' }) + } + placed.push({ left, right }) + } + + obstacles.sort((a, b) => a.x - b.x) + pits.sort((a, b) => a.x - b.x) + return { obstacles, pits } +} diff --git a/hugo-apps/src/app-space-game/physics.ts b/hugo-apps/src/app-space-game/physics.ts new file mode 100644 index 000000000..ad116d690 --- /dev/null +++ b/hugo-apps/src/app-space-game/physics.ts @@ -0,0 +1,196 @@ +// Pure platformer physics for Kasimir. y is the FEET position; ground top is y = 0. +// Crates are solid (block from the side, standable on top); spikes cost rings; +// pits are gaps in the ground you fall through. + +import { + SPEED, ACCEL, DECEL, TURN_ACCEL, AIR_ACCEL, GRAV, JUMP, JUMP_CUT_GRAV, FALL_GRAV, MAX_FALL, + COYOTE_S, JUMP_BUFFER_S, INVULN_S, FALL_LIMIT_Y, BODY_HW, +} from './constants' +import type { WorldHazards } from './layout' + +export type { WorldHazards } from './layout' + +export const GROUND_Y = 0 +const EPS = 0.04 +const PIT_FORGIVE = 0.15 // feet may hang this far over a pit edge without falling +const SAFE_MARGIN = 1.2 // respawn points keep this much ground from any hazard + +export interface PhysicsState { + x: number + y: number + vx: number + vy: number + onGround: boolean + facing: 1 | -1 + coyote: number + jumpBuffer: number + jumpHeld: boolean + invuln: number + lastSafeX: number +} + +export interface PhysicsInput { + moveDir: -1 | 0 | 1 + jump: boolean // button currently held +} + +export type PhysicsEvent = 'none' | 'jump' | 'land' | 'hit' | 'fell' + +export function initialState(x: number): PhysicsState { + return { + x, y: GROUND_Y, vx: 0, vy: 0, onGround: true, facing: 1, + coyote: 0, jumpBuffer: 0, jumpHeld: false, invuln: 0, lastSafeX: x, + } +} + +function approach(v: number, target: number, step: number): number { + return v < target ? Math.min(v + step, target) : Math.max(v - step, target) +} + +export function overPit(x: number, pits: WorldHazards['pits']): boolean { + for (const p of pits) { + if (x > p.x0 + PIT_FORGIVE && x < p.x1 - PIT_FORGIVE) return true + } + return false +} + +function isSafe(x: number, h: WorldHazards): boolean { + const near = (x0: number, x1: number) => x > x0 - SAFE_MARGIN && x < x1 + SAFE_MARGIN + for (const p of h.pits) if (near(p.x0, p.x1)) return false + for (const c of h.crates) if (near(c.x0, c.x1)) return false + for (const s of h.spikes) if (near(s.x0, s.x1)) return false + return true +} + +export function stepPhysics( + s: PhysicsState, + input: PhysicsInput, + h: WorldHazards, + dt: number, +): { next: PhysicsState; event: PhysicsEvent } { + let { vx, vy, onGround, facing, coyote, jumpBuffer, invuln, lastSafeX } = s + const { x, y } = s + let event: PhysicsEvent = 'none' + const pressed = input.jump && !s.jumpHeld + + jumpBuffer = pressed ? JUMP_BUFFER_S : Math.max(0, jumpBuffer - dt) + coyote = onGround ? COYOTE_S : Math.max(0, coyote - dt) + invuln = Math.max(0, invuln - dt) + + // --- Horizontal velocity --- + const target = input.moveDir * SPEED + let accel: number + if (!onGround) accel = AIR_ACCEL + else if (input.moveDir === 0) accel = DECEL + else if (vx !== 0 && Math.sign(vx) !== input.moveDir) accel = TURN_ACCEL + else accel = ACCEL + vx = approach(vx, target, accel * dt) + if (input.moveDir !== 0) facing = input.moveDir + + // --- Jump (buffered + coyote) --- + if (jumpBuffer > 0 && (onGround || coyote > 0)) { + vy = JUMP + onGround = false + coyote = 0 + jumpBuffer = 0 + event = 'jump' + } + + // --- Gravity (variable jump height: releasing early cuts the rise) --- + let g = GRAV + if (vy > 0 && !input.jump) g *= JUMP_CUT_GRAV + else if (vy < 0) g *= FALL_GRAV + vy = Math.max(MAX_FALL, vy + g * dt) + + // --- X move + crate side collision --- + let nx = x + vx * dt + for (const c of h.crates) { + if (y >= c.top - EPS) continue // above the crate + if (nx + BODY_HW > c.x0 && nx - BODY_HW < c.x1) { + nx = x <= (c.x0 + c.x1) / 2 ? c.x0 - BODY_HW : c.x1 + BODY_HW + vx = 0 + } + } + // Inside a pit: its walls hold Kasimir in the hole. + if (y < GROUND_Y - EPS) { + for (const p of h.pits) { + if (x >= p.x0 && x <= p.x1) { + const lo = Math.min(p.x0 + BODY_HW, (p.x0 + p.x1) / 2) + const hi = Math.max(p.x1 - BODY_HW, (p.x0 + p.x1) / 2) + if (nx < lo) { nx = lo; vx = 0 } + if (nx > hi) { nx = hi; vx = 0 } + break + } + } + } + // Level walls + if (nx < h.minX + BODY_HW) { nx = h.minX + BODY_HW; vx = 0 } + if (nx > h.maxX - BODY_HW) { nx = h.maxX - BODY_HW; vx = 0 } + + // --- Y move + landing --- + let ny = y + vy * dt + const wasOnGround = s.onGround + onGround = false + let onCrate = false + if (vy <= 0) { + let top = -Infinity + for (const c of h.crates) { + if (nx + BODY_HW > c.x0 + EPS && nx - BODY_HW < c.x1 - EPS && y >= c.top - EPS && ny <= c.top) { + if (c.top > top) { top = c.top; onCrate = true } + } + } + if (y >= GROUND_Y - EPS && ny <= GROUND_Y && !overPit(nx, h.pits) && GROUND_Y > top) { + top = GROUND_Y + onCrate = false + } + if (top > -Infinity) { + ny = top + vy = 0 + onGround = true + if (!wasOnGround && event === 'none') event = 'land' + } + } + + // --- Spikes --- + if (invuln === 0) { + for (const sp of h.spikes) { + if (nx + BODY_HW * 0.7 > sp.x0 && nx - BODY_HW * 0.7 < sp.x1 && ny < sp.top) { + const away = Math.sign(nx - (sp.x0 + sp.x1) / 2) || -facing + vx = away * 5 + vy = 9 + ny = Math.max(ny, sp.top) + onGround = false + invuln = INVULN_S + event = 'hit' + break + } + } + } + + if (ny < FALL_LIMIT_Y) event = 'fell' + + if (onGround && !onCrate && isSafe(nx, h)) lastSafeX = nx + + return { + next: { + x: nx, y: ny, vx, vy, onGround, facing, coyote, jumpBuffer, + jumpHeld: input.jump, invuln, lastSafeX, + }, + event, + } +} + +/** State after a fall: back on the last safe ground, briefly invulnerable. */ +export function respawn(s: PhysicsState): PhysicsState { + return { + ...s, + x: s.lastSafeX, + y: GROUND_Y, + vx: 0, + vy: 0, + onGround: true, + coyote: 0, + jumpBuffer: 0, + invuln: INVULN_S, + } +} diff --git a/hugo-apps/src/app-space-game/renderer.ts b/hugo-apps/src/app-space-game/renderer.ts new file mode 100644 index 000000000..5ad3e7751 --- /dev/null +++ b/hugo-apps/src/app-space-game/renderer.ts @@ -0,0 +1,672 @@ +// Three.js renderer + game loop for the Kasimir Arcade island. +// Fixed-timestep physics with render interpolation, a damped look-ahead camera, +// an atlas-animated Kasimir, DOM labels, particle feedback and goal interaction. + +import * as THREE from 'three' +import type { LevelModel, Goal } from './types' +import { zoneColor } from './level-model' +import frameMapJson from './kasimir-frames.json' +import { BODY_HW, BODY_H, FIXED_DT, KASIMIR_H } from './constants' +import { buildWorldLayout, goalAt, zoneIndexAt, LEVEL_PAD } from './layout' +import type { WorldGoal } from './layout' +import { initialState, stepPhysics, respawn, overPit } from './physics' +import type { PhysicsState, PhysicsInput } from './physics' +import { autopilotInput, watchdog } from './autopilot' +import type { ProgressTrack } from './autopilot' +import { selectClip, frameAt, jumpFrame, playbackRate } from './anim' +import type { ClipName, AnimContext, AnimEvent } from './anim' +import { reset as ringsReset, collect as ringsCollect, onHit as ringsOnHit } from './rings' +import type { RingState } from './rings' +import { buildScene } from './scene' +import { Effects } from './fx' +import { LabelLayer } from './label-layer' +import { softDotTexture } from './textures' + +const KASIMIR_FALLBACK_TEX = '/images/devtoberfest/selfie/thumbnails/Kasimir_Smiles.png' +const CAM_BASE_Y = 4.2 +const CAM_LOOK_DROP = 1.0 +const CAM_DIST = 15 +const RESPAWN_S = 0.9 +const ATTRACT_PAUSE_S = 1.8 +const CELEBRATE_S = 1.4 +const WRAP_S = 0.8 + +export type GameEvent = 'hit' | 'pickup' | 'goal' | 'jump' | 'fall' + +export interface ZoneInfo { + index: number + total: number + title: string + color: string + locked: boolean +} + +export interface GoalPrompt { + trackId: string + title: string + label: string + kind: Goal['kind'] + locked: boolean +} + +export interface GameHandle { + start(): void + stop(): void + setPlayerControl(on: boolean): void + onRingCount(cb: (n: number) => void): void + onEvent(cb: (e: GameEvent) => void): void + onZone?(cb: (z: ZoneInfo | null) => void): void + onGoalPrompt?(cb: (g: GoalPrompt | null) => void): void + /** Open the goal Kasimir is standing at (player mode). Returns true if it opened. */ + activateGoal?(): boolean + dispose?(): void +} + +export interface GameOptions { + onPickTrack: (trackId: string) => void + /** Element that hosts the HTML label overlay (defaults to the canvas' parent). */ + labelRoot?: HTMLElement | null + reducedMotion?: boolean +} + +interface FrameMap { + image: string + atlas: { w: number; h: number; cols: number; rows: number } + cell: { w: number; h: number } + anchor: { x: number; y: number } + charH: number + clips: Record +} + +type Mode = 'play' | 'respawn' | 'pause' | 'celebrate' | 'wrap' + +function noopHandle(): GameHandle { + return { + start() {}, stop() {}, setPlayerControl() {}, onRingCount() {}, onEvent() {}, + onZone() {}, onGoalPrompt() {}, activateGoal: () => false, dispose() {}, + } +} + +/** Critically damped spring toward target (Unity SmoothDamp). */ +function smoothDamp(cur: number, target: number, vel: { v: number }, smooth: number, dt: number): number { + const omega = 2 / smooth + const x = omega * dt + const exp = 1 / (1 + x + 0.48 * x * x + 0.235 * x * x * x) + const change = cur - target + const temp = (vel.v + omega * change) * dt + vel.v = (vel.v - omega * temp) * exp + return target + (change + temp) * exp +} + +export function createGame(canvas: HTMLCanvasElement, model: LevelModel, opts: GameOptions): GameHandle { + let renderer: THREE.WebGLRenderer + try { + renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' }) + } catch { + return noopHandle() + } + + const reducedMotion = opts.reducedMotion ?? false + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)) + if (renderer.shadowMap) { + renderer.shadowMap.enabled = true + renderer.shadowMap.type = THREE.PCFSoftShadowMap + } + ;(renderer as { toneMapping?: THREE.ToneMapping }).toneMapping = THREE.ACESFilmicToneMapping + ;(renderer as { toneMappingExposure?: number }).toneMappingExposure = 1.05 + + const layout = buildWorldLayout(model) + const hazards = layout.hazards + + const scene = new THREE.Scene() + scene.fog = new THREE.Fog(0x1a1640, 26, 95) + const camera = new THREE.PerspectiveCamera(40, 16 / 9, 0.1, 400) + + // Lighting: sky/ground fill, warm key with shadows that follows the camera, cool rim. + scene.add(new THREE.HemisphereLight(0xa9bcff, 0x2a1d10, 0.9)) + scene.add(new THREE.AmbientLight(0x404a80, 0.35)) + const key = new THREE.DirectionalLight(0xfff0dd, 1.7) + key.castShadow = true + key.shadow.mapSize.set(1024, 1024) + key.shadow.camera.left = -18 + key.shadow.camera.right = 18 + key.shadow.camera.top = 12 + key.shadow.camera.bottom = -6 + key.shadow.camera.near = 1 + key.shadow.camera.far = 60 + key.shadow.bias = -0.0008 + scene.add(key, key.target) + const rim = new THREE.DirectionalLight(0x7fa8ff, 0.6) + rim.position.set(6, 8, -12) + scene.add(rim) + + const world = buildScene(scene, layout) + const fx = new Effects(scene) + const labelHost = opts.labelRoot ?? canvas.parentElement + const labels = labelHost ? new LabelLayer(labelHost, layout) : null + + // ---------- Kasimir ---------- + const fm = frameMapJson as unknown as FrameMap + const wpp = KASIMIR_H / fm.charH + const kGeo = new THREE.PlaneGeometry(fm.cell.w * wpp, fm.cell.h * wpp) + // PlaneGeometry is centred; anchor.y is measured from the cell top — move the feet to the origin. + kGeo.translate(0, (fm.anchor.y - fm.cell.h / 2) * wpp, 0) + const kMat = new THREE.MeshBasicMaterial({ transparent: true, alphaTest: 0.02, depthWrite: false }) + const kMesh = new THREE.Mesh(kGeo, kMat) + kMesh.renderOrder = 5 + const kas = new THREE.Group() + kas.add(kMesh) + scene.add(kas) + + let atlas: THREE.Texture | null = null + let lastFrameKey = '' + new THREE.TextureLoader().load( + fm.image, + tex => { + tex.colorSpace = THREE.SRGBColorSpace + tex.repeat.set(1 / fm.atlas.cols, 1 / fm.atlas.rows) + tex.anisotropy = 4 + atlas = tex + kMat.map = tex + kMat.needsUpdate = true + lastFrameKey = '' + }, + undefined, + () => { + new THREE.TextureLoader().load(KASIMIR_FALLBACK_TEX, tex => { + tex.colorSpace = THREE.SRGBColorSpace + kMat.map = tex + kMat.needsUpdate = true + }) + }, + ) + + function showFrame(clip: ClipName, index: number, facing: 1 | -1): void { + if (!atlas) return + const def = fm.clips[clip] ?? fm.clips.idle + const f = def.frames[Math.max(0, Math.min(index, def.frames.length - 1))] + const k = `${f.x},${f.y},${facing}` + if (k === lastFrameKey) return + lastFrameKey = k + const cols = fm.atlas.cols, rows = fm.atlas.rows + const col = Math.round(f.x / fm.cell.w), row = Math.round(f.y / fm.cell.h) + atlas.repeat.set(facing === 1 ? 1 / cols : -1 / cols, 1 / rows) + atlas.offset.set(facing === 1 ? col / cols : (col + 1) / cols, 1 - (row + 1) / rows) + } + + const shadowTex = softDotTexture('rgba(0,0,0,0.55)', 'rgba(0,0,0,0)') + const blob = new THREE.Mesh(new THREE.PlaneGeometry(1.8, 0.8), new THREE.MeshBasicMaterial({ map: shadowTex, transparent: true, depthWrite: false })) + blob.rotation.x = -Math.PI / 2 + scene.add(blob) + + // ---------- State ---------- + let phys: PhysicsState = initialState(layout.spawnX) + let prevX = phys.x + let prevY = phys.y + let rings: RingState = ringsReset() + let mode: Mode = 'play' + let modeT = 0 + let pose: AnimContext['pose'] = null + let pendingTrack: string | null = null + let animEvent: AnimEvent = 'none' + let sinceEventMs = 0 + let clip: ClipName = 'idle' + let clipMs = 0 + let squash = 0 + let shake = 0 + let fade = 1 + let progress: ProgressTrack = { lastX: phys.x, lastAdvanceT: 0, nowT: 0 } + let simT = 0 + let acc = 0 + const visited = new Set() + let playerControl = false + let rafId = 0 + let started = false + let camX = phys.x + let camY = CAM_BASE_Y + const camVX = { v: 0 } + const camVY = { v: 0 } + let look = 2.4 + let currentZone = -1 + let currentGoal: WorldGoal | null = null + + let ringCb: ((n: number) => void) | null = null + let eventCb: ((e: GameEvent) => void) | null = null + let zoneCb: ((z: ZoneInfo | null) => void) | null = null + let goalCb: ((g: GoalPrompt | null) => void) | null = null + const safe = (f: (() => T) | undefined | null) => { try { f?.() } catch { /* never throw into the page */ } } + const emit = (e: GameEvent) => safe(() => eventCb?.(e)) + const emitRings = () => safe(() => ringCb?.(rings.count)) + + function width(): number { return canvas.clientWidth || canvas.width || 800 } + function height(): number { return canvas.clientHeight || canvas.height || 600 } + + function setAnimEvent(e: AnimEvent): void { + animEvent = e + sinceEventMs = 0 + } + + function teleport(next: PhysicsState): void { + phys = next + prevX = next.x + prevY = next.y + } + + function resetRun(): void { + teleport(initialState(layout.spawnX)) + rings = ringsReset() + mode = 'play' + modeT = 0 + pose = null + pendingTrack = null + setAnimEvent('none') + visited.clear() + for (const c of world.coins) { + c.alive = true + c.mesh.visible = true + } + for (const g of world.gates) g.lit = false + progress = { lastX: phys.x, lastAdvanceT: simT, nowT: simT } + camX = phys.x + look + camVX.v = 0 + fade = 0 + emitRings() + } + + // ---------- Input ---------- + const keys = { left: false, right: false, jump: false } + const pointers = new Map() + let swipeJumpUntil = 0 + + function gameKey(k: string): boolean { + return ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' ', 'Spacebar', 'Enter', 'a', 'A', 'd', 'D', 'w', 'W'].includes(k) + } + + function onKeyDown(e: KeyboardEvent): void { + if (!playerControl) return + if (gameKey(e.key)) e.preventDefault() + switch (e.key) { + case 'ArrowRight': case 'd': case 'D': keys.right = true; break + case 'ArrowLeft': case 'a': case 'A': keys.left = true; break + case ' ': case 'Spacebar': keys.jump = true; break + case 'ArrowUp': case 'w': case 'W': + if (!activateGoal()) keys.jump = true + break + case 'Enter': activateGoal(); break + } + } + + function onKeyUp(e: KeyboardEvent): void { + switch (e.key) { + case 'ArrowRight': case 'd': case 'D': keys.right = false; break + case 'ArrowLeft': case 'a': case 'A': keys.left = false; break + case ' ': case 'Spacebar': case 'ArrowUp': case 'w': case 'W': keys.jump = false; break + } + } + + function onPointerDown(e: PointerEvent): void { + const rect = canvas.getBoundingClientRect() + const rel = (e.clientX - rect.left) / (rect.width || 1) + const zone = rel < 1 / 3 ? 'left' : rel > 2 / 3 ? 'right' : 'jump' + pointers.set(e.pointerId, { zone, y0: e.clientY, t0: performance.now() }) + } + function onPointerMove(e: PointerEvent): void { + const p = pointers.get(e.pointerId) + if (p && p.y0 - e.clientY > 40 && performance.now() - p.t0 < 400) { + swipeJumpUntil = simT + 0.3 + p.y0 = -Infinity + } + } + function onPointerUp(e: PointerEvent): void { + pointers.delete(e.pointerId) + } + + function playerInput(): PhysicsInput { + let left = keys.left, right = keys.right, jump = keys.jump || simT < swipeJumpUntil + for (const p of pointers.values()) { + if (p.zone === 'left') left = true + else if (p.zone === 'right') right = true + else jump = true + } + const moveDir: -1 | 0 | 1 = right && !left ? 1 : left && !right ? -1 : 0 + return { moveDir, jump } + } + + // ---------- Goals ---------- + function promptFor(g: WorldGoal | null): GoalPrompt | null { + if (!g) return null + return { trackId: g.trackId ?? g.zoneId, title: g.zoneTitle, label: g.label, kind: g.kind, locked: g.locked } + } + + function activateGoal(): boolean { + if (!playerControl || mode !== 'play' || !phys.onGround) return false + const g = goalAt(layout, phys.x) + if (!g) return false + if (g.locked) { + shake = reducedMotion ? 0 : 0.15 + labels?.floater(g.x, 4.5, 'Locked', 'loss', camera, width(), height()) + return true + } + mode = 'celebrate' + modeT = 0 + pose = 'celebrate' + pendingTrack = g.trackId ?? g.zoneId + teleport({ ...phys, vx: 0 }) + fx.confetti(g.x, 4) + emit('goal') + return true + } + + // ---------- Simulation step ---------- + function collectCoins(): void { + for (let i = 0; i < world.coins.length; i++) { + const c = world.coins[i] + if (!c.alive || !c.collectable) continue + const cx = c.mesh.position.x + const cy = c.baseY + if (Math.abs(cx - phys.x) < BODY_HW + 0.42 && cy > phys.y - 0.4 && cy < phys.y + BODY_H + 0.4) { + c.alive = false + c.mesh.visible = false + rings = ringsCollect(rings) + fx.sparkle(cx, cy) + labels?.floater(cx, cy + 1.2, '+1', 'gain', camera, width(), height()) + emitRings() + emit('pickup') + } + } + } + + function loseRings(): void { + const { next, scattered } = ringsOnHit(rings) + rings = next + if (scattered > 0) { + fx.scatterRings(phys.x, phys.y, scattered) + labels?.floater(phys.x, phys.y + BODY_H + 0.8, `−${scattered}`, 'loss', camera, width(), height()) + } + } + + function step(dt: number): void { + simT += dt + modeT += dt + sinceEventMs += dt * 1000 + prevX = phys.x + prevY = phys.y + + if (mode === 'respawn') { + if (phys.y > -9) { + const r = stepPhysics(phys, { moveDir: 0, jump: false }, hazards, dt) + phys = { ...r.next, y: Math.max(r.next.y, -9) } + } + if (modeT >= RESPAWN_S) { + teleport(respawn(phys)) + mode = 'play' + modeT = 0 + setAnimEvent('respawn') + progress = { lastX: phys.x, lastAdvanceT: simT, nowT: simT } + } + return + } + if (mode === 'pause') { + phys = { ...phys, vx: 0 } + if (modeT >= ATTRACT_PAUSE_S) { mode = 'play'; pose = null } + return + } + if (mode === 'celebrate') { + if (modeT >= CELEBRATE_S && pendingTrack) { + const id = pendingTrack + pendingTrack = null + safe(() => opts.onPickTrack(id)) + } + return + } + if (mode === 'wrap') { + fade = Math.max(0, 1 - modeT / WRAP_S) + if (modeT >= WRAP_S) resetRun() + return + } + + // --- play --- + let input: PhysicsInput + if (playerControl) { + input = playerInput() + } else { + input = autopilotInput(phys, hazards) + // Attract mode pauses at each goal to show it off. + const zi = zoneIndexAt(layout, phys.x) + const g = zi >= 0 ? layout.zones[zi].goal : null + if (g && !visited.has(zi) && phys.onGround && phys.x >= g.x - 0.2) { + visited.add(zi) + mode = 'pause' + modeT = 0 + pose = g.locked ? null : g.kind === 'track' ? 'reach' : 'celebrate' + teleport({ ...phys, vx: 0 }) + if (!g.locked) fx.sparkle(g.x, 3) + return + } + } + + const { next, event } = stepPhysics(phys, input, hazards, dt) + phys = next + + if (event === 'jump') { + setAnimEvent('jump') + squash = -0.8 + fx.dust(phys.x, 0, phys.facing) + emit('jump') + } else if (event === 'land') { + setAnimEvent('land') + squash = 1 + fx.dust(phys.x, phys.y, 0) + } else if (event === 'hit') { + setAnimEvent('hit') + loseRings() + shake = reducedMotion ? 0 : 0.35 + emit('hit') + emitRings() + } else if (event === 'fell') { + setAnimEvent('fell') + loseRings() + mode = 'respawn' + modeT = 0 + emit('fall') + emitRings() + return + } + + collectCoins() + + if (!playerControl) { + if (phys.x - progress.lastX > 0.5) progress = { lastX: phys.x, lastAdvanceT: simT, nowT: simT } + progress = { ...progress, nowT: simT } + const wd = watchdog(progress, phys, hazards) + if (wd.forced) { + teleport(wd.next) + setAnimEvent('respawn') + progress = { lastX: phys.x, lastAdvanceT: simT, nowT: simT } + } + if (phys.x >= layout.length - LEVEL_PAD - 0.5) { + mode = 'wrap' + modeT = 0 + } + } + } + + // ---------- Frame ---------- + const clock = new THREE.Clock(false) + + function resize(): void { + const w = width(), h = height() + camera.aspect = w / h || 1 + // Pull back on narrow or short screens so the path ahead and the labels still fit. + const narrow = Math.max(1, 1.78 / camera.aspect) + const short = Math.max(1, 1 + (600 - h) / 1200) + camera.position.z = CAM_DIST * Math.min(1.7, narrow * short) + camera.updateProjectionMatrix() + renderer.setSize(w, h, false) + } + + function frame(): void { + const dt = Math.min(clock.getDelta(), 0.1) + acc += dt + let steps = 0 + while (acc >= FIXED_DT && steps < 24) { + step(FIXED_DT) + acc -= FIXED_DT + steps++ + if (!started) return + } + const alpha = acc / FIXED_DT + const rx = prevX + (phys.x - prevX) * alpha + const ry = prevY + (phys.y - prevY) * alpha + + // Animation + const ctx: AnimContext = { + onGround: phys.onGround, vx: phys.vx, vy: phys.vy, + lastEvent: animEvent, sinceEventMs, pose, + } + const nextClip = selectClip(ctx) + if (nextClip !== clip) { clip = nextClip; clipMs = 0 } else { clipMs += dt * 1000 * playbackRate(clip, phys.vx) } + const def = fm.clips[clip] + const idx = clip === 'jump' ? jumpFrame(ctx, def?.frames.length ?? 1) : frameAt(clip, clipMs, fm.clips as never) + showFrame(clip, idx, phys.facing) + + squash *= Math.exp(-dt * 10) + kas.scale.set(1 + 0.08 * squash, 1 - 0.12 * squash, 1) + kas.position.set(rx, ry, 0.35) + const blinking = phys.invuln > 0 && mode === 'play' + kMat.opacity = (blinking ? (Math.floor(simT * 14) % 2 ? 0.35 : 1) : 1) * (mode === 'respawn' ? Math.max(0, 1 - modeT / 0.4) : 1) + kas.visible = kMat.opacity > 0.01 + + // Contact shadow on whatever is under Kasimir + let floorY = 0 + for (const c of hazards.crates) if (rx > c.x0 && rx < c.x1 && ry >= c.top - 0.05) floorY = c.top + const overHole = floorY === 0 && overPit(rx, hazards.pits) + blob.visible = !overHole && ry > -0.2 + const lift = Math.max(0, ry - floorY) + blob.position.set(rx, floorY + 0.02, 0.35) + blob.scale.setScalar(Math.max(0.35, 1 - lift * 0.15)) + ;(blob.material as THREE.MeshBasicMaterial).opacity = Math.max(0.2, 1 - lift * 0.18) + + // Camera: critically damped follow with look-ahead in the facing direction + look += (phys.facing * 2.4 - look) * Math.min(1, dt * 1.8) + camX = smoothDamp(camX, rx + look, camVX, 0.32, dt) + const camTargetY = CAM_BASE_Y + Math.max(-2.5, Math.min(2.2, (ry - 0.6) * 0.35)) + camY = smoothDamp(camY, camTargetY, camVY, 0.45, dt) + shake *= Math.exp(-dt * 7) + const sx = shake > 0.002 ? (Math.random() - 0.5) * shake : 0 + const sy = shake > 0.002 ? (Math.random() - 0.5) * shake : 0 + camera.position.x = camX + sx + camera.position.y = camY + sy + camera.lookAt(camX + sx, camY - CAM_LOOK_DROP + sy, 0) + + key.position.set(camX - 10, 20, 14) + key.target.position.set(camX, 0, 0) + + // Zone + goal prompt notifications + const zi = zoneIndexAt(layout, rx) + if (zi !== currentZone) { + currentZone = zi + const z = layout.zones[zi] + safe(() => zoneCb?.(z ? { index: zi, total: layout.zones.length, title: z.title, color: zoneColor(z.colorIndex), locked: z.locked } : null)) + } + const g = playerControl && mode === 'play' && phys.onGround ? goalAt(layout, phys.x) : null + if (g !== currentGoal) { + currentGoal = g + safe(() => goalCb?.(promptFor(g))) + } + + if (mode !== 'wrap') fade = Math.min(1, fade + dt * 2.5) + canvas.style.opacity = fade.toFixed(3) + + world.update(simT, dt, camX, rx, zi) + fx.update(dt) + renderer.render(scene, camera) + labels?.update(camera, camX, rx, ry, width(), height()) + + // stop() may have run inside this frame (e.g. onPickTrack → hide); don't re-arm the loop. + if (started) rafId = requestAnimationFrame(frame) + } + + function attach(): void { + window.addEventListener('resize', resize) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) + canvas.addEventListener('pointerdown', onPointerDown) + canvas.addEventListener('pointermove', onPointerMove) + canvas.addEventListener('pointerup', onPointerUp) + canvas.addEventListener('pointercancel', onPointerUp) + canvas.addEventListener('pointerleave', onPointerUp) + } + + function detach(): void { + window.removeEventListener('resize', resize) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) + canvas.removeEventListener('pointerdown', onPointerDown) + canvas.removeEventListener('pointermove', onPointerMove) + canvas.removeEventListener('pointerup', onPointerUp) + canvas.removeEventListener('pointercancel', onPointerUp) + canvas.removeEventListener('pointerleave', onPointerUp) + keys.left = keys.right = keys.jump = false + pointers.clear() + } + + return { + start() { + if (started) return + started = true + resize() + attach() + resetRun() + camX = phys.x + look + clock.start() + acc = 0 + rafId = requestAnimationFrame(frame) + }, + stop() { + if (rafId) cancelAnimationFrame(rafId) + rafId = 0 + detach() + playerControl = false + started = false + clock.stop() + if (currentGoal) { + currentGoal = null + safe(() => goalCb?.(null)) + } + }, + setPlayerControl(on: boolean) { + playerControl = on + if (on && mode === 'pause') { mode = 'play'; pose = null } + if (!on) { + keys.left = keys.right = keys.jump = false + // Autopilot resumes from here — don't let stale progress trip the wedge watchdog. + progress = { lastX: phys.x, lastAdvanceT: simT, nowT: simT } + } + }, + onRingCount(cb) { ringCb = cb }, + onEvent(cb) { eventCb = cb }, + onZone(cb) { zoneCb = cb }, + onGoalPrompt(cb) { goalCb = cb }, + activateGoal, + dispose() { + try { + if (rafId) cancelAnimationFrame(rafId) + detach() + labels?.dispose() + fx.dispose() + scene.traverse(obj => { + const m = obj as THREE.Mesh + if (m.geometry) m.geometry.dispose() + const mat = m.material as THREE.Material | THREE.Material[] | undefined + if (Array.isArray(mat)) mat.forEach(x => x.dispose()) + else mat?.dispose() + }) + atlas?.dispose() + renderer.dispose() + } catch { + // best-effort + } + }, + } +} diff --git a/hugo-apps/src/app-space-game/rings.ts b/hugo-apps/src/app-space-game/rings.ts new file mode 100644 index 000000000..b91271b30 --- /dev/null +++ b/hugo-apps/src/app-space-game/rings.ts @@ -0,0 +1,18 @@ +export interface RingState { + count: number +} + +export function reset(): RingState { + return { count: 0 } +} + +export function collect(s: RingState): RingState { + return { count: s.count + 1 } +} + +export function onHit(s: RingState): { next: RingState; scattered: number } { + return { + next: { count: 0 }, + scattered: s.count + } +} diff --git a/hugo-apps/src/app-space-game/scene.ts b/hugo-apps/src/app-space-game/scene.ts new file mode 100644 index 000000000..5126fa61a --- /dev/null +++ b/hugo-apps/src/app-space-game/scene.ts @@ -0,0 +1,520 @@ +// Builds the static arcade world from a WorldLayout and returns handles for the +// pieces that animate (coins, flags, chests, gate lamps, clouds, sky tint). + +import * as THREE from 'three' +import type { WorldLayout } from './layout' +import { zoneColor } from './level-model' +import { COIN_Y } from './layout' +import { + groundSideTexture, grassTexture, crateTexture, chestTexture, hazardStripeTexture, windowsTexture, + softDotTexture, cloudTexture, flagTexture, signTexture, seeded, +} from './textures' + +const GROUND_DEPTH = 8 // z extent of the play strip (z ∈ [-5, 3]) +const GROUND_BACK = -5 +const GROUND_FRONT = 3 +const GROUND_THICK = 7 + +export interface CoinView { + mesh: THREE.Mesh + baseY: number + alive: boolean + collectable: boolean +} +export interface FlagView { cloth: THREE.Mesh; base: Float32Array; x: number; locked: boolean; phase: number } +export interface ChestView { lid: THREE.Group; glow: THREE.Sprite; x: number; locked: boolean; open: number } +export interface GateView { lamp: THREE.Mesh; glow: THREE.Sprite; x: number; lit: boolean } + +export interface SceneHandles { + coins: CoinView[] + flags: FlagView[] + chests: ChestView[] + gates: GateView[] + update(t: number, dt: number, camX: number, kasX: number, zoneIndex: number): void +} + +function scaleUV(geo: THREE.BufferGeometry, sx: number, sy: number): THREE.BufferGeometry { + const uv = geo.getAttribute('uv') as THREE.BufferAttribute + for (let i = 0; i < uv.count; i++) uv.setXY(i, uv.getX(i) * sx, uv.getY(i) * sy) + uv.needsUpdate = true + return geo +} + +function solidSpans(layout: WorldLayout, from: number, to: number): Array<[number, number]> { + const pits = [...layout.hazards.pits].sort((a, b) => a.x0 - b.x0) + const spans: Array<[number, number]> = [] + let x = from + for (const p of pits) { + if (p.x0 > x) spans.push([x, p.x0]) + x = Math.max(x, p.x1) + } + if (to > x) spans.push([x, to]) + return spans +} + +/** Zone sky palette: deep night top, zone-tinted horizon. */ +export function zonePalette(colorIndex: number): { top: THREE.Color; horizon: THREE.Color; fog: THREE.Color } { + const accent = new THREE.Color(zoneColor(colorIndex)) + const top = new THREE.Color('#070b1f').lerp(accent, 0.12) + const horizon = new THREE.Color('#2a1f55').lerp(accent, 0.45) + const fog = horizon.clone().lerp(new THREE.Color('#0d1230'), 0.35) + return { top, horizon, fog } +} + +export function buildScene(scene: THREE.Scene, layout: WorldLayout): SceneHandles { + const L0 = -40 + const L1 = layout.length + 40 + const rnd = seeded(2026) + + // ---------- Sky (screen-filling gradient that follows the camera) ---------- + const skyUniforms = { + uTop: { value: new THREE.Color('#070b1f') }, + uHorizon: { value: new THREE.Color('#2a1f55') }, + } + const sky = new THREE.Mesh( + new THREE.PlaneGeometry(400, 160), + new THREE.ShaderMaterial({ + uniforms: skyUniforms, + depthWrite: false, + fog: false, + vertexShader: 'varying vec2 vUv; void main(){ vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0); }', + fragmentShader: + 'uniform vec3 uTop; uniform vec3 uHorizon; varying vec2 vUv;' + + 'void main(){ float t = smoothstep(0.25, 0.85, vUv.y); gl_FragColor = vec4(mix(uHorizon, uTop, t), 1.0); }', + }), + ) + sky.position.set(0, 30, -120) + sky.renderOrder = -10 + scene.add(sky) + + // Stars (far, nearly fixed) + const starGeo = new THREE.BufferGeometry() + const starPos = new Float32Array(900 * 3) + for (let i = 0; i < 900; i++) { + starPos[i * 3] = (rnd() - 0.5) * 360 + starPos[i * 3 + 1] = 12 + rnd() * 70 + starPos[i * 3 + 2] = -110 + } + starGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3)) + const starMat = new THREE.PointsMaterial({ color: 0xffffff, size: 1.6, sizeAttenuation: false, transparent: true, opacity: 0.85, fog: false }) + const stars = new THREE.Points(starGeo, starMat) + scene.add(stars) + + // Moon with halo + const moon = new THREE.Group() + const halo = new THREE.Sprite(new THREE.SpriteMaterial({ map: softDotTexture('rgba(255,240,200,0.55)', 'rgba(255,240,200,0)'), fog: false, depthWrite: false })) + halo.scale.set(40, 40, 1) + const disc = new THREE.Mesh(new THREE.CircleGeometry(5, 48), new THREE.MeshBasicMaterial({ color: 0xfff1cc, fog: false })) + moon.add(halo, disc) + moon.position.set(40, 48, -105) + scene.add(moon) + + // Clouds (slow drift) + const clouds: THREE.Sprite[] = [] + for (let i = 0; i < 26; i++) { + const m = new THREE.SpriteMaterial({ map: cloudTexture(i + 3), transparent: true, opacity: 0.18 + rnd() * 0.18, depthWrite: false, fog: false }) + const c = new THREE.Sprite(m) + const s = 14 + rnd() * 18 + c.scale.set(s, s * 0.45, 1) + c.position.set(L0 + rnd() * (L1 - L0), 22 + rnd() * 22, -70 - rnd() * 15) + c.userData.speed = 0.4 + rnd() * 0.8 + clouds.push(c) + scene.add(c) + } + + // ---------- Far mountains + mid hills (silhouettes) ---------- + const ridge = (z: number, baseY: number, amp: number, color: string, seed: number, step: number) => { + const r = seeded(seed) + const shape = new THREE.Shape() + shape.moveTo(L0 - 60, -20) + let y = baseY + for (let x = L0 - 60; x <= L1 + 60; x += step) { + y = baseY + Math.sin(x * 0.05 + seed) * amp * 0.5 + (r() - 0.5) * amp + shape.lineTo(x, y) + } + shape.lineTo(L1 + 60, -20) + shape.lineTo(L0 - 60, -20) + const mesh = new THREE.Mesh(new THREE.ShapeGeometry(shape), new THREE.MeshBasicMaterial({ color, fog: false })) + mesh.position.z = z + scene.add(mesh) + } + ridge(-90, 20, 14, '#1b1f4a', 7, 9) + ridge(-68, 10, 8, '#22285c', 9, 6) + + // ---------- Skyline (instanced buildings with lit windows) ---------- + const buildingCount = Math.ceil((L1 - L0) / 3.2) + const bGeo = scaleUV(new THREE.BoxGeometry(1, 1, 1), 1, 2) + const bMat = new THREE.MeshStandardMaterial({ + color: 0x151a36, + roughness: 0.9, + emissive: 0xffffff, + emissiveMap: windowsTexture(3), + emissiveIntensity: 0.32, + }) + const buildings = new THREE.InstancedMesh(bGeo, bMat, buildingCount) + const m4 = new THREE.Matrix4() + const q = new THREE.Quaternion() + for (let i = 0; i < buildingCount; i++) { + const w = 2.4 + rnd() * 2.6 + const h = 6 + rnd() * 14 + m4.compose(new THREE.Vector3(L0 + i * 3.2 + rnd(), h / 2 - 2, -52 - rnd() * 8), q, new THREE.Vector3(w, h, 2.5)) + buildings.setMatrixAt(i, m4) + } + buildings.instanceMatrix.needsUpdate = true + scene.add(buildings) + + // Neon roof strips in each zone's colour + for (const z of layout.zones) { + const col = new THREE.Color(zoneColor(z.colorIndex)).lerp(new THREE.Color('#ffffff'), 0.25) + for (let k = 0; k < 3; k++) { + const bar = new THREE.Mesh( + new THREE.BoxGeometry(3 + rnd() * 3, 0.25, 0.25), + new THREE.MeshBasicMaterial({ color: col }), + ) + bar.position.set(z.x0 + 4 + rnd() * Math.max(1, z.x1 - z.x0 - 8), 8 + rnd() * 8, -49) + scene.add(bar) + } + } + + // ---------- Near trees + lamp posts ---------- + const treeCount = Math.ceil((L1 - L0) / 5) + const trunkGeo = new THREE.CylinderGeometry(0.18, 0.25, 1.6, 6) + const crownGeo = new THREE.ConeGeometry(1.3, 3.2, 7) + const trunkMat = new THREE.MeshStandardMaterial({ color: 0x4a2f22, roughness: 1 }) + const crownMat = new THREE.MeshStandardMaterial({ color: 0x1f6b48, roughness: 0.8, flatShading: true }) + const trunks = new THREE.InstancedMesh(trunkGeo, trunkMat, treeCount) + const crowns = new THREE.InstancedMesh(crownGeo, crownMat, treeCount * 2) + for (let i = 0; i < treeCount; i++) { + const x = L0 + i * 5 + rnd() * 3 + const z = -7.5 - rnd() * 4 + const s = 0.8 + rnd() * 0.7 + m4.compose(new THREE.Vector3(x, 0.8 * s, z), q, new THREE.Vector3(s, s, s)) + trunks.setMatrixAt(i, m4) + m4.compose(new THREE.Vector3(x, (1.6 + 1.3) * s, z), q, new THREE.Vector3(s, s, s)) + crowns.setMatrixAt(i * 2, m4) + m4.compose(new THREE.Vector3(x, (1.6 + 2.6) * s, z), q, new THREE.Vector3(s * 0.75, s * 0.75, s * 0.75)) + crowns.setMatrixAt(i * 2 + 1, m4) + crowns.setColorAt(i * 2, new THREE.Color().setHSL(0.38 + rnd() * 0.06, 0.5, 0.22 + rnd() * 0.08)) + crowns.setColorAt(i * 2 + 1, new THREE.Color().setHSL(0.38 + rnd() * 0.06, 0.5, 0.28 + rnd() * 0.08)) + } + trunks.instanceMatrix.needsUpdate = true + crowns.instanceMatrix.needsUpdate = true + if (crowns.instanceColor) crowns.instanceColor.needsUpdate = true + scene.add(trunks, crowns) + + const lampGlowTex = softDotTexture('rgba(255,214,140,0.9)', 'rgba(255,214,140,0)') + const lampPostMat = new THREE.MeshStandardMaterial({ color: 0x2b3245, metalness: 0.6, roughness: 0.4 }) + const lampPostGeo = new THREE.CylinderGeometry(0.08, 0.1, 4.2, 6) + for (let x = 8; x < layout.length; x += 14) { + const post = new THREE.Mesh(lampPostGeo, lampPostMat) + post.position.set(x, 2.1, -4.6) + const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: lampGlowTex, transparent: true, depthWrite: false })) + glow.scale.set(2.6, 2.6, 1) + glow.position.set(x, 4.3, -4.5) + const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), new THREE.MeshBasicMaterial({ color: 0xffe2a8 })) + bulb.position.set(x, 4.3, -4.6) + scene.add(post, glow, bulb) + } + + // ---------- Ground with real pits ---------- + const sideTex = groundSideTexture() + const grassTex = grassTexture() + const earthMat = new THREE.MeshStandardMaterial({ map: sideTex, roughness: 1 }) + const topMat = new THREE.MeshStandardMaterial({ color: 0x3c9b46, roughness: 0.9 }) + const lipMat = new THREE.MeshStandardMaterial({ map: grassTex, roughness: 0.9 }) + for (const [a, b] of solidSpans(layout, L0, L1)) { + const w = b - a + const geo = scaleUV(new THREE.BoxGeometry(w, GROUND_THICK, GROUND_DEPTH), w / 4, GROUND_THICK / 4) + const block = new THREE.Mesh(geo, [earthMat, earthMat, topMat, earthMat, earthMat, earthMat]) + block.position.set((a + b) / 2, -GROUND_THICK / 2, (GROUND_BACK + GROUND_FRONT) / 2) + block.receiveShadow = true + scene.add(block) + const lip = new THREE.Mesh(scaleUV(new THREE.PlaneGeometry(w + 0.1, 0.5), w / 4, 1), lipMat) + lip.position.set((a + b) / 2, -0.2, GROUND_FRONT + 0.01) + scene.add(lip) + } + // Pit interiors: dark shaft with a faint glow far below + warning stripes at the rims + const stripeTex = hazardStripeTexture() + const pitWallMat = new THREE.MeshBasicMaterial({ color: 0x06040d }) + const pitGlowMat = new THREE.MeshBasicMaterial({ map: softDotTexture('rgba(150,60,255,0.55)', 'rgba(150,60,255,0)'), transparent: true, depthWrite: false }) + for (const p of layout.hazards.pits) { + const w = p.x1 - p.x0 + const back = new THREE.Mesh(new THREE.PlaneGeometry(w, GROUND_THICK), pitWallMat) + back.position.set((p.x0 + p.x1) / 2, -GROUND_THICK / 2, GROUND_BACK + 0.02) + const glow = new THREE.Mesh(new THREE.PlaneGeometry(w * 1.6, 3), pitGlowMat) + glow.position.set((p.x0 + p.x1) / 2, -GROUND_THICK + 1, GROUND_BACK + 0.05) + scene.add(back, glow) + for (const ex of [p.x0, p.x1]) { + const stripe = new THREE.Mesh( + scaleUV(new THREE.BoxGeometry(0.22, 0.08, GROUND_DEPTH), 1, 1), + new THREE.MeshBasicMaterial({ map: stripeTex }), + ) + stripe.position.set(ex + (ex === p.x0 ? -0.11 : 0.11), 0.04, (GROUND_BACK + GROUND_FRONT) / 2) + scene.add(stripe) + } + } + + // Foreground bushes along the front edge (never overlap Kasimir — they sit low and in front) + const bushGeo = new THREE.IcosahedronGeometry(0.55, 0) + const bushMat = new THREE.MeshStandardMaterial({ color: 0x2f8f45, flatShading: true, roughness: 0.8 }) + const flowerMat = new THREE.MeshBasicMaterial({ color: 0xffd34d }) + const bushSpots = solidSpans(layout, L0, L1).flatMap(([a, b]) => { + const out: number[] = [] + for (let x = a + 1; x < b - 1; x += 3 + rnd() * 4) out.push(x) + return out + }) + const bushes = new THREE.InstancedMesh(bushGeo, bushMat, Math.max(1, bushSpots.length)) + const flowers = new THREE.InstancedMesh(new THREE.SphereGeometry(0.08, 6, 4), flowerMat, Math.max(1, bushSpots.length)) + bushSpots.forEach((x, i) => { + const s = 0.6 + rnd() * 0.6 + m4.compose(new THREE.Vector3(x, 0.15 * s, GROUND_FRONT - 0.3), q, new THREE.Vector3(s * 1.4, s * 0.7, s)) + bushes.setMatrixAt(i, m4) + m4.compose(new THREE.Vector3(x + 0.2, 0.45 * s, GROUND_FRONT - 0.05), q, new THREE.Vector3(1, 1, 1)) + flowers.setMatrixAt(i, m4) + }) + bushes.count = bushSpots.length + flowers.count = bushSpots.length + bushes.instanceMatrix.needsUpdate = true + flowers.instanceMatrix.needsUpdate = true + scene.add(bushes, flowers) + + // ---------- Crates ---------- + const crateMat = new THREE.MeshStandardMaterial({ map: crateTexture(), roughness: 0.75 }) + for (const c of layout.hazards.crates) { + const w = c.x1 - c.x0 + const crate = new THREE.Mesh(new THREE.BoxGeometry(w, c.top, 1.6), crateMat) + crate.position.set((c.x0 + c.x1) / 2, c.top / 2, 0) + crate.castShadow = true + crate.receiveShadow = true + scene.add(crate) + } + + // ---------- Spikes ---------- + const spikeCones: THREE.Matrix4[] = [] + const plateMat = new THREE.MeshStandardMaterial({ map: stripeTex, roughness: 0.6 }) + for (const s of layout.hazards.spikes) { + const w = s.x1 - s.x0 + const plate = new THREE.Mesh(scaleUV(new THREE.BoxGeometry(w, 0.14, 1.5), w / 1.2, 1), plateMat) + plate.position.set((s.x0 + s.x1) / 2, 0.07, 0) + plate.receiveShadow = true + scene.add(plate) + const n = Math.max(3, Math.round(w / 0.3)) + for (let i = 0; i < n; i++) { + for (const z of [-0.35, 0.35]) { + const x = s.x0 + (i + 0.5) * (w / n) + (z > 0 ? 0.06 : -0.06) + spikeCones.push(new THREE.Matrix4().makeTranslation(x, 0.14 + (s.top - 0.14) / 2, z)) + } + } + } + if (spikeCones.length) { + const cone = new THREE.InstancedMesh( + new THREE.ConeGeometry(0.13, 0.42, 6), + new THREE.MeshStandardMaterial({ color: 0xc9d2de, metalness: 0.85, roughness: 0.25, emissive: 0x551010, emissiveIntensity: 0.4 }), + spikeCones.length, + ) + spikeCones.forEach((mm, i) => cone.setMatrixAt(i, mm)) + cone.instanceMatrix.needsUpdate = true + cone.castShadow = true + scene.add(cone) + } + + // ---------- Checkpoint gates (decorative arch behind the path) ---------- + const stoneMat = new THREE.MeshStandardMaterial({ color: 0x8a8fa3, roughness: 0.7 }) + const gates: GateView[] = [] + const amberGlowTex = softDotTexture('rgba(255,190,80,0.9)', 'rgba(255,190,80,0)') + for (const g of layout.gates) { + const group = new THREE.Group() + for (const dx of [-1.7, 1.7]) { + const p = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.28, 4, 10), stoneMat) + p.position.set(dx, 2, 0) + group.add(p) + } + const arch = new THREE.Mesh(new THREE.TorusGeometry(1.7, 0.2, 8, 28, Math.PI), stoneMat) + arch.position.set(0, 4, 0) + group.add(arch) + const lampMat = new THREE.MeshStandardMaterial({ color: 0xffb02e, emissive: 0xffb02e, emissiveIntensity: 0.15 }) + const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.35, 16, 12), lampMat) + lamp.position.set(0, 5.9, 0) + group.add(lamp) + const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: amberGlowTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending })) + glow.scale.set(3.2, 3.2, 1) + glow.position.set(0, 5.9, 0.2) + group.add(glow) + group.position.set(g.x, 0, -3) + scene.add(group) + gates.push({ lamp, glow, x: g.x, lit: false }) + } + + // ---------- Zone signs ---------- + const postMat = new THREE.MeshStandardMaterial({ color: 0x6b4a2c, roughness: 0.9 }) + layout.zones.forEach((z, i) => { + const color = zoneColor(z.colorIndex) + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.1, 0.12, 2.6, 8), postMat) + post.position.set(z.x0 + 2.5, 1.3, -1.8) + const face = new THREE.Mesh(new THREE.CircleGeometry(0.85, 32), new THREE.MeshStandardMaterial({ map: signTexture(String(i + 1), color), roughness: 0.6 })) + face.position.set(z.x0 + 2.5, 2.9, -1.7) + scene.add(post, face) + }) + + // ---------- Goals: flags (track) and chests (prize / checkpoint) ---------- + const flags: FlagView[] = [] + const chests: ChestView[] = [] + const chestTex = chestTexture() + const poleMat = new THREE.MeshStandardMaterial({ color: 0xd7dde8, metalness: 0.8, roughness: 0.3 }) + const goldMat = new THREE.MeshStandardMaterial({ color: 0xffc94a, metalness: 0.9, roughness: 0.25, emissive: 0x6b4a00, emissiveIntensity: 0.4 }) + layout.zones.forEach((z, i) => { + const goal = z.goal + const color = zoneColor(z.colorIndex) + const gx = goal.x + if (goal.kind === 'track') { + const base = new THREE.Mesh(new THREE.CylinderGeometry(0.7, 0.9, 0.5, 16), stoneMat) + base.position.set(gx, 0.25, -1.2) + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.09, 0.11, 5, 10), poleMat) + pole.position.set(gx, 2.5, -1.2) + const ball = new THREE.Mesh(new THREE.SphereGeometry(0.24, 16, 12), goldMat) + ball.position.set(gx, 5.1, -1.2) + const clothGeo = new THREE.PlaneGeometry(2.6, 1.6, 16, 4) + clothGeo.translate(1.3, 0, 0) + const cloth = new THREE.Mesh( + clothGeo, + new THREE.MeshStandardMaterial({ map: flagTexture(color, String(i + 1), goal.locked), side: THREE.DoubleSide, roughness: 0.8 }), + ) + cloth.position.set(gx + 0.08, goal.locked ? 2.6 : 4.2, -1.2) + if (goal.locked) cloth.rotation.z = -0.25 + cloth.castShadow = true + scene.add(base, pole, ball, cloth) + flags.push({ cloth, base: Float32Array.from(clothGeo.getAttribute('position').array as Float32Array), x: gx, locked: goal.locked, phase: i * 1.7 }) + } else { + const chest = new THREE.Group() + const bodyMat = new THREE.MeshStandardMaterial({ map: chestTex, color: goal.locked ? 0x8a8a8a : 0xffffff, roughness: 0.55, metalness: 0.15 }) + const body = new THREE.Mesh(new THREE.BoxGeometry(2, 1.2, 1.3), bodyMat) + body.position.y = 0.6 + body.castShadow = true + chest.add(body) + if (!goal.locked) { + const treasure = new THREE.Mesh(new THREE.BoxGeometry(1.7, 0.2, 1.0), goldMat) + treasure.position.set(0, 1.47, 0) + chest.add(treasure) + } + for (const dx of [-0.75, 0.75]) { + const band = new THREE.Mesh(new THREE.BoxGeometry(0.16, 1.24, 1.34), goldMat) + band.position.set(dx, 0.6, 0) + chest.add(band) + } + const lid = new THREE.Group() + lid.position.set(0, 1.2, -0.65) + const lidMesh = new THREE.Mesh(new THREE.CylinderGeometry(0.65, 0.65, 2, 16, 1, false, 0, Math.PI), bodyMat) + lidMesh.rotation.z = Math.PI / 2 + lidMesh.rotation.y = Math.PI / 2 + lidMesh.position.set(0, 0, 0.65) + lid.add(lidMesh) + chest.add(lid) + const glow = new THREE.Sprite(new THREE.SpriteMaterial({ map: amberGlowTex, transparent: true, opacity: 0, depthWrite: false, blending: THREE.AdditiveBlending })) + glow.scale.set(4, 4, 1) + glow.position.set(0, 1.9, 0.3) + chest.add(glow) + if (goal.locked) { + const lock = new THREE.Mesh(new THREE.BoxGeometry(0.4, 0.4, 0.15), goldMat) + lock.position.set(0, 1.0, 0.7) + const shackle = new THREE.Mesh(new THREE.TorusGeometry(0.14, 0.04, 6, 16, Math.PI), goldMat) + shackle.position.set(0, 1.2, 0.7) + chest.add(lock, shackle) + } + const plinth = new THREE.Mesh(new THREE.CylinderGeometry(1.5, 1.7, 0.3, 20), new THREE.MeshStandardMaterial({ color, roughness: 0.6 })) + plinth.position.y = -0.15 + 0.15 + chest.add(plinth) + body.position.y += 0.3 + lid.position.y += 0.3 + chest.position.set(gx, 0, -1.2) + scene.add(chest) + chests.push({ lid, glow, x: gx, locked: goal.locked, open: 0 }) + } + }) + + // ---------- Coins ---------- + const coinGeo = new THREE.TorusGeometry(0.42, 0.13, 12, 28) + const coinMats = { + open: new THREE.MeshStandardMaterial({ color: 0xffc23a, metalness: 0.9, roughness: 0.2, emissive: 0x8a5a00, emissiveIntensity: 0.55 }), + done: new THREE.MeshStandardMaterial({ color: 0x5fe07a, metalness: 0.7, roughness: 0.25, emissive: 0x1d6b2d, emissiveIntensity: 0.6 }), + locked: new THREE.MeshStandardMaterial({ color: 0x777b88, metalness: 0.4, roughness: 0.6, transparent: true, opacity: 0.45 }), + } + const coins: CoinView[] = layout.coins.map(c => { + const mat = !c.available ? coinMats.locked : c.done ? coinMats.done : coinMats.open + const mesh = new THREE.Mesh(coinGeo, mat) + mesh.position.set(c.x, COIN_Y, 0) + mesh.castShadow = c.available + scene.add(mesh) + return { mesh, baseY: COIN_Y, alive: true, collectable: c.available } + }) + + // ---------- Per-frame animation ---------- + const fog = scene.fog as THREE.Fog | null + const target = { top: new THREE.Color(), horizon: new THREE.Color(), fog: new THREE.Color() } + let lastZone = -2 + + function update(t: number, dt: number, camX: number, kasX: number, zoneIndex: number): void { + sky.position.x = camX + stars.position.x = camX * 0.92 + moon.position.x = camX * 0.95 + 40 + starMat.opacity = 0.7 + Math.sin(t * 1.3) * 0.15 + + for (const c of clouds) { + c.position.x += c.userData.speed * dt + if (c.position.x > camX + 160) c.position.x -= 320 + } + + if (zoneIndex !== lastZone && zoneIndex >= 0) { + const p = zonePalette(layout.zones[zoneIndex].colorIndex) + target.top.copy(p.top) + target.horizon.copy(p.horizon) + target.fog.copy(p.fog) + if (lastZone === -2) { + skyUniforms.uTop.value.copy(p.top) + skyUniforms.uHorizon.value.copy(p.horizon) + fog?.color.copy(p.fog) + } + lastZone = zoneIndex + } + const k = Math.min(1, dt * 1.5) + skyUniforms.uTop.value.lerp(target.top, k) + skyUniforms.uHorizon.value.lerp(target.horizon, k) + fog?.color.lerp(target.fog, k) + + for (const c of coins) { + if (!c.alive) continue + c.mesh.rotation.y += dt * 2.6 + c.mesh.position.y = c.baseY + Math.sin(t * 2.2 + c.mesh.position.x * 0.7) * 0.1 + } + + for (const f of flags) { + if (f.locked) continue + const pos = f.cloth.geometry.getAttribute('position') as THREE.BufferAttribute + const arr = pos.array as Float32Array + for (let i = 0; i < pos.count; i++) { + const bx = f.base[i * 3] + const amp = bx * 0.12 + arr[i * 3 + 2] = f.base[i * 3 + 2] + Math.sin(t * 4 + bx * 2.2 + f.phase) * amp + arr[i * 3 + 1] = f.base[i * 3 + 1] + Math.sin(t * 3 + bx * 1.6 + f.phase) * amp * 0.25 + } + pos.needsUpdate = true + f.cloth.geometry.computeVertexNormals() + } + + for (const ch of chests) { + const near = !ch.locked && Math.abs(kasX - ch.x) < 3.2 + ch.open += ((near ? 1 : 0) - ch.open) * Math.min(1, dt * 5) + ch.lid.rotation.x = -ch.open * 1.15 + ;(ch.glow.material as THREE.SpriteMaterial).opacity = ch.locked ? 0 : 0.25 + ch.open * 0.6 + Math.sin(t * 3) * 0.05 + } + + for (const g of gates) { + if (!g.lit && kasX > g.x) g.lit = true + const mat = g.lamp.material as THREE.MeshStandardMaterial + const targetI = g.lit ? 1.4 + Math.sin(t * 4) * 0.2 : 0.15 + mat.emissiveIntensity += (targetI - mat.emissiveIntensity) * Math.min(1, dt * 6) + ;(g.glow.material as THREE.SpriteMaterial).opacity = g.lit ? 0.85 : 0 + } + } + + return { coins, flags, chests, gates, update } +} diff --git a/hugo-apps/src/app-space-game/sound.ts b/hugo-apps/src/app-space-game/sound.ts new file mode 100644 index 000000000..e3fb0308e --- /dev/null +++ b/hugo-apps/src/app-space-game/sound.ts @@ -0,0 +1,120 @@ +// WebAudio pickup sound for app-space-game. +// Starts DISABLED. blip() is a no-op until first user gesture AND enabled. +// Guards window.AudioContext (and webkitAudioContext). NEVER throws. + +/** The return type of createSound. */ +export interface SoundHandle { + /** Whether audio is currently enabled. */ + enabled: boolean + /** Toggle enabled state. */ + toggle(): void + /** Play a short blip sound (ring pickup). No-op if disabled or no AudioContext. */ + blip(): void + jump(): void + hit(): void + fall(): void + goal(): void +} + +export function createSound(): SoundHandle { + let enabled = false + let gestureReceived = false + let ctx: AudioContext | null = null + + function getCtx(): AudioContext | null { + if (ctx) return ctx + try { + // Guard both AudioContext and webkitAudioContext + const Ctor = + (typeof window !== 'undefined' && + ((window as Record).AudioContext as typeof AudioContext | undefined)) || + (typeof window !== 'undefined' && + ((window as Record).webkitAudioContext as typeof AudioContext | undefined)) || + null + if (!Ctor) return null + ctx = new Ctor() + return ctx + } catch { + return null + } + } + + // Mark first user gesture — required before AudioContext can produce sound + function onFirstGesture(): void { + gestureReceived = true + if (typeof window !== 'undefined') { + window.removeEventListener('pointerdown', onFirstGesture) + window.removeEventListener('keydown', onFirstGesture) + window.removeEventListener('touchstart', onFirstGesture) + } + // Resume context if it was suspended (browser policy) + try { + const c = getCtx() + if (c && c.state === 'suspended') { + c.resume().catch(() => { /* silent */ }) + } + } catch { + // silent + } + } + + if (typeof window !== 'undefined') { + window.addEventListener('pointerdown', onFirstGesture, { once: true, passive: true }) + window.addEventListener('keydown', onFirstGesture, { once: true, passive: true }) + window.addEventListener('touchstart', onFirstGesture, { once: true, passive: true }) + } + + return { + get enabled() { + return enabled + }, + toggle() { + enabled = !enabled + // Toggling is itself a user gesture. + gestureReceived = true + }, + blip() { + tone([[880, 440]], 0.08, 'sine', 0.18) + }, + jump() { + tone([[320, 640]], 0.12, 'square', 0.06) + }, + hit() { + tone([[220, 90]], 0.25, 'sawtooth', 0.12) + }, + fall() { + tone([[600, 120]], 0.5, 'triangle', 0.14) + }, + goal() { + tone([[523, 523], [659, 659], [784, 784], [1047, 1047]], 0.1, 'square', 0.07, 0.09) + }, + } + + /** + * Plays a short sequence of sweeps. Each entry is [startHz, endHz]. + * No-op unless enabled AND a user gesture has occurred. NEVER throws. + */ + function tone(sweeps: Array<[number, number]>, dur: number, type: OscillatorType, vol: number, gap = 0): void { + if (!enabled || !gestureReceived) return + try { + const c = getCtx() + if (!c || c.state === 'suspended') return + sweeps.forEach(([f0, f1], i) => { + const t0 = c.currentTime + i * (gap || dur) + const osc = c.createOscillator() + const gain = c.createGain() + osc.connect(gain) + gain.connect(c.destination) + osc.type = type + osc.frequency.setValueAtTime(f0, t0) + if (f1 !== f0) osc.frequency.exponentialRampToValueAtTime(f1, t0 + dur * 0.8) + gain.gain.setValueAtTime(vol, t0) + gain.gain.exponentialRampToValueAtTime(0.001, t0 + dur) + osc.start(t0) + osc.stop(t0 + dur + 0.02) + }) + } catch { + // audio is best-effort + } + } +} diff --git a/hugo-apps/src/app-space-game/textures.ts b/hugo-apps/src/app-space-game/textures.ts new file mode 100644 index 000000000..0b7346430 --- /dev/null +++ b/hugo-apps/src/app-space-game/textures.ts @@ -0,0 +1,282 @@ +// Procedural canvas textures for the arcade scene. Every generator degrades to a +// blank texture when a 2D context is unavailable (tests / exotic browsers). + +import * as THREE from 'three' + +function canvas(w: number, h: number): { cv: HTMLCanvasElement; g: CanvasRenderingContext2D | null } { + const cv = document.createElement('canvas') + cv.width = w + cv.height = h + let g: CanvasRenderingContext2D | null = null + try { g = cv.getContext('2d') } catch { g = null } + return { cv, g } +} + +function finish(cv: HTMLCanvasElement, opts: { repeat?: boolean; srgb?: boolean } = {}): THREE.CanvasTexture { + const t = new THREE.CanvasTexture(cv) + if (opts.srgb !== false) t.colorSpace = THREE.SRGBColorSpace + if (opts.repeat) { + t.wrapS = THREE.RepeatWrapping + t.wrapT = THREE.RepeatWrapping + } + t.anisotropy = 4 + return t +} + +/** Mulberry32 — deterministic texture noise so scenes look the same every load. */ +export function seeded(seed: number): () => number { + let s = seed >>> 0 + return () => { + s = (s + 0x6d2b79f5) | 0 + let t = Math.imul(s ^ (s >>> 15), 1 | s) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** Earth/stone side of the ground with layered strata and pebbles. */ +export function groundSideTexture(): THREE.CanvasTexture { + const { cv, g } = canvas(256, 256) + if (g) { + const rnd = seeded(11) + const grad = g.createLinearGradient(0, 0, 0, 256) + grad.addColorStop(0, '#5b3b2a') + grad.addColorStop(1, '#2a1a14') + g.fillStyle = grad + g.fillRect(0, 0, 256, 256) + for (let band = 0; band < 5; band++) { + g.fillStyle = `rgba(0,0,0,${0.08 + band * 0.03})` + const y = 40 + band * 46 + g.beginPath() + g.moveTo(0, y) + for (let x = 0; x <= 256; x += 16) g.lineTo(x, y + Math.sin(x * 0.05 + band) * 5) + g.lineTo(256, y + 18) + g.lineTo(0, y + 18) + g.fill() + } + for (let i = 0; i < 70; i++) { + const x = rnd() * 256, y = 20 + rnd() * 236, r = 2 + rnd() * 6 + g.fillStyle = `rgba(${150 + rnd() * 60},${120 + rnd() * 50},${100 + rnd() * 40},${0.25 + rnd() * 0.3})` + g.beginPath() + g.ellipse(x, y, r * 1.3, r, rnd() * 3, 0, Math.PI * 2) + g.fill() + } + } + return finish(cv, { repeat: true }) +} + +/** Grass top strip (tileable horizontally). */ +export function grassTexture(): THREE.CanvasTexture { + const { cv, g } = canvas(256, 64) + if (g) { + const rnd = seeded(5) + g.fillStyle = '#3fae4a' + g.fillRect(0, 0, 256, 64) + for (let i = 0; i < 260; i++) { + const x = rnd() * 256 + g.strokeStyle = rnd() > 0.5 ? '#5fd36a' : '#2c8a37' + g.lineWidth = 2 + g.beginPath() + g.moveTo(x, 64) + g.lineTo(x + (rnd() - 0.5) * 8, 64 - 10 - rnd() * 30) + g.stroke() + } + } + return finish(cv, { repeat: true }) +} + +/** Wooden crate face with planks, cross brace and metal corners. */ +export function crateTexture(): THREE.CanvasTexture { + const { cv, g } = canvas(256, 256) + if (g) { + g.fillStyle = '#b5773b' + g.fillRect(0, 0, 256, 256) + for (let i = 0; i < 4; i++) { + g.fillStyle = i % 2 ? '#a96c33' : '#bf8245' + g.fillRect(0, i * 64, 256, 62) + g.fillStyle = 'rgba(60,30,10,0.5)' + g.fillRect(0, i * 64 + 62, 256, 2) + } + g.strokeStyle = '#7a4a1e' + g.lineWidth = 28 + g.beginPath(); g.moveTo(24, 24); g.lineTo(232, 232); g.stroke() + g.strokeStyle = '#8f5826' + g.lineWidth = 18 + g.beginPath(); g.moveTo(24, 24); g.lineTo(232, 232); g.stroke() + g.strokeStyle = '#6b3f17' + g.lineWidth = 24 + g.strokeRect(12, 12, 232, 232) + g.fillStyle = '#9aa6b5' + for (const [x, y] of [[0, 0], [216, 0], [0, 216], [216, 216]]) { + g.fillRect(x, y, 40, 40) + g.fillStyle = '#c9d3df' + g.fillRect(x + 4, y + 4, 32, 6) + g.fillStyle = '#5c6572' + g.beginPath(); g.arc(x + 20, y + 22, 5, 0, Math.PI * 2); g.fill() + g.fillStyle = '#9aa6b5' + } + } + return finish(cv) +} + +/** Treasure chest face: dark red-brown planks with gold trim and studs (distinct from crates). */ +export function chestTexture(): THREE.CanvasTexture { + const { cv, g } = canvas(256, 256) + if (g) { + for (let i = 0; i < 6; i++) { + g.fillStyle = i % 2 ? '#6e2a17' : '#7d321c' + g.fillRect(i * 43, 0, 43, 256) + g.fillStyle = 'rgba(0,0,0,0.35)' + g.fillRect(i * 43 + 41, 0, 2, 256) + } + const gold = g.createLinearGradient(0, 0, 0, 256) + gold.addColorStop(0, '#ffe79a') + gold.addColorStop(0.5, '#e0a52a') + gold.addColorStop(1, '#a8701a') + g.strokeStyle = gold + g.lineWidth = 22 + g.strokeRect(11, 11, 234, 234) + g.fillStyle = gold + g.fillRect(0, 112, 256, 22) + g.fillStyle = '#fff3c4' + for (const [x, y] of [[11, 11], [245, 11], [11, 245], [245, 245], [11, 123], [245, 123]]) { + g.beginPath(); g.arc(x, y, 6, 0, Math.PI * 2); g.fill() + } + } + return finish(cv) +} + +/** Yellow/black hazard stripes for spike bases and pit edges. */ +export function hazardStripeTexture(): THREE.CanvasTexture { + const { cv, g } = canvas(128, 32) + if (g) { + g.fillStyle = '#ffc61a' + g.fillRect(0, 0, 128, 32) + g.fillStyle = '#1a1a1a' + for (let x = -32; x < 160; x += 32) { + g.beginPath() + g.moveTo(x, 32); g.lineTo(x + 16, 0); g.lineTo(x + 32, 0); g.lineTo(x + 16, 32) + g.fill() + } + } + return finish(cv, { repeat: true }) +} + +/** Skyline window grid: lit and unlit windows, used as an emissive map on buildings. */ +export function windowsTexture(seed: number): THREE.CanvasTexture { + const { cv, g } = canvas(128, 256) + if (g) { + const rnd = seeded(seed) + g.fillStyle = '#000' + g.fillRect(0, 0, 128, 256) + for (let y = 8; y < 248; y += 16) { + for (let x = 8; x < 120; x += 16) { + if (rnd() < 0.3) { + const warm = rnd() < 0.7 + g.fillStyle = warm ? `rgba(255,${200 + rnd() * 40},${120 + rnd() * 60},1)` : 'rgba(140,210,255,1)' + g.fillRect(x, y, 9, 10) + } + } + } + } + return finish(cv, { repeat: true }) +} + +/** Soft round blob — clouds, glows, sparkles, shadows. */ +export function softDotTexture(inner = 'rgba(255,255,255,1)', outer = 'rgba(255,255,255,0)'): THREE.CanvasTexture { + const { cv, g } = canvas(64, 64) + if (g) { + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 32) + grad.addColorStop(0, inner) + grad.addColorStop(1, outer) + g.fillStyle = grad + g.fillRect(0, 0, 64, 64) + } + return finish(cv) +} + +/** Four-point sparkle star. */ +export function sparkleTexture(): THREE.CanvasTexture { + const { cv, g } = canvas(64, 64) + if (g) { + const grad = g.createRadialGradient(32, 32, 0, 32, 32, 30) + grad.addColorStop(0, 'rgba(255,255,255,1)') + grad.addColorStop(0.25, 'rgba(255,230,140,0.9)') + grad.addColorStop(1, 'rgba(255,200,60,0)') + g.fillStyle = grad + g.beginPath() + g.moveTo(32, 0); g.quadraticCurveTo(36, 28, 64, 32); g.quadraticCurveTo(36, 36, 32, 64) + g.quadraticCurveTo(28, 36, 0, 32); g.quadraticCurveTo(28, 28, 32, 0) + g.fill() + } + return finish(cv) +} + +/** Fluffy cloud built from overlapping soft circles. */ +export function cloudTexture(seed: number): THREE.CanvasTexture { + const { cv, g } = canvas(256, 128) + if (g) { + const rnd = seeded(seed) + for (let i = 0; i < 9; i++) { + const x = 50 + rnd() * 156, y = 60 + (rnd() - 0.5) * 30, r = 26 + rnd() * 30 + const grad = g.createRadialGradient(x, y, 0, x, y, r) + grad.addColorStop(0, 'rgba(255,255,255,0.85)') + grad.addColorStop(1, 'rgba(255,255,255,0)') + g.fillStyle = grad + g.beginPath(); g.arc(x, y, r, 0, Math.PI * 2); g.fill() + } + } + return finish(cv) +} + +/** Flag cloth: zone colour with a light border and a big zone number. */ +export function flagTexture(color: string, label: string, locked: boolean): THREE.CanvasTexture { + const { cv, g } = canvas(256, 160) + if (g) { + g.fillStyle = locked ? '#5a5f6b' : color + g.fillRect(0, 0, 256, 160) + g.fillStyle = 'rgba(255,255,255,0.18)' + for (let i = 0; i < 256; i += 32) g.fillRect(i, 0, 16, 160) + g.strokeStyle = 'rgba(255,255,255,0.85)' + g.lineWidth = 8 + g.strokeRect(6, 6, 244, 148) + g.fillStyle = '#ffffff' + g.font = 'bold 92px system-ui, sans-serif' + g.textAlign = 'center' + g.textBaseline = 'middle' + g.fillText(locked ? '🔒' : label, 128, 84) + } + return finish(cv) +} + +/** Round sign face for the zone number at the start of each zone. */ +export function signTexture(text: string, color: string): THREE.CanvasTexture { + const { cv, g } = canvas(256, 256) + if (g) { + g.fillStyle = '#f2e3c4' + g.beginPath(); g.arc(128, 128, 120, 0, Math.PI * 2); g.fill() + g.lineWidth = 16 + g.strokeStyle = color + g.stroke() + g.fillStyle = color + g.font = 'bold 130px system-ui, sans-serif' + g.textAlign = 'center' + g.textBaseline = 'middle' + g.fillText(text, 128, 138) + } + return finish(cv) +} + +/** Vertical sky gradient (top → horizon). */ +export function skyTexture(top: string, mid: string, horizon: string): THREE.CanvasTexture { + const { cv, g } = canvas(4, 256) + if (g) { + const grad = g.createLinearGradient(0, 0, 0, 256) + grad.addColorStop(0, top) + grad.addColorStop(0.55, mid) + grad.addColorStop(1, horizon) + g.fillStyle = grad + g.fillRect(0, 0, 4, 256) + } + return finish(cv) +} diff --git a/hugo-apps/src/app-space-game/types.ts b/hugo-apps/src/app-space-game/types.ts new file mode 100644 index 000000000..01d40ee23 --- /dev/null +++ b/hugo-apps/src/app-space-game/types.ts @@ -0,0 +1,103 @@ +// Payload interfaces copied from hugo-apps/src/app-space/AppSpace.vue (lines 53-118) +// to avoid cross-island import dependency. + +export interface AppSpaceItem { + imsId: number | null + title: string + type: 'TUTORIAL' | 'CHECKPOINT' | 'PRIZE' + status: string + progress: number + experience: string + timeToComplete: number + url: string + description: string + recordId?: number + unlocked?: boolean + temporarilyUnavailable?: boolean + unavailableReason?: string + prizeId?: number | null +} + +export interface ForkLane { + label: string + items: AppSpaceItem[] +} + +export interface LinearSegment { + order: number + kind: 'linear' + items: AppSpaceItem[] +} + +export interface ClusterSegment { + order: number + kind: 'parallel-any' + items: AppSpaceItem[] +} + +export interface ForkSegment { + order: number + kind: 'fork' + lanes: ForkLane[] +} + +export type AppSpaceSegment = LinearSegment | ClusterSegment | ForkSegment + +export interface AppSpaceTrack { + id: string + title: string + description: string + items: AppSpaceItem[] + segments?: AppSpaceSegment[] +} + +export interface AppSpaceData { + eventId: number + eventName: string + eventDescription?: string + hasLogo?: boolean + authenticated?: boolean + appSpaceGameEnabled?: boolean + type: string + paths: AppSpaceTrack[] +} + +// Task 2+ interfaces: Level model for app-space gameplay +export interface Coin { + title: string + done: boolean + available: boolean +} + +export interface Gate { + afterCoinIndex: number +} + +export interface Goal { + kind: 'prize' | 'checkpoint' | 'track' + label: string + prizeId: number | null + trackId: string +} + +// x in coin-index units (hazards sit between coins, e.g. 1.5); w/h in world units. +// crate = solid block you must jump over or onto; spike = low hazard that costs rings. +export interface Obstacle { x: number; w: number; h: number; kind: 'crate' | 'spike' } +export interface Pit { x: number; w: number } + +export interface Zone { + id: string + title: string + colorIndex: number + cleared: boolean + locked: boolean + coins: Coin[] + gates: Gate[] + goal: Goal + obstacles: Obstacle[] + pits: Pit[] +} + +export interface LevelModel { + zones: Zone[] +} diff --git a/hugo-apps/src/app-space/AppSpace.vue b/hugo-apps/src/app-space/AppSpace.vue index 1582a9dd6..0537f01ed 100644 --- a/hugo-apps/src/app-space/AppSpace.vue +++ b/hugo-apps/src/app-space/AppSpace.vue @@ -5,6 +5,7 @@ import { useConfetti } from '../composables/useConfetti' import { checkpointBarcodeUrl as _checkpointBarcodeUrl, shouldShowCheckpointBarcode as _shouldShowCheckpointBarcode } from './checkpoint-barcode' import { isItemUnlocked as _isItemUnlocked, isTutorialOpenable as _isTutorialOpenable } from './app-space-gating' import type { GatingItem } from './app-space-gating' +import { trackTotalTime } from './app-space-time' // ── Configuration ───────────────────────────────────────────────── const eventId = ref(null) @@ -393,10 +394,9 @@ function completedCount(track: AppSpaceTrack): number { return tutorialItems(track).filter(i => i.status === 'COMPLETED').length } -function trackTotalTime(track: AppSpaceTrack): number { - if (hasSegments(track)) return segmentTutorials(track).reduce((sum, i) => sum + i.timeToComplete, 0) - return tutorialItems(track).reduce((sum, i) => sum + i.timeToComplete, 0) -} +// #3007: trackTotalTime now lives in ./app-space-time (imported above). Branching +// (fork / parallel-any) segments are AVERAGED across their optional paths rather +// than summed, so a track's headline time reflects the one path a learner walks. // #2887 authored completion rule: a track is complete when its reward is // satisfiable. A segment carrying a synthetic lane prize (CHECKPOINT+prizeId) diff --git a/hugo-apps/src/app-space/__tests__/app-space-time.test.ts b/hugo-apps/src/app-space/__tests__/app-space-time.test.ts new file mode 100644 index 000000000..06f751f98 --- /dev/null +++ b/hugo-apps/src/app-space/__tests__/app-space-time.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest' +import { trackTotalTime } from '../app-space-time' +import type { TimeTrack } from '../app-space-time' + +// #3007: a track's "average time to complete" must not sum every tutorial when +// the track branches. Mandatory (linear) segments sum as-is; each branch segment +// contributes the AVERAGE of its alternatives' totals — the sum of all optional +// path times divided by the number of optional paths. + +function tut(m: number) { + return { type: 'TUTORIAL' as const, timeToComplete: m } +} + +describe('trackTotalTime (#3007)', () => { + it('sums a single linear segment (20 + 40 = 60)', () => { + const track: TimeTrack = { + segments: [{ order: 1, kind: 'linear', items: [tut(20), tut(40)] }], + } + expect(trackTotalTime(track)).toBe(60) + }) + + it('averages the lanes of a fork segment (lane A 30, lane B 50 => 40)', () => { + const track: TimeTrack = { + segments: [ + { order: 1, kind: 'fork', lanes: [ + { label: 'A', items: [tut(30)] }, + { label: 'B', items: [tut(50)] }, + ] }, + ], + } + expect(trackTotalTime(track)).toBe(40) + }) + + it('averages a fork over per-lane TOTALS, not per-tutorial (lane A 10+20=30, lane B 50 => 40)', () => { + const track: TimeTrack = { + segments: [ + { order: 1, kind: 'fork', lanes: [ + { label: 'A', items: [tut(10), tut(20)] }, + { label: 'B', items: [tut(50)] }, + ] }, + ], + } + expect(trackTotalTime(track)).toBe(40) + }) + + it('adds mandatory linear time to averaged fork time (60 + avg(30,50)=40 => 100)', () => { + const track: TimeTrack = { + segments: [ + { order: 1, kind: 'linear', items: [tut(20), tut(40)] }, + { order: 2, kind: 'fork', lanes: [ + { label: 'A', items: [tut(10), tut(20)] }, + { label: 'B', items: [tut(50)] }, + ] }, + ], + } + expect(trackTotalTime(track)).toBe(100) + }) + + it('averages a parallel-any cluster — pick any one (20, 40, 60 => 40)', () => { + const track: TimeTrack = { + segments: [{ order: 1, kind: 'parallel-any', items: [tut(20), tut(40), tut(60)] }], + } + expect(trackTotalTime(track)).toBe(40) + }) + + it('sums contributions across multiple branch segments (linear 60 + fork avg 40 + cluster avg 30 => 130)', () => { + const track: TimeTrack = { + segments: [ + { order: 1, kind: 'linear', items: [tut(60)] }, + { order: 2, kind: 'fork', lanes: [ + { label: 'A', items: [tut(30)] }, + { label: 'B', items: [tut(50)] }, + ] }, // avg 40 + { order: 3, kind: 'parallel-any', items: [tut(20), tut(40)] }, // avg 30 + ], + } + expect(trackTotalTime(track)).toBe(130) + }) + + it('ignores non-tutorial items (synthetic prize checkpoints have timeToComplete 0)', () => { + const track: TimeTrack = { + segments: [ + { order: 1, kind: 'fork', lanes: [ + { label: 'A', items: [tut(40), { type: 'CHECKPOINT', timeToComplete: 0 }] }, + { label: 'B', items: [tut(60)] }, + ] }, + ], + } + expect(trackTotalTime(track)).toBe(50) + }) + + it('falls back to flat items[] when a track has no segments (legacy payload)', () => { + const track: TimeTrack = { + items: [tut(15), tut(45), { type: 'CHECKPOINT', timeToComplete: 0 }], + } + expect(trackTotalTime(track)).toBe(60) + }) + + it('returns 0 for an empty track', () => { + expect(trackTotalTime({ segments: [] })).toBe(0) + expect(trackTotalTime({ items: [] })).toBe(0) + expect(trackTotalTime({})).toBe(0) + }) + + it('treats an empty fork lane list as contributing 0 (no divide-by-zero)', () => { + const track: TimeTrack = { + segments: [{ order: 1, kind: 'fork', lanes: [] }], + } + expect(trackTotalTime(track)).toBe(0) + }) +}) diff --git a/hugo-apps/src/app-space/app-space-time.ts b/hugo-apps/src/app-space/app-space-time.ts new file mode 100644 index 000000000..5b1a92e83 --- /dev/null +++ b/hugo-apps/src/app-space/app-space-time.ts @@ -0,0 +1,81 @@ +/** + * Pure time-aggregation helper for the App Space track overview. + * + * Extracted to a sibling module (same pattern as app-space-gating.ts / + * checkpoint-barcode.ts) so AppSpace.vue and tests can share it without mounting + * the full component. + * + * #3007 "Average time to complete": a track's headline time must NOT sum every + * tutorial when the track branches. A branch is an OPTIONAL choice — the learner + * walks one path, not all of them — so summing every alternative overstates the + * time (the TechEd "Joule Agent Lab" fork reported the combined time of both + * branches). Instead: + * + * - linear (mandatory run of items) → SUM every tutorial + * - parallel-any (pick ANY ONE of N tutorials) → AVERAGE the N tutorial times + * - fork (pick ONE of N lanes/groups) → AVERAGE the N per-lane totals + * + * Track total = sum of linear times + the per-branch averages. "Average" here is + * exactly the issue's definition: sum of the optional paths' times divided by the + * number of optional paths. Only TUTORIAL items carry time (synthetic prize + * CHECKPOINTs are timeToComplete 0), so non-tutorials wash out of the math. + */ + +export interface TimeItem { + type: 'TUTORIAL' | 'CHECKPOINT' | 'PRIZE' + timeToComplete: number +} + +export interface TimeForkLane { + label: string + items: TimeItem[] +} + +export type TimeSegment = + | { order: number; kind: 'linear'; items: TimeItem[] } + | { order: number; kind: 'parallel-any'; items: TimeItem[] } + | { order: number; kind: 'fork'; lanes: TimeForkLane[] } + +export interface TimeTrack { + items?: TimeItem[] + segments?: TimeSegment[] +} + +/** Sum the time of only the TUTORIAL items in a list. */ +function sumTutorialTime(items: TimeItem[]): number { + return items.reduce((sum, i) => (i.type === 'TUTORIAL' ? sum + (i.timeToComplete || 0) : sum), 0) +} + +/** Mean of a list of numbers; 0 for an empty list (no divide-by-zero). */ +function mean(values: number[]): number { + if (values.length === 0) return 0 + return values.reduce((a, b) => a + b, 0) / values.length +} + +/** Time contributed by one segment, per the #3007 branching rules. */ +function segmentTime(seg: TimeSegment): number { + if (seg.kind === 'fork') { + // Each lane is one optional path; its cost is the sum of its tutorials. + return mean(seg.lanes.map(lane => sumTutorialTime(lane.items))) + } + if (seg.kind === 'parallel-any') { + // Each tutorial is one optional path; the learner completes exactly one. + const times = seg.items.filter(i => i.type === 'TUTORIAL').map(i => i.timeToComplete || 0) + return mean(times) + } + // linear: mandatory — every tutorial counts. + return sumTutorialTime(seg.items) +} + +/** + * Total "average time to complete" for a track, in the same unit as + * `item.timeToComplete` (minutes). Prefers the segment structure (#2887) when + * present so branching paths are averaged; falls back to summing the legacy flat + * `items[]` for older payloads that predate segments. + */ +export function trackTotalTime(track: TimeTrack): number { + if (Array.isArray(track.segments) && track.segments.length > 0) { + return track.segments.reduce((sum, seg) => sum + segmentTime(seg), 0) + } + return sumTutorialTime(track.items ?? []) +} diff --git a/hugo-apps/vite.config.ts b/hugo-apps/vite.config.ts index 82f6c9e92..776c10a69 100644 --- a/hugo-apps/vite.config.ts +++ b/hugo-apps/vite.config.ts @@ -297,6 +297,7 @@ export default defineConfig({ input: { navigator: resolve(__dirname, 'src/navigator/main.ts'), 'app-space': resolve(__dirname, 'src/app-space/main.ts'), + 'app-space-game': resolve(__dirname, 'src/app-space-game/main.ts'), devtoberfest: resolve(__dirname, 'src/devtoberfest/main.ts'), gameboard: resolve(__dirname, 'src/gameboard/main.ts'), arcade: resolve(__dirname, 'src/arcade/main.ts'), diff --git a/hugo/assets/css/directives-ui.css b/hugo/assets/css/directives-ui.css new file mode 100644 index 000000000..66b27d8ce --- /dev/null +++ b/hugo/assets/css/directives-ui.css @@ -0,0 +1,170 @@ +/* hugo/assets/css/directives-ui.css + * + * Styles for the five UI instruction directives: + * tut-menu — navigation menu path (breadcrumb-style) + * tut-kbd — keyboard shortcut + * tut-icon — UI5 inline icon + * tut-ui — UI element name (button/field/tab/link) + * tut-ask — "Ask Joule" contextual AI button + * + * Uses SAP Horizon design tokens throughout. Tokens auto-resolve in both + * light and dark mode when the UI5 theme is loaded; no manual dark-mode + * overrides are needed for token-based values. + * + * Related: hugo/layouts/shortcodes/tut-*.html + */ + +/* --------------------------------------------------------------------------- + * tut-menu — inline menu navigation path + * e.g. Tools › Connections › New Connection + * ------------------------------------------------------------------------- */ + +.tut-menu { + display: inline-flex; + align-items: center; + gap: 0.25em; + flex-wrap: wrap; + vertical-align: middle; +} + +.tut-menu__item { + display: inline-block; + border: 1px solid var(--sapField_BorderColor, #89919a); + border-radius: 3px; + padding: 0.05em 0.35em; + font-size: 0.9em; + white-space: nowrap; + color: var(--sapContent_LabelColor, #556b82); + background: var(--sapButton_Background, #fff); +} + +.tut-menu__sep { + color: var(--sapContent_LabelColor, #556b82); + font-size: 0.75em; + user-select: none; + line-height: 1; + padding: 0 0.1em; +} + +/* --------------------------------------------------------------------------- + * tut-kbd — keyboard shortcut + * e.g. Ctrl+S, ⌘S + * ------------------------------------------------------------------------- */ + +.tut-kbd { + font-family: monospace; + font-size: 0.875em; + border: 1px solid var(--sapField_BorderColor, #89919a); + border-radius: 3px; + padding: 0.1em 0.4em; + background: var(--sapButton_Background, #fff); + color: var(--sapContent_LabelColor, #556b82); + white-space: nowrap; + /* Subtle box-shadow gives physical key appearance */ + box-shadow: 0 1px 0 var(--sapField_BorderColor, #89919a); + vertical-align: middle; +} + +/* --------------------------------------------------------------------------- + * tut-icon — inline UI5 icon + * Sized to match surrounding text; color follows content icon token. + * ------------------------------------------------------------------------- */ + +.tut-icon { + width: 1em; + height: 1em; + vertical-align: middle; + color: var(--sapContent_IconColor, #0070f2); + /* Align baseline with surrounding text */ + position: relative; + top: -0.05em; +} + +/* --------------------------------------------------------------------------- + * tut-ui — UI element name (button, field, tab, link) + * Renders the visible label of a UI element in bold with a subtle border. + * ------------------------------------------------------------------------- */ + +.tut-ui { + font-weight: 600; + border: 1px solid var(--sapField_BorderColor, #89919a); + border-radius: 3px; + padding: 0 0.25em; + white-space: nowrap; + color: var(--sapContent_LabelColor, #556b82); + background: var(--sapButton_Background, #fff); +} + +/* Type modifiers: subtle visual distinction without heavy styling */ +.tut-ui--button { + background: var(--sapButton_Background, #fff); + border-color: var(--sapUiHighlight, #0070f2); + color: var(--sapUiHighlight, #0070f2); +} + +.tut-ui--tab { + border-bottom: 2px solid var(--sapUiHighlight, #0070f2); + border-radius: 3px 3px 0 0; +} + +.tut-ui--field { + font-weight: 400; + font-style: italic; +} + +.tut-ui--link { + color: var(--sapUiHighlight, #0070f2); + text-decoration: underline; + border: none; + padding: 0; + background: transparent; + font-weight: 600; +} + +/* --------------------------------------------------------------------------- + * tut-ask — "Ask Joule" contextual AI button + * Hidden by default ([hidden] attr); JS reveals it when Joule is available. + * ------------------------------------------------------------------------- */ + +.tut-ask { + display: inline-flex; + align-items: center; + gap: 0.3em; + cursor: pointer; + font-size: 0.85em; + border: 1px solid var(--sapUiHighlight, #0070f2); + border-radius: 3px; + padding: 0.15em 0.5em; + background: var(--sapButton_Background, #fff); + color: var(--sapUiHighlight, #0070f2); + font-family: inherit; + vertical-align: middle; + transition: background 120ms ease, color 120ms ease; +} + +.tut-ask:hover { + background: var(--sapUiHighlight, #0070f2); + color: var(--sapButton_Background, #fff); +} + +.tut-ask:focus-visible { + outline: 2px solid var(--sapContent_FocusColor, #0854a0); + outline-offset: 2px; +} + +/* [hidden] must override display:inline-flex */ +.tut-ask[hidden] { + display: none; +} + +/* --------------------------------------------------------------------------- + * Print: hide interactive / icon-only elements + * tut-menu and tut-kbd print as-is (their text content is meaningful in print) + * ------------------------------------------------------------------------- */ + +@media print { + .tut-ask, + .tut-icon { + display: none; + } +} diff --git a/hugo/assets/css/qa-authoring.css b/hugo/assets/css/qa-authoring.css new file mode 100644 index 000000000..43216465d --- /dev/null +++ b/hugo/assets/css/qa-authoring.css @@ -0,0 +1,41 @@ +/* QA authoring aids — only loaded when site.Params.qa is true */ + +/* qa-only frame */ +.tut-qa-only-frame { + border: 2px dashed #B58A00; + background: #FFFBE6; + padding: .75rem 1rem; + margin: 1rem 0; + border-radius: 4px; +} +.tut-qa-only-label { + display: block; + font-weight: 700; + color: #5C3D00; + margin-bottom: .5rem; + text-transform: uppercase; + font-size: .8rem; + letter-spacing: .05em; +} +[data-theme="dark"] .tut-qa-only-frame { background: #2a2200; border-color: #b58a00; } +[data-theme="dark"] .tut-qa-only-label { color: #f0c040; } + +/* author notes */ +.tut-author-note { + background: #FFF3CD; + border: 1px solid #FFC107; + border-radius: 3px; + padding: 0 .3em; + font-style: italic; + color: #856404; +} +.tut-author-note-block { + background: #FFF3CD; + border-left: 4px solid #FFC107; + padding: .5rem .75rem; + margin: .75rem 0; + color: #856404; + font-style: italic; +} +[data-theme="dark"] .tut-author-note, +[data-theme="dark"] .tut-author-note-block { background: #2a1f00; border-color: #ffc107; color: #ffc107; } diff --git a/hugo/assets/css/rich-media.css b/hugo/assets/css/rich-media.css new file mode 100644 index 000000000..67769c0dd --- /dev/null +++ b/hugo/assets/css/rich-media.css @@ -0,0 +1,65 @@ +/* rich-media.css — styles for directive-powered rich media components */ + +/* Figure numbering */ +body { counter-reset: tut-figure; } +.tut-figure { counter-increment: tut-figure; } +.tut-figure__num::before { content: "Figure " counter(tut-figure) ": "; } +.tut-figure { margin: 1.5rem 0; } +.tut-figure__images { display: flex; gap: 0.5rem; flex-wrap: wrap; justify-content: center; } +.tut-figure__caption { text-align: center; font-style: italic; margin-top: 0.5rem; color: var(--sapContent_LabelColor, #6a6d70); } + +/* Compare slider */ +.tut-compare { position: relative; max-width: 100%; } +.tut-compare__images { position: relative; overflow: hidden; } +.tut-compare__before { display: block; width: 100%; } +.tut-compare__after-wrap { position: absolute; inset: 0; overflow: hidden; clip-path: inset(0 50% 0 0); } +.tut-compare__after { display: block; width: 100%; } +.tut-compare__slider { width: 100%; margin-top: 0.5rem; cursor: col-resize; } +@media print { .tut-compare__slider { display: none; } } + +/* Hotspots */ +.tut-hotspot-wrap { position: relative; display: inline-block; } +.tut-hotspot { + position: absolute; + display: inline-flex; align-items: center; justify-content: center; + width: 1.5rem; height: 1.5rem; + border-radius: 50%; + background: var(--sapBrandColor, #0070f2); + color: #fff; + font-size: 0.75rem; font-weight: bold; + text-decoration: none; + transform: translate(-50%, -50%); + cursor: pointer; +} +.tut-hotspot--active, .tut-hotspot:focus { outline: 2px solid var(--sapBrandColor, #0070f2); outline-offset: 2px; } +li.tut-hotspot--active { background: var(--sapHighlightColor, #e8f4fd); border-radius: 4px; } + +/* Cast (terminal recording) */ +.tut-cast { background: #1e1e1e; border-radius: 4px; overflow: hidden; min-height: 6rem; } +.tut-cast__placeholder { color: #aaa; padding: 1rem; margin: 0; font-family: monospace; } + +/* Diagram */ +.tut-diagram { margin: 1.5rem 0; text-align: center; } +.tut-diagram img { max-width: 100%; height: auto; } +.tut-diagram figcaption { font-style: italic; color: var(--sapContent_LabelColor, #6a6d70); margin-top: 0.5rem; } +/* Dark mode diagram inversion when theme=auto */ +.tut-diagram[data-theme="auto"] img:not([data-src-dark]) { + filter: none; +} +@media (prefers-color-scheme: dark) { + .tut-diagram[data-theme="auto"] img:not([data-src-dark]) { filter: invert(1) hue-rotate(180deg); } +} + +/* Interactive tables */ +table.sortable th button { + background: none; border: none; cursor: pointer; font: inherit; font-weight: bold; + padding: 0; display: flex; align-items: center; gap: 0.25rem; +} +table.sortable th button[aria-sort="ascending"]::after { content: " ↑"; } +table.sortable th button[aria-sort="descending"]::after { content: " ↓"; } +table.filterable + input[type="search"], +input[type="search"]:has(+ table.filterable) { + margin-bottom: 0.5rem; padding: 0.25rem 0.5rem; width: 100%; + border: 1px solid var(--sapField_BorderColor, #89919a); border-radius: 4px; +} +table.sticky thead th { position: sticky; top: 0; background: var(--sapBackgroundColor, #fff); z-index: 1; } diff --git a/hugo/assets/css/sap-fundamental.src.css b/hugo/assets/css/sap-fundamental.src.css index 20cb30780..2869632e9 100644 --- a/hugo/assets/css/sap-fundamental.src.css +++ b/hugo/assets/css/sap-fundamental.src.css @@ -907,6 +907,100 @@ blockquote.alert-caution { background-color: var(--sapErrorBackground, #ffeaf4); } +/* ── Callout enhancements (#callout-enhancements) ───────────────────────── + Icons, titles, collapsible types, spoiler, print rules. */ + +/* Shared aside/details reset so .alert-* rules apply identically */ +aside.alert, details.alert { + border-left: 4px solid var(--sapInformationBorderColor, #0070f2); + background-color: var(--sapInformationBackground, #e1f4ff); + padding: 0.75rem 1rem; + border-radius: 0 0.25rem 0.25rem 0; + color: var(--sapTextColor, #32363a); + margin: 1rem 0; +} + +/* Icon + type label layout */ +.alert__icon { margin-right: 0.375rem; font-size: 1rem; vertical-align: middle; } +.alert__title { font-weight: 600; margin-bottom: 0.25rem; } +.alert__body > :first-child { margin-top: 0; } +.alert__body > :last-child { margin-bottom: 0; } + +/* Summary row for collapsible alerts */ +details.alert > summary.alert__summary { + cursor: pointer; + list-style: none; + display: flex; + align-items: center; + font-weight: 600; + padding: 0; + margin: 0; +} +details.alert > summary.alert__summary::-webkit-details-marker { display: none; } +details.alert > summary.alert__summary::marker { display: none; } +details.alert > summary.alert__summary::before { + content: '▶'; + font-size: 0.65rem; + margin-right: 0.5rem; + transition: transform 0.15s; + display: inline-block; +} +details.alert[open] > summary.alert__summary::before { transform: rotate(90deg); } +details.alert > .alert__body { margin-top: 0.5rem; } + +/* New type colors (aside + details variants) */ +aside.alert-result, details.alert-result { + border-left-color: var(--sapSuccessBorderColor, #30914c); + background-color: var(--sapSuccessBackground, #f5fae5); +} +aside.alert-details, details.alert-details { + border-left-color: var(--sapNeutralBorderColor, #8696a9); + background-color: var(--sapNeutralBackground, #f2f2f2); +} +aside.alert-troubleshoot, details.alert-troubleshoot { + border-left-color: var(--sapWarningBorderColor, #dd6100); + background-color: var(--sapWarningBackground, #fff8d6); +} +aside.alert-hint, details.alert-hint { + border-left-color: var(--sapInformationBorderColor, #0070f2); + background-color: var(--sapInformationBackground, #e1f4ff); +} +aside.alert-solution, details.alert-solution { + border-left-color: var(--sapSuccessBorderColor, #30914c); + background-color: var(--sapSuccessBackground, #f5fae5); +} + +/* Spoiler inline element */ +.tut-spoiler { + background-color: currentColor; + color: transparent; + border-radius: 0.2em; + cursor: pointer; + user-select: none; + padding: 0 0.15em; + transition: color 0.2s, background-color 0.2s; +} +.tut-spoiler:focus-visible { outline: 2px solid var(--sapFocusColor, #0070f2); } +.tut-spoiler[aria-pressed="true"] { + background-color: transparent; + color: inherit; +} + +/* Progressive reveal: hint N+1 disabled until hint N opened */ +details.alert-hint[data-hint-disabled] { + opacity: 0.5; + pointer-events: none; +} +details.alert-hint[data-hint-disabled] > summary { pointer-events: none; } + +/* Print: force all collapsible alerts open */ +@media print { + details.alert { display: block; } + details.alert > summary.alert__summary { display: block; } + details.alert > .alert__body { display: block; } + .tut-spoiler { background-color: transparent !important; color: inherit !important; } +} + /* Tutorial meta bar (tags + time) */ .tutorial-meta { display: flex; @@ -1259,6 +1353,33 @@ ui5-li .step-badge-row { line-height: 1.3; } +/* [tutorial-step-pacing] Per-step time chip + Optional badge, rendered by + hugo/layouts/shortcodes/tutorial-step.html next to the step title. */ +.step-time-chip, +.step-optional-badge { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + padding: 0.0625rem 0.5rem; + border-radius: 999px; + margin-left: 0.5rem; + vertical-align: middle; +} + +.step-time-chip { + background: var(--sapInfobar_Background, var(--sapList_Background, #f5f6f7)); + color: var(--sapContent_LabelColor, #556b82); + border: 1px solid var(--sapList_BorderColor, #e5e5e5); +} + +.step-optional-badge { + background: var(--sapWarningBackground, #fef7f1); + color: var(--sapWarningColor, #e76500); + border: 1px solid var(--sapWarningBorderColor, #e9730c); +} + .step-toggle-icon { font-size: 1.25rem; font-weight: 400; @@ -1346,7 +1467,7 @@ ui5-li .step-badge-row { */ .step-content > .os-options, .step-content > .option-tabs, -.step-content > blockquote.alert, +.step-content > :is(blockquote, aside, details).alert, /* #2820 — the OPTION group also detaches the PROSE/IMAGE/code that trails it * within the same substep (e.g. "Set the install directory…", the install * screenshot). Those land as column-0 direct children of .step-content after @@ -1358,12 +1479,12 @@ ui5-li .step-badge-row { .step-content > .os-options ~ figure, .step-content > .os-options ~ pre, .step-content > .os-options ~ .code-block, -.step-content > .os-options ~ blockquote, +.step-content > .os-options ~ :is(blockquote, aside, details), .step-content > .option-tabs ~ p, .step-content > .option-tabs ~ figure, .step-content > .option-tabs ~ pre, .step-content > .option-tabs ~ .code-block, -.step-content > .option-tabs ~ blockquote { +.step-content > .option-tabs ~ :is(blockquote, aside, details) { /* #2820 — tutorial code fences render as `
` (the * `
` is nested inside, so the `~ pre` entry above never matches them);
    * a code box that TRAILS an OPTION group within the same substep must get
@@ -1468,6 +1589,46 @@ ui5-li .step-badge-row {
   opacity: 1;
 }
 
+/* [tutorial-step-pacing] Checklist "all checked" emphasis — visual nudge
+   only, never auto-marks the step done (design D4 "All checked" scenario). */
+.step-actions button.is-checklist-complete:not(.is-completed) {
+  box-shadow: 0 0 0 2px var(--sapPositiveColor, #30914c);
+}
+
+.tutorial-step.skipped .step-header {
+  opacity: 0.6;
+}
+
+/* Interactive checklists (task-lists.ts) */
+.task-list-counter {
+  font-size: 0.75rem;
+  font-weight: 600;
+  color: var(--sapContent_LabelColor, #556b82);
+  margin: 0.5rem 0 0.25rem;
+}
+
+.step-content li.task-list-item {
+  list-style: none;
+  margin-left: -1.5rem;
+}
+
+.step-content li.task-list-item .task-list-checkbox {
+  margin-right: 0.5rem;
+}
+
+.step-content li.task-list-item .task-list-item-label {
+  cursor: pointer;
+}
+
+/* [tutorial-step-pacing] Remaining-time indicator — populated + shown by
+   tutorial.ts's updateRemainingTime(); hidden by default in markup. */
+.op-chip--remaining-time,
+.tutorial-remaining-time {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.25rem;
+}
+
 .step-validation-mount:empty {
   display: none;
 }
@@ -2780,6 +2941,44 @@ html.dark .code-block-body .chroma .err {
   background-color: transparent;
 }
 
+/* Console blocks — prompt/output/continuation line styling */
+.cl-out { opacity: 0.5; user-select: none; }
+.cl-cmd { /* default: inherit */ }
+.cl-cont { padding-left: 1.5em; }
+html.dark .cl-out { opacity: 0.4; }
+
+/* Diff markers */
+.diff-added  { background: rgba(0,200,80,.12); display: block; }
+.diff-removed { background: rgba(220,50,50,.12); display: block; text-decoration: line-through; opacity: .6; }
+.diff-highlight { background: rgba(200,160,0,.12); display: block; }
+.diff-legend { font-size: .75em; opacity: .6; margin-left: auto; padding-right: .25rem; }
+/* Focus: dim all lines until block is hovered */
+.code-block-body:has(.diff-focus):not(:hover) .line:not(.diff-focus) { opacity: .35; }
+html.dark .diff-added  { background: rgba(0,200,80,.18); }
+html.dark .diff-removed { background: rgba(220,50,50,.18); }
+html.dark .diff-highlight { background: rgba(200,160,0,.18); }
+
+/* Tree fences */
+[data-tree] [role="tree"] { list-style: none; padding-left: 0; margin: 0; font-family: var(--sapFontCodeFamily, monospace); font-size: .9em; }
+[data-tree] [role="treeitem"] { display: block; padding: .1rem 0; white-space: pre; }
+.tree-new      { color: var(--sapPositiveColor, #256f3a); }
+.tree-modified { color: var(--sapInformativeColor, #0064d9); }
+.tree-deleted  { color: var(--sapNegativeColor, #aa0808); opacity: .65; text-decoration: line-through; }
+.tree-annotation { opacity: .55; font-size: .85em; margin-left: .75rem; font-style: italic; }
+
+/* Walkthrough directive */
+.tut-walkthrough { display: grid; grid-template-columns: 1fr; gap: 1rem; margin: 1.5rem 0; }
+@media (min-width: 1024px) {
+  .tut-walkthrough { grid-template-columns: 1fr 1fr; align-items: start; }
+  .tut-walkthrough-code { position: sticky; top: 4rem; }
+}
+.tut-walkthrough-notes ol, .tut-walkthrough-notes ul { padding-left: 1.25rem; }
+.tut-walkthrough-notes li { cursor: pointer; padding: .25rem .5rem; border-radius: .25rem; transition: background .15s; }
+.tut-walkthrough-notes li.wt-active,
+.tut-walkthrough-notes li:hover { background: var(--sapHighlightColor, #0064d9); color: #fff; }
+.wt-hl { background: rgba(0,100,220,.15); display: block; }
+html.dark .wt-hl { background: rgba(100,180,255,.2); }
+
 /* Chroma readability overrides (dark mode).
  * Hugo's `gen chromastyles --style=github-dark` emits empty rules for
  * unstyled tokens (.p, .n, .nx, .x, .na, .nb, .bp), expecting them to
diff --git a/hugo/assets/js/__tests__/cast.test.ts b/hugo/assets/js/__tests__/cast.test.ts
new file mode 100644
index 000000000..ddf52fd9d
--- /dev/null
+++ b/hugo/assets/js/__tests__/cast.test.ts
@@ -0,0 +1,66 @@
+// @vitest-environment happy-dom
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+
+// Provide a minimal AsciinemaPlayer stub on window.
+const mockCreate = vi.fn();
+beforeEach(() => {
+  vi.resetModules();
+  document.body.innerHTML = '';
+  (window as unknown as Record)['AsciinemaPlayer'] = { create: mockCreate };
+  mockCreate.mockClear();
+});
+
+it('mounts player on .tut-cast elements in viewport', async () => {
+  document.body.innerHTML = `
+    
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); // bypass IO for unit test + expect(mockCreate).toHaveBeenCalledOnce(); + const [src, el, opts] = mockCreate.mock.calls[0]; + expect(src).toBe('https://example.com/demo.cast'); + expect(el).toBeInstanceOf(HTMLElement); + expect(opts.rows).toBe(24); + expect(opts.speed).toBe(1.5); +}); + +it('does not double-mount an already mounted element', async () => { + document.body.innerHTML = ` +
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); + expect(mockCreate).not.toHaveBeenCalled(); +}); + +it('falls back to immediate mount when IntersectionObserver absent', async () => { + document.body.innerHTML = ` +
`; + // Remove IO from window to simulate absent support. + const origIO = (window as unknown as Record)['IntersectionObserver']; + delete (window as unknown as Record)['IntersectionObserver']; + const { initCast } = await import('../cast.js'); + await initCast({}); + expect(mockCreate).toHaveBeenCalledOnce(); + (window as unknown as Record)['IntersectionObserver'] = origIO; +}); + +it('passes idle-limit as idleTimeLimit to player', async () => { + document.body.innerHTML = ` +
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); + const [, , opts] = mockCreate.mock.calls[0]; + expect(opts.idleTimeLimit).toBe(2); +}); + +it('removes the placeholder pre after mounting', async () => { + document.body.innerHTML = ` +
+
Loading…
+
`; + const { initCast } = await import('../cast.js'); + await initCast({ skipObserver: true }); + expect(document.querySelector('.tut-cast__placeholder')).toBeNull(); +}); diff --git a/hugo/assets/js/__tests__/hotspots.test.ts b/hugo/assets/js/__tests__/hotspots.test.ts new file mode 100644 index 000000000..4b44efcf4 --- /dev/null +++ b/hugo/assets/js/__tests__/hotspots.test.ts @@ -0,0 +1,27 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +let document: Document; + +beforeEach(() => { + vi.resetModules(); + document = window.document; + document.body.innerHTML = ''; +}); + +it('binds focus on marker to highlight list item', async () => { + document.body.innerHTML = ` +
+ + 1 +
+
    +
  1. Step one
  2. +
`; + const { initHotspots } = await import('../hotspots.js'); + initHotspots(); + const marker = document.querySelector('.tut-hotspot') as HTMLElement; + marker.dispatchEvent(new Event('focus')); + const li = document.querySelector('li'); + expect(li?.classList.contains('tut-hotspot--active')).toBe(true); +}); diff --git a/hugo/assets/js/__tests__/table-enhance.test.ts b/hugo/assets/js/__tests__/table-enhance.test.ts new file mode 100644 index 000000000..707561f1d --- /dev/null +++ b/hugo/assets/js/__tests__/table-enhance.test.ts @@ -0,0 +1,62 @@ +// @vitest-environment happy-dom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +beforeEach(() => { + vi.resetModules(); + document.body.innerHTML = ''; +}); + +describe('table-enhance', () => { + it('adds aria-sort button to sortable table headers', async () => { + document.body.innerHTML = ` + + + +
NameValue
B2
A1
`; + const { initTableEnhance } = await import('../table-enhance.js'); + initTableEnhance(); + const btn = document.querySelector('th button'); + expect(btn).not.toBeNull(); + expect(document.querySelector('[aria-sort]')).not.toBeNull(); + }); + + it('adds filter input above filterable table', async () => { + document.body.innerHTML = ` + + + +
Item
HANA
BTP
`; + const { initTableEnhance } = await import('../table-enhance.js'); + initTableEnhance(); + expect(document.querySelector('input[type="search"]')).not.toBeNull(); + }); + + it('sorting numerically sorts rows', async () => { + document.body.innerHTML = ` + + + + + + + +
Num
10
2
20
`; + const { initTableEnhance } = await import('../table-enhance.js'); + initTableEnhance(); + const btn = document.querySelector('th button') as HTMLButtonElement; + btn.click(); + const cells = Array.from(document.querySelectorAll('tbody td')).map(td => td.textContent); + expect(cells).toEqual(['2', '10', '20']); + }); + + it('does not touch tables without sortable/filterable class', async () => { + document.body.innerHTML = ` + + + +
Name
A
`; + const { initTableEnhance } = await import('../table-enhance.js'); + initTableEnhance(); + expect(document.querySelector('th button')).toBeNull(); + }); +}); diff --git a/hugo/assets/js/callout-hints.ts b/hugo/assets/js/callout-hints.ts new file mode 100644 index 000000000..b7c636b33 --- /dev/null +++ b/hugo/assets/js/callout-hints.ts @@ -0,0 +1,39 @@ +// callout-hints.ts — progressive reveal for consecutive hint alerts +// Finds
groups within each .step-content. +// Requires: nothing (pure DOM). +// Called: DOMContentLoaded. + +document.addEventListener('DOMContentLoaded', () => { + document.querySelectorAll('.step-content').forEach(step => { + const hints = [...step.querySelectorAll('details.alert-hint')] + // Group consecutive hints (they must be adjacent siblings) + const groups: HTMLDetailsElement[][] = [] + let group: HTMLDetailsElement[] = [] + for (const hint of hints) { + if (group.length === 0 || hint.previousElementSibling === group[group.length - 1]) { + group.push(hint) + } else { + if (group.length > 1) groups.push(group) + group = [hint] + } + } + if (group.length > 1) groups.push(group) + + // For each group, wire progressive reveal + groups.forEach(g => { + // Number the hints + g.forEach((hint, i) => { + const summary = hint.querySelector('summary') + if (summary) summary.textContent = `Hint ${i + 1}` + }) + // Disable hints 2..N + for (let i = 1; i < g.length; i++) g[i].setAttribute('data-hint-disabled', '') + // Wire open event + for (let i = 0; i < g.length - 1; i++) { + g[i].addEventListener('toggle', () => { + if (g[i].open) g[i + 1].removeAttribute('data-hint-disabled') + }, { once: true }) + } + }) + }) +}) diff --git a/hugo/assets/js/cast.ts b/hugo/assets/js/cast.ts new file mode 100644 index 000000000..941f6a12a --- /dev/null +++ b/hugo/assets/js/cast.ts @@ -0,0 +1,117 @@ +// cast.ts — lazy-mount asciinema player on .tut-cast[data-tut-cast] elements. +// The asciinema-player is vendored as an IIFE (sets window.AsciinemaPlayer), +// so we inject a +
+ {{ end }} diff --git a/hugo/layouts/partials/head.html b/hugo/layouts/partials/head.html index 4f03747e2..3170ef7e8 100644 --- a/hugo/layouts/partials/head.html +++ b/hugo/layouts/partials/head.html @@ -66,6 +66,7 @@ {{ $lightbox := resources.Get "css/lightbox.css" | minify | fingerprint }} {{ $skeletons := resources.Get "css/skeletons.css" | minify | fingerprint }} {{ $missionSideNav := resources.Get "css/mission-side-nav.css" | minify | fingerprint }} +{{ $directivesUi := resources.Get "css/directives-ui.css" | minify | fingerprint }} @@ -85,6 +86,8 @@ +{{ if site.Params.qa }}{{ $qaAuthoring := resources.Get "css/qa-authoring.css" | minify | fingerprint }}{{ end }} + +{{/* [tutorial-step-pacing] see u1-object-page.html for rationale. */}} +{{ $taskLists := resources.Get "js/task-lists.ts" | js.Build (dict "minify" true) }} + +{{ $hints := resources.Get "js/callout-hints.ts" | js.Build (dict "minify" true) }} + +{{- if .Params.hasWalkthrough -}} +{{ $wt := resources.Get "js/code-walkthrough.ts" | js.Build (dict "minify" true) }} + +{{- end -}} +{{/* kbd-os.ts: swap labels to match the active OS preference. + Loaded unconditionally on all tutorial pages (every tutorial may contain + :kbd directives) unless we are in preview mode. */}} +{{ if not site.Params.previewMode }} +{{ $kbdOs := resources.Get "js/kbd-os.ts" | js.Build (dict "minify" true) }} + +{{ end }} {{ if not site.Params.qa }}{{ end }} {{ end }} diff --git a/hugo/layouts/tutorials/u1-object-page.html b/hugo/layouts/tutorials/u1-object-page.html index b0ed9b8b4..100aee992 100644 --- a/hugo/layouts/tutorials/u1-object-page.html +++ b/hugo/layouts/tutorials/u1-object-page.html @@ -284,6 +284,11 @@

{{ .Title }}{{ if in .Params.ta {{ end }} + {{/* [tutorial-step-pacing] Populated client-side by tutorial.ts + (updateRemainingTime) — hidden by default, only shown once at + least half the steps declare a time. Not server-rendered because + it depends on live completed/skipped state. */}} + {{ if gt (int .Params.stepCount) 0 }}{{ end }}

@@ -370,6 +375,30 @@

Prerequisites

{{ end }} + {{/* [synced-tabs] Page-level sync-group picker bars — one per sync key. + Rendered only when the tutorial declares at least one sync group key + (syncKeysUsed is a non-empty slice). Each picker shows the union of + canonical values for that key, sourced from .Params.syncGroups. + sync-groups.ts hides the picker when fewer than 2 strips on the page + share that key, so rendering extra pickers for single-strip tutorials + is safe — they are hidden client-side without layout shift. */}} + {{ if .Params.syncKeysUsed }} +
+ {{ range .Params.syncKeysUsed }} + {{ $key := . }} + {{ $group := index $.Params.syncGroups $key }} + {{ if $group }} +
+ {{ $key | title }}: + + {{ range $group.values }} + {{ . }} + {{ end }} + +
+ {{ end }} + {{ end }} + {{ end }} {{/* U2: Fiori-native wizard step indicator. Renders only for ≥3-step tutorials (per ui5-wizard guidelines). The wizard's content slots stay empty — the accordion below holds the actual content. step-change scrolls to anchor. */}} @@ -514,6 +543,15 @@

Discussion

{{ $js := resources.Get "js/tutorial.ts" | js.Build (dict "minify" true) }} {{ end }} +{{/* [tutorial-step-pacing] Interactive checklists — progressive-enhances any + GFM task list (`- [ ]`) inside step content. Runs on every tutorial page + (cheap no-op scan when a tutorial has no task lists); gated the same as + tutorial.ts (not in previewMode, which has no progress-bar/#tutorial-data + slug to key localStorage off of). */}} +{{ if not site.Params.previewMode }} +{{ $taskLists := resources.Get "js/task-lists.ts" | js.Build (dict "minify" true) }} + +{{ end }} {{/* Issue #173: load the OS-picker runtime only when the page has OS-flavored option blocks (hasOsOptions=true) — matches the picker guard above. Without this, os-toggle.ts never mounts, the picker stays inert, and every OS panel @@ -523,6 +561,34 @@

Discussion

{{ $osToggle := resources.Get "js/os-toggle.ts" | js.Build (dict "minify" true) }} {{ end }} +{{/* [synced-tabs] Load the sync-groups runtime when the tutorial uses sync groups. + Gated on syncKeysUsed (set by the content parser) so tutorial pages without + option sync groups ship no extra script fetch. Also excluded from previewMode + where client-side interactivity is suppressed. */}} +{{ if and .Params.syncKeysUsed (not site.Params.previewMode) }} +{{ $syncGroups := resources.Get "js/sync-groups.ts" | js.Build (dict "minify" true) }} + +{{ end }} +{{- $richMediaCss := resources.Get "css/rich-media.css" | minify | fingerprint -}} + +{{- $hotspotsJs := resources.Get "js/hotspots.ts" | js.Build (dict "minify" true) -}} + +{{ if .Params.hasCompare -}} +{{- $compareJs := resources.Get "js/compare.ts" | js.Build (dict "minify" true) -}} + +{{- end }} +{{ if .Params.hasCast -}} +{{- $castJs := resources.Get "js/cast.ts" | js.Build (dict "minify" true) -}} + +{{- end }} +{{/* kbd-os.ts: swap labels to match the active OS preference. + Loaded unconditionally on all tutorial pages (every tutorial may contain + :kbd directives) unless we are in QA/preview mode where the authoring + chrome doesn't run client-side OS detection. */}} +{{ if not site.Params.previewMode }} +{{ $kbdOs := resources.Get "js/kbd-os.ts" | js.Build (dict "minify" true) }} + +{{ end }} {{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }} {{ if and (not site.Params.qa) (not site.Params.previewMode) }}{{ end }} @@ -594,13 +660,19 @@

Discussion

} // Mirror existing progress segments into the ring. + // [tutorial-step-pacing] Required-step denominator: stepCount minus any + // steps marked {optional} (data-optional="true" on .tutorial-step), and + // only non-optional steps count toward "completed" — mirrors the + // server-side completion calc in srv/developer-service.js. function updateRing() { const ring = document.getElementById('op-progress-ring'); const label = document.getElementById('op-progress-label'); const wrap = document.getElementById('op-progress'); if (!ring || !label || !wrap) return; - const total = parseInt(wrap.dataset.stepCount || '0', 10) || 0; - const completed = document.querySelectorAll('.tutorial-step.completed').length; + const rawTotal = parseInt(wrap.dataset.stepCount || '0', 10) || 0; + const optionalCount = document.querySelectorAll('.tutorial-step[data-optional="true"]').length; + const total = Math.max(0, rawTotal - optionalCount); + const completed = document.querySelectorAll('.tutorial-step.completed:not([data-optional="true"])').length; const pct = total ? Math.round((completed / total) * 100) : 0; ring.style.setProperty('--pct', String(pct)); label.textContent = `${completed}/${total}`; diff --git a/hugo/static/images/app-space-game/kasimir-atlas.png b/hugo/static/images/app-space-game/kasimir-atlas.png new file mode 100644 index 000000000..f79c89626 Binary files /dev/null and b/hugo/static/images/app-space-game/kasimir-atlas.png differ diff --git a/hugo/static/js/joule.js b/hugo/static/js/joule.js index 72bcd2069..a465d97f7 100644 --- a/hugo/static/js/joule.js +++ b/hugo/static/js/joule.js @@ -903,6 +903,7 @@ if (trigger) trigger.remove(); if (stepFab) stepFab.remove(); if (embedJouleBtn) embedJouleBtn.remove(); + document.querySelectorAll('.tut-ask').forEach(btn => btn.remove()); return; } if (trigger) trigger.hidden = false; @@ -922,6 +923,22 @@ // accounted for the always-present fixed bar, so the overlap recurred // whenever the user was not scrolled to the very bottom of the page. } + // Wire .tut-ask[data-prompt] buttons: reveal + attach delegated click listener + // so pressing "Ask Joule" on a :ask directive opens Joule pre-loaded with the + // step context and the directive's prompt string. Uses event delegation so + // buttons that are dynamically inserted (e.g. via Hugo island hydration) are + // also covered without a MutationObserver. + document.querySelectorAll('.tut-ask[data-prompt]').forEach(btn => { + btn.hidden = false; + }); + document.addEventListener('click', (e) => { + const btn = e.target.closest('.tut-ask[data-prompt]'); + if (!btn) return; + const ctx = (typeof window.opGetCurrentStep === 'function') + ? window.opGetCurrentStep() + : { slug: '', n: 1, heading: '' }; + window.joule.openWithStepContext({ ...ctx, prompt: btn.dataset.prompt }); + }); if (cfg.bannerText) { banner.textContent = cfg.bannerText; banner.hidden = false; } // Auto-open after login redirect: _openImpl() appends ?joule=open to returnTo, diff --git a/openspec/changes/tutorial-authoring-aids/.openspec.yaml b/openspec/changes/tutorial-authoring-aids/.openspec.yaml new file mode 100644 index 000000000..1491b921e --- /dev/null +++ b/openspec/changes/tutorial-authoring-aids/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-05 diff --git a/openspec/changes/tutorial-authoring-aids/design.md b/openspec/changes/tutorial-authoring-aids/design.md new file mode 100644 index 000000000..6b6b49e0f --- /dev/null +++ b/openspec/changes/tutorial-authoring-aids/design.md @@ -0,0 +1,37 @@ +## Context + +Tutorials build for two channels: `prod` and `qa` (`Channel` in `scripts/fetch-tutorials.ts`; QA uses `hugo.qa.toml`, `content-qa/`, `site.Params.qa`). Authors preview on `/tutorials-qa/` from the private `-Contribution` repos and promote to the public `Tutorials` repo (skill `tutorial-qa-to-prod`). `compose.ts` runs pre-passes over the body; raw source is also published (`ContentFiles.sourceContent`) and read by MCP, the freshness detector and AI pipelines. Lint rules live in `scripts/lint-rules/` (`{id, describe, scan(slug, lines, rawLines) → LintFinding[]}`) and run in CI with `continue-on-error`. + +## Goals / Non-Goals + +**Goals:** zero risk of author notes reaching PROD readers or machine consumers; visible review aids on QA; actionable CLI deprecation feedback before readers hit errors. + +**Non-Goals:** threaded review comments/UI (GitHub PRs cover that); executing commands to verify them (ASSERT/CODECHECK cover that); automatic rewriting of deprecated commands. + +## Decisions + +### D1 — `%% … %%` syntax +Chosen because it is plain text, unlikely in tutorial prose, familiar from Obsidian, and visible (not hidden) in GitHub's raw view so reviewers see notes there too. Inline `%%text%%` and block (lines between `%%` alone on a line). Fence-aware (never inside code — `%%` appears in SQL/Jupyter magic). Optional author prefix `%% @jdoe: … %%` is displayed as attribution. *Alternative*: HTML comments `` — rejected; Goldmark drops them and existing tutorials already contain thousands of non-review comments (licence headers, disabled content) that must not suddenly appear on QA. + +### D2 — Strip early, channel-aware +`stripAuthorNotes(body, channel)` runs **first** in `compose.ts`, before frontmatter-derived extraction, intro, steps, variables and directives. PROD: remove notes (collapse resulting blank lines). QA: replace with a ``/block marker that the QA layout styles. The published PROD `sourceContent` is the stripped body, so MCP/AI/freshness never see notes on PROD; on QA, `renderDirectivesAsText` drops notes. Unterminated `%%` → build error on QA, lint error on PROD (and stripping stops at end of step to avoid swallowing content). + +### D3 — `:::qa-only` +Container directive, label = reason. In the QA build renders a dashed frame with "QA only: "; in PROD the whole container (incl. nested content) is removed before step parsing, so a QA-only step-internal block never affects PROD step numbering. Step headings inside `qa-only` are rejected (keeps step slugs/progress stable across channels). Lint `qa-only-in-prod-source` (warning) fires on files in PROD source repos; the `tutorial-qa-to-prod` skill reports the count and asks the author to resolve before opening the PROD PR. + +### D4 — CLI deprecation rules +`cli-deprecations.json`: entries `{ tool, match (regex on a normalised command line), severity (warn|error), since, message, replacement, docs }`. The rule scans fenced blocks tagged `bash|sh|shell|console|terminal|powershell|cmd` and inline code starting with a known tool name; strips prompts (`$ `, `PS> `) and continuations. Severity defaults to warning (CI non-blocking, consistent with existing rules). The initial set is curated with the CLI owners (Cloud Foundry, BTP CLI, CAP, MBT teams) — e.g. `cf` v6-era push flags replaced by manifest `routes:` — and every entry must cite a release note or docs link in `docs`; no rule ships without one. The freshness detector imports the same JSON to create findings, so the dashboard and lint agree. + +## Risks / Trade-offs + +- [Note syntax collides with content] → fence-aware; `%%` in prose is rare; corpus scan before rollout lists every existing `%%` occurrence for manual triage. +- [QA/PROD divergence confuses authors] → QA notes counter shows "Hidden on PROD"; QA-only frame states it explicitly. +- [Deprecation list rots] → owned file with `since` + docs link; quarterly review task; false positives are warnings only. + +## Migration Plan + +Corpus scan for `%%` and `:::qa-only` before enabling; enable strip in both channels at once; lint rules start as warnings. Rollback: revert pre-pass (QA notes would then render as literal text; PROD unaffected if no notes were promoted — the PROD lint guards that). + +## Open Questions + +- Should PROD builds hard-fail on leftover notes instead of stripping silently? Proposed: strip + warning, because stripping is safe and a failure would block unrelated hotfixes. diff --git a/openspec/changes/tutorial-authoring-aids/proposal.md b/openspec/changes/tutorial-authoring-aids/proposal.md new file mode 100644 index 000000000..f4e235f38 --- /dev/null +++ b/openspec/changes/tutorial-authoring-aids/proposal.md @@ -0,0 +1,26 @@ +## Why + +Authors and reviewers collaborate in the markdown itself, but today they have nowhere safe to leave notes: HTML comments (``) are invisible on the QA preview where review happens, and text left in by mistake ships to developers.sap.com. Work-in-progress content (a step still being validated, a reviewer question) can't be previewed on the QA channel without risking it reaching PROD when the file is promoted. CLI commands go stale silently — `cf` v6 flags, `btp` subcommands, `cds` options renamed between majors — and readers hit errors that the freshness detector only discovers much later. + +## What Changes + +- **Author-only notes**: `%% reviewer: please verify the region list %%` (inline or multi-line, Obsidian-style comment syntax). Stripped from PROD builds entirely; rendered on the QA channel as highlighted sticky notes with a page-level "N author notes" counter and toggle. Never sent to MCP, search, AI pipelines, or published source content. +- **QA-only blocks**: `:::qa-only[Reason]` … `:::` container. Rendered with a "QA only" frame on the QA channel; stripped from PROD. The QA→PROD promotion skill (`tutorial-qa-to-prod`) and PROD CI warn (lint) when promoted files still contain QA-only blocks or author notes. +- **CLI freshness lint**: a lint rule that scans console/shell code blocks for commands and flags known deprecated or removed `cf`, `btp`, `cds`, `mbt`, `npm`, and `hana-cli` commands/flags from a maintained rules file, with suggested replacements and a docs link per rule. Findings appear in `validate-tutorials`, PR lint output and the freshness detector's dashboard. + +## Capabilities + +### New Capabilities +- `tutorial-authoring-aids`: author-only notes, QA-only content blocks, and deprecated-CLI linting. + +### Modified Capabilities + + +## Impact + +- New fence-aware pre-pass `scripts/parsers/author-notes.ts` (runs first in `compose.ts`, before any content extraction, so notes never reach steps/intro/MCP sources); channel-aware via the existing `Channel = 'prod' | 'qa'` in `fetch-tutorials.ts`. +- `qa-only` container directive registered on `tutorial-directive-grammar` with channel-aware render. +- Stored source content (`ContentFiles.sourceContent`) for PROD is stripped of notes/QA blocks before publish. +- New lint rules `scripts/lint-rules/author-notes-in-prod.ts`, `deprecated-cli.ts` + `scripts/lint-rules/data/cli-deprecations.json`; freshness detector imports the same rules file. +- `.claude/skills/tutorial-qa-to-prod` promotion flow: warning step. +- QA layout: notes toggle UI and CSS. diff --git a/openspec/changes/tutorial-authoring-aids/specs/tutorial-authoring-aids/spec.md b/openspec/changes/tutorial-authoring-aids/specs/tutorial-authoring-aids/spec.md new file mode 100644 index 000000000..5896e7af5 --- /dev/null +++ b/openspec/changes/tutorial-authoring-aids/specs/tutorial-authoring-aids/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Author-only notes +Text enclosed in `%% … %%` outside code SHALL be treated as an author note. Notes SHALL be removed from PROD builds and from all PROD-published source content before any other parsing, and SHALL render as highlighted notes with a page-level counter and visibility toggle on the QA channel. Notes SHALL never appear in MCP output, AI pipeline input, or search indexes. + +#### Scenario: PROD build +- **WHEN** a step contains `Choose a region. %% @jdoe: is eu10 still default? %%` +- **THEN** the PROD page and published source contain "Choose a region." without the note + +#### Scenario: QA preview +- **WHEN** the same tutorial renders on `/tutorials-qa/` +- **THEN** the note appears highlighted with attribution "jdoe" and the page shows "1 author note" + +#### Scenario: Inside code +- **WHEN** a SQL fence contains `%%` +- **THEN** the fence content is unchanged in both channels + +#### Scenario: Unterminated note +- **WHEN** a `%%` note is never closed +- **THEN** the QA build fails with the line number and PROD lint reports an error + +### Requirement: QA-only blocks +The `:::qa-only[Reason]` container SHALL render its content inside a labelled "QA only" frame on the QA channel and SHALL be removed, including nested content, from PROD builds before step parsing. Step headings inside a QA-only block SHALL fail the build. + +#### Scenario: Stripped on PROD +- **WHEN** a step contains a QA-only block with a draft verification command +- **THEN** the PROD page for that step does not contain the block and step numbering is unchanged + +#### Scenario: Step heading inside +- **WHEN** a QA-only block contains `### Extra step` +- **THEN** the build fails with `directive/invalid-content` + +### Requirement: Promotion safeguards +The QA-to-PROD promotion flow and PROD source lint SHALL report remaining author notes and QA-only blocks in files being promoted. + +#### Scenario: Promoting with notes +- **WHEN** an author promotes a tutorial containing two author notes and one QA-only block +- **THEN** the promotion reports all three locations and asks the author to resolve them before the PROD pull request is opened + +### Requirement: Deprecated CLI lint +A lint rule SHALL scan shell-type code fences and CLI inline code for commands matching a maintained deprecation rules file covering at least `cf`, `btp`, `cds`, and `mbt`, ignoring prompts and joining continuation lines, and SHALL report the message, replacement, and documentation link for each match. The freshness detector SHALL use the same rules file. + +#### Scenario: Deprecated flag +- **WHEN** a console block contains `$ cf push myapp --hostname myhost` +- **THEN** lint reports a warning with the suggested replacement and docs link + +#### Scenario: Non-shell fence +- **WHEN** a JavaScript fence contains the string `cf push --hostname` +- **THEN** no finding is reported + +#### Scenario: Shared with freshness detector +- **WHEN** a new rule is added to the rules file +- **THEN** both lint and the freshness detector report matches without further code changes diff --git a/openspec/changes/tutorial-authoring-aids/tasks.md b/openspec/changes/tutorial-authoring-aids/tasks.md new file mode 100644 index 000000000..2c4b7cca6 --- /dev/null +++ b/openspec/changes/tutorial-authoring-aids/tasks.md @@ -0,0 +1,29 @@ +## 1. Author notes + +- [ ] 1.1 Corpus scan of `.tutorial-cache/` for existing `%%` and `:::qa-only` occurrences; triage report +- [ ] 1.2 `scripts/parsers/author-notes.ts` `stripAuthorNotes(body, channel)` (inline/block, fence-aware, attribution, unterminated detection) +- [ ] 1.3 Call it first in `compose.ts`; ensure PROD-published `sourceContent` is the stripped body +- [ ] 1.4 QA rendering: note markers, styles, page counter and toggle in the QA layout +- [ ] 1.5 Drop notes in `renderDirectivesAsText` for QA consumers + +## 2. QA-only blocks + +- [ ] 2.1 Register `qa-only` container with channel-aware render/removal before step parsing; reject step headings +- [ ] 2.2 QA frame shortcode `tut-qa-only.html` and CSS + +## 3. Promotion safeguards + +- [ ] 3.1 Lint rule `author-notes-in-prod-source` / `qa-only-in-prod-source` +- [ ] 3.2 Update the `tutorial-qa-to-prod` skill to report and block on leftovers + +## 4. CLI deprecation lint + +- [ ] 4.1 Create `scripts/lint-rules/data/cli-deprecations.json` with initial curated rules (review with CLI owners) +- [ ] 4.2 `scripts/lint-rules/deprecated-cli.ts` (shell fence detection, prompt stripping, continuation joining) +- [ ] 4.3 Freshness detector imports the rules file and emits findings +- [ ] 4.4 Quarterly review reminder documented in rules file header + +## 5. Docs and tests + +- [ ] 5.1 Author docs: notes, QA-only blocks, CLI lint +- [ ] 5.2 Unit tests for stripping (both channels, code fences, unterminated), qa-only removal, lint rule matching; e2e for QA notes toggle diff --git a/openspec/changes/tutorial-callout-enhancements/.openspec.yaml b/openspec/changes/tutorial-callout-enhancements/.openspec.yaml new file mode 100644 index 000000000..1491b921e --- /dev/null +++ b/openspec/changes/tutorial-callout-enhancements/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-05 diff --git a/openspec/changes/tutorial-callout-enhancements/design.md b/openspec/changes/tutorial-callout-enhancements/design.md new file mode 100644 index 000000000..955935361 --- /dev/null +++ b/openspec/changes/tutorial-callout-enhancements/design.md @@ -0,0 +1,44 @@ +## Context + +`render-blockquote.html` (#2595) renders plain `>` byte-identically to Hugo's default and maps Goldmark's `.AlertType` (note, tip, important, warning, caution) to `alert alert-` classes; CSS maps them to SAP MessageStrip colors, color-only, no icon or title. Hugo ≥0.134 exposes `.AlertTitle` and `.AlertSign` (`+`/`-` for foldable) on blockquote render hooks; unknown designators resolve to `AlertType ""`. Legacy note patterns are normalised by `blockquote-notes.ts` and `blockquote-fence.ts`. + +## Goals / Non-Goals + +**Goals:** keep the `> [!TYPE]` syntax authors already know from GitHub; non-color signalling (icon + hidden label); collapsible content without JavaScript (`
`); zero change for plain `>` blockquotes. + +**Non-Goals:** custom author-defined callout types/colours; changing legacy note normalisation. + +## Decisions + +### D1 — Use Hugo's native alert title/sign support +Rely on Hugo's `.AlertTitle` and `.AlertSign` (available since Hugo 0.132; the pipeline pins 0.147.7 in `rebuild-content.yml`, so no bump needed). For the five GitHub types, Goldmark already parses them. For new types (`DETAILS`, `TROUBLESHOOT`, `RESULT`, `HINT`, `SOLUTION`), Hugo's alert extension accepts any `[!WORD]` designator in recent versions (`.AlertType` = lowercased word). If the pinned version rejects unknown types, a fence-aware pre-pass `blockquote-alerts.ts` rewrites `> [!DETAILS]` into `> [!NOTE]` plus a first-line marker comment `` that the render hook detects. Decision: prefer native; the pre-pass is the fallback path, picked in task 1.1. + +### D2 — Rendering +- Common: `