diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..64f7fef --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(kubectl *)" + ] + } +} diff --git a/.devcontainer/check.mjs b/.devcontainer/check.mjs new file mode 100644 index 0000000..6419b54 --- /dev/null +++ b/.devcontainer/check.mjs @@ -0,0 +1,21 @@ +// Checks that the dev container's Playwright Chromium matches the Playwright in +// package-lock.json. Dependabot bumps the lockfile but not devcontainer.json, and +// a mismatch means every new container downloads Chromium again. Run by CI. +import { readFileSync } from 'node:fs'; + +const read = (path) => readFileSync(new URL(path, import.meta.url), 'utf8'); + +const locked = JSON.parse(read('../package-lock.json')).packages['node_modules/playwright']?.version; +const pinned = read('./devcontainer.json') + .match(/"\.\/features\/playwright-chromium":\s*\{\s*"version":\s*"([^"]+)"/)?.[1]; + +if (!locked || !pinned) { + console.error(`Couldn't find Playwright's version (package-lock.json: ${locked}, devcontainer.json: ${pinned}).`); + process.exit(1); +} +if (locked !== pinned) { + console.error(`package-lock.json has Playwright ${locked}, but .devcontainer/devcontainer.json installs ${pinned}.`); + console.error(`Set "./features/playwright-chromium": { "version": "${locked}" } in devcontainer.json.`); + process.exit(1); +} +console.log(`Playwright ${locked}: devcontainer.json matches package-lock.json.`); diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1c64cf2..9a57cab 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,24 +1,80 @@ -// For format details, see https://aka.ms/devcontainer.json. For config options, see the -// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node +// Format: https://containers.dev/implementors/json_reference/ { - "name": "Node.js & TypeScript", - // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile - "image": "mcr.microsoft.com/devcontainers/typescript-node:5-24-trixie", + "name": "Blockout", + "image": "mcr.microsoft.com/devcontainers/typescript-node:5-26-trixie", + "hostRequirements": { "cpus": 1, "memory": "4gb", "storage": "10gb" }, - // Features to add to the dev container. More info: https://containers.dev/features. - // "features": {}, + // Features: https://containers.dev/features. Local ones live in ./features: + // cloudflared for tunnels, Chromium for the Playwright browser tests, + // python3-venv for the docs site, Agent Skills for Claude Code (user-level, in + // ~/.claude/skills), and the coder CLI from the Coder deployment (always that + // server's version). The other versions are pinned so a rebuild gets the same + // tools; .devcontainer/check.mjs keeps Playwright in step with package-lock.json. + "features": { + "ghcr.io/devcontainers/features/github-cli:1": {}, + // kubectl and helm, for the services the workspace's cluster runs (like Keycloak) + "ghcr.io/devcontainers/features/kubectl-helm-minikube:1": { "minikube": "none" }, + "./features/cloudflared": { "version": "2026.10.0" }, + "./features/playwright-chromium": { "version": "1.63.0" }, + "./features/python-venv": {}, + "./features/agent-skills": { + "source": "DeepSpaceCartel/skills", + "ref": "5642bd7c6c82a1fdc5f36ddadadf02723951f296" + }, + "./features/coder-cli": { "url": "https://coder.deepspacecartel.com" }, + // op, for reading deploy secrets from 1Password (deploy/blockout/.env.1password) + "./features/1password-cli": { "version": "latest" }, + // On every start: Blockout's clients in the shared Keycloak (keycloak-clients.json), + // with this workspace's URLs for the ports labelled [sign-in] below + "./features/keycloak-clients": {} + }, - // Use 'forwardPorts' to make a list of ports inside the container available locally. - // The classroom server (npm run classroom) - "forwardPorts": [8080], + // The workspace has 4 GiB, shared by the editor, its servers and everything + // run here. Each Node process gets at most 1.5 GiB of heap, so a runaway build + // or dev server fails on its own instead of taking the whole container (every + // terminal and session) down with it. Processes that set their own limit (VS + // Code's servers) keep theirs; native memory (bundler workers) isn't covered. + // Clocks and logs in Los Angeles time (CI stays on UTC). + "containerEnv": { + "NODE_OPTIONS": "--max-old-space-size=1536", + "TZ": "America/Los_Angeles", + // The shared Keycloak, for BLOCKOUT_AUTH=keycloak npm start (docs/guides/set-up-keycloak.md) + "KEYCLOAK_ISSUER": "https://id.deepspacecartel.com/realms/dev", + "KEYCLOAK_CLIENT_ID": "blockout" + }, - // Use 'postCreateCommand' to run commands after the container is created. - // Playwright + Chromium for the browser tests, and cloudflared for tunnels - "postCreateCommand": "bash .devcontainer/postCreateCommand.sh" + // The Blockout server (npm start), the Vite dev server (npm run dev), the + // docs site (mkdocs serve) and Metro for the phone app (npm run tunnel + // --workspace @blockout/mobile). [sign-in]: the port's Coder URL may sign in + // through Keycloak (the keycloak-clients Feature adds it to the client) + "forwardPorts": [8080, 5173, 8000, 8081], + "portsAttributes": { + "8080": { "label": "Blockout server [sign-in]" }, + "5173": { "label": "Vite [sign-in]" }, + "8000": { "label": "Docs (mkdocs serve)" }, + "8081": { "label": "Metro (phone app)" }, + // Throwaway servers that Playwright test scripts start + "8134-8199": { "onAutoForward": "ignore" } + }, - // Configure tool-specific properties. - // "customizations": {}, + // npm packages and the docs venv, which need the workspace (features can't see it) + "postCreateCommand": "bash .devcontainer/postCreateCommand.sh", - // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. - // "remoteUser": "root" + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code", + "dbaeumer.vscode-eslint", + "redhat.vscode-yaml" + ], + "settings": { + // mkdocs.yml uses Material's !!python/name tags + "yaml.customTags": [ + "!!python/name:material.extensions.emoji.twemoji", + "!!python/name:material.extensions.emoji.to_svg", + "!!python/name:pymdownx.superfences.fence_code_format" + ] + } + } + } } diff --git a/.devcontainer/features/1password-cli/devcontainer-feature.json b/.devcontainer/features/1password-cli/devcontainer-feature.json new file mode 100644 index 0000000..737e10e --- /dev/null +++ b/.devcontainer/features/1password-cli/devcontainer-feature.json @@ -0,0 +1,13 @@ +{ + "id": "1password-cli", + "version": "1.0.0", + "name": "1Password CLI", + "description": "The 1Password CLI (op) from 1Password's signed apt repository, for reading deploy secrets (like the Google OAuth client secret) instead of typing them.", + "options": { + "version": { + "type": "string", + "default": "2.40.0", + "description": "An op version in 1Password's apt repository (like 2.40.0), or latest." + } + } +} diff --git a/.devcontainer/features/1password-cli/install.sh b/.devcontainer/features/1password-cli/install.sh new file mode 100755 index 0000000..1fe1f3d --- /dev/null +++ b/.devcontainer/features/1password-cli/install.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Installs the 1Password CLI (op) from 1Password's apt repository, following +# https://developer.1password.com/docs/cli/get-started/. apt checks the packages' +# signatures with 1Password's key, and the key itself is checked against the +# fingerprint 1Password publishes before anything trusts it. +set -euo pipefail + +VERSION="${VERSION:-2.40.0}" +FINGERPRINT=3FEF9748469ADBE15DA7CA80AC2D62742012EA22 +KEY_ID=AC2D62742012EA22 +arch="$(dpkg --print-architecture)" # amd64 or arm64 + +export DEBIAN_FRONTEND=noninteractive +missing=() +for tool in curl gpg; do command -v "$tool" >/dev/null 2>&1 || missing+=("$tool"); done +if [ "${#missing[@]}" -gt 0 ]; then + apt-get update -qq + apt-get install -y -qq --no-install-recommends curl ca-certificates gnupg +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +export GNUPGHOME="$tmp/gnupg" # no keyring left behind in root's home +mkdir -m 700 "$GNUPGHOME" +curl -fsSL https://downloads.1password.com/linux/keys/1password.asc -o "$tmp/1password.asc" +got="$(gpg --batch --quiet --show-keys --with-colons "$tmp/1password.asc" | awk -F: '/^fpr:/ { print $10; exit }')" +if [ "$got" != "$FINGERPRINT" ]; then + echo "1password-cli: the signing key's fingerprint is $got, expected $FINGERPRINT." >&2 + exit 1 +fi + +gpg --batch --quiet --dearmor < "$tmp/1password.asc" > /usr/share/keyrings/1password-archive-keyring.gpg +echo "deb [arch=${arch} signed-by=/usr/share/keyrings/1password-archive-keyring.gpg] https://downloads.1password.com/linux/debian/${arch} stable main" \ + > /etc/apt/sources.list.d/1password.list +# debsig policy: the package files are signed too, not only the repository +mkdir -p "/etc/debsig/policies/${KEY_ID}" "/usr/share/debsig/keyrings/${KEY_ID}" +curl -fsSL https://downloads.1password.com/linux/debian/debsig/1password.pol -o "/etc/debsig/policies/${KEY_ID}/1password.pol" +cp /usr/share/keyrings/1password-archive-keyring.gpg "/usr/share/debsig/keyrings/${KEY_ID}/debsig.gpg" + +apt-get update -qq +if [ "$VERSION" = latest ]; then + apt-get install -y -qq --no-install-recommends 1password-cli +else + apt-get install -y -qq --no-install-recommends "1password-cli=${VERSION}-*" +fi +rm -rf /var/lib/apt/lists/* +op --version diff --git a/.devcontainer/features/agent-skills/devcontainer-feature.json b/.devcontainer/features/agent-skills/devcontainer-feature.json new file mode 100644 index 0000000..e3de4f5 --- /dev/null +++ b/.devcontainer/features/agent-skills/devcontainer-feature.json @@ -0,0 +1,33 @@ +{ + "id": "agent-skills", + "version": "1.1.0", + "name": "Agent Skills", + "description": "Agent Skills from a GitHub repo, installed user-level (~/.claude/skills) for the container user with the skills CLI.", + "options": { + "source": { + "type": "string", + "default": "DeepSpaceCartel/skills", + "description": "The skills repo on GitHub (owner/repo)." + }, + "ref": { + "type": "string", + "default": "", + "description": "A commit, branch or tag to install from. Empty means the default branch; a commit SHA makes builds reproducible." + }, + "skills": { + "type": "string", + "default": "*", + "description": "Space-separated skill names to install, or * for all of them." + }, + "agent": { + "type": "string", + "default": "claude-code", + "description": "The agent to install for (the skills CLI's --agent)." + }, + "cliVersion": { + "type": "string", + "default": "1.7.1", + "description": "The skills CLI (npm package skills) version to run." + } + } +} diff --git a/.devcontainer/features/agent-skills/install.sh b/.devcontainer/features/agent-skills/install.sh new file mode 100755 index 0000000..ab72702 --- /dev/null +++ b/.devcontainer/features/agent-skills/install.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Installs Agent Skills user-level for the container user. Features run before the +# workspace is mounted, so they can't go in the project's .claude/skills. +# Needs Node (npx) and git, which the base image has. +set -euo pipefail + +SOURCE="${SOURCE:-DeepSpaceCartel/skills}" +REF="${REF:-}" +SKILLS="${SKILLS:-*}" +AGENT="${AGENT:-claude-code}" +CLIVERSION="${CLIVERSION:-1.7.1}" +user="${_REMOTE_USER:-root}" + +if ! command -v npx >/dev/null 2>&1; then + echo "agent-skills: needs Node (npx) in the base image." >&2 + exit 1 +fi + +# The skills CLI takes a ref as a GitHub tree URL +from="$SOURCE" +[ -n "$REF" ] && from="https://github.com/${SOURCE}/tree/${REF}" + +# As the container user, so the files land in (and belong to) their home +cmd="npx --yes 'skills@${CLIVERSION}' add '${from}' --global --copy --yes --agent '${AGENT}'" +set -f +for s in $SKILLS; do cmd+=" --skill '${s}'"; done +set +f + +su "$user" -s /bin/bash -c " + set -euo pipefail + export DO_NOT_TRACK=1 DISABLE_TELEMETRY=1 npm_config_update_notifier=false + export npm_config_cache=\"\$(mktemp -d)\" + $cmd + rm -rf \"\$npm_config_cache\" +" diff --git a/.devcontainer/features/cloudflared/devcontainer-feature.json b/.devcontainer/features/cloudflared/devcontainer-feature.json new file mode 100644 index 0000000..7e4a207 --- /dev/null +++ b/.devcontainer/features/cloudflared/devcontainer-feature.json @@ -0,0 +1,13 @@ +{ + "id": "cloudflared", + "version": "1.1.0", + "name": "cloudflared", + "description": "Cloudflare's tunnel client, for sharing the classroom server through a quick tunnel.", + "options": { + "version": { + "type": "string", + "default": "2026.10.0", + "description": "A cloudflared release tag (like 2026.10.0), or latest." + } + } +} diff --git a/.devcontainer/features/cloudflared/install.sh b/.devcontainer/features/cloudflared/install.sh new file mode 100755 index 0000000..7eab04a --- /dev/null +++ b/.devcontainer/features/cloudflared/install.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Installs cloudflared from Cloudflare's GitHub release .deb. +set -euo pipefail + +VERSION="${VERSION:-2026.10.0}" +arch="$(dpkg --print-architecture)" # amd64 or arm64 + +if [ "$VERSION" = latest ]; then + url="https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb" +else + url="https://github.com/cloudflare/cloudflared/releases/download/${VERSION}/cloudflared-linux-${arch}.deb" +fi + +if ! command -v curl >/dev/null 2>&1; then + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +curl -fsSL -o "$tmp/cloudflared.deb" "$url" +dpkg -i "$tmp/cloudflared.deb" +cloudflared --version | head -n 1 diff --git a/.devcontainer/features/coder-cli/devcontainer-feature.json b/.devcontainer/features/coder-cli/devcontainer-feature.json new file mode 100644 index 0000000..f77ee54 --- /dev/null +++ b/.devcontainer/features/coder-cli/devcontainer-feature.json @@ -0,0 +1,13 @@ +{ + "id": "coder-cli", + "version": "1.0.0", + "name": "Coder CLI", + "description": "The coder CLI, downloaded from a Coder deployment so it matches that server's version.", + "options": { + "url": { + "type": "string", + "default": "https://coder.deepspacecartel.com", + "description": "The Coder deployment to download the CLI from (what CODER_AGENT_URL points at in a workspace)." + } + } +} diff --git a/.devcontainer/features/coder-cli/install.sh b/.devcontainer/features/coder-cli/install.sh new file mode 100755 index 0000000..df0fdd5 --- /dev/null +++ b/.devcontainer/features/coder-cli/install.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Installs the coder CLI from a Coder deployment's /bin endpoint, so the CLI is the +# same version as the server. CODER_AGENT_URL only exists at runtime, hence the option. +set -euo pipefail + +URL="${URL:-https://coder.deepspacecartel.com}" +URL="${URL%/}" +arch="$(dpkg --print-architecture)" # amd64 or arm64 + +if ! command -v curl >/dev/null 2>&1; then + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates + rm -rf /var/lib/apt/lists/* +fi + +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +curl -fsSL -o "$tmp/coder" "${URL}/bin/coder-linux-${arch}" +install -m 0755 "$tmp/coder" /usr/local/bin/coder +coder version diff --git a/.devcontainer/features/keycloak-clients/devcontainer-feature.json b/.devcontainer/features/keycloak-clients/devcontainer-feature.json new file mode 100644 index 0000000..5f7d3d3 --- /dev/null +++ b/.devcontainer/features/keycloak-clients/devcontainer-feature.json @@ -0,0 +1,30 @@ +{ + "id": "keycloak-clients", + "version": "1.0.0", + "name": "Keycloak clients for Coder workspaces", + "description": "On every start, creates or updates the project's Keycloak clients (from a JSON file in the repository) and adds this Coder workspace's URLs to them, so apps on Coder's port URLs can sign in. Needs python3 and kubectl, and the platform's registrar Secret (keycloak/workspace-registrar).", + "options": { + "file": { + "type": "string", + "default": ".devcontainer/keycloak-clients.json", + "description": "The project's clients, relative to the repository root." + }, + "marker": { + "type": "string", + "default": "sign-in", + "description": "A forwarded port's URL is added when its portsAttributes label ends in []." + }, + "domain": { + "type": "string", + "default": "", + "description": "Coder's wildcard domain (the part after ------.). Empty: the host of CODER_AGENT_URL." + }, + "secret": { + "type": "string", + "default": "keycloak/workspace-registrar", + "description": "The registrar's Secret, as /." + } + }, + "postStartCommand": "keycloak-clients sync", + "installsAfter": ["ghcr.io/devcontainers/features/kubectl-helm-minikube"] +} diff --git a/.devcontainer/features/keycloak-clients/install.sh b/.devcontainer/features/keycloak-clients/install.sh new file mode 100755 index 0000000..6aa04d0 --- /dev/null +++ b/.devcontainer/features/keycloak-clients/install.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Installs keycloak-clients (a Python 3 script, standard library only) and +# writes the Feature's options as its defaults: options only reach this script, +# at build time, so the command reads them back from a file on every start. +# kubectl comes from another Feature (kubectl-helm-minikube). +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" + +if ! command -v python3 >/dev/null 2>&1; then + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends python3 && rm -rf /var/lib/apt/lists/* + else + echo "keycloak-clients: needs python3 in the base image." >&2 + exit 1 + fi +fi +command -v kubectl >/dev/null 2>&1 || echo "keycloak-clients: no kubectl yet (add the kubectl-helm-minikube Feature)." >&2 + +install -m 0755 "$here/keycloak-clients" /usr/local/bin/keycloak-clients + +# (as JSON, so values are never run by a shell) +python3 - "${FILE:-}" "${MARKER:-}" "${DOMAIN:-}" "${SECRET:-}" > /usr/local/etc/keycloak-clients.json <<'EOF' +import json, sys +keys = ['KEYCLOAK_CLIENTS_FILE', 'KEYCLOAK_CLIENTS_MARKER', 'KEYCLOAK_CLIENTS_DOMAIN', 'KEYCLOAK_CLIENTS_SECRET'] +print(json.dumps(dict(zip(keys, sys.argv[1:])), indent=2)) +EOF diff --git a/.devcontainer/features/keycloak-clients/keycloak-clients b/.devcontainer/features/keycloak-clients/keycloak-clients new file mode 100755 index 0000000..b3428df --- /dev/null +++ b/.devcontainer/features/keycloak-clients/keycloak-clients @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""Creates a project's Keycloak clients and lets its Coder workspace sign in. + + keycloak-clients sync create or update the project's clients, and add this + workspace's URLs to them + keycloak-clients urls only print this workspace's URLs + +The project describes its clients in a JSON file (.devcontainer/keycloak-clients.json +by default): Keycloak's own client fields, plus "roles" (client roles to create) +and "workspaceUrls" (true: this workspace's URLs are added to it). A port's URL +is added when its devcontainer.json portsAttributes label ends in "[sign-in]". + +Keycloak only allows a * at the end of a redirect address's path, never in the +host, so every workspace URL (https://------.) +is listed. URLs of workspaces that no longer exist are dropped on every sync. + +It signs in as the platform's registrar, whose credentials are in the Secret +keycloak/workspace-registrar (rts-terraform, docs/keycloak.md), read with the +workspace's own kubectl access. Only Python's standard library, and kubectl. + +Settings: the Feature's options (install.sh writes them to DEFAULTS), each +overridden by an environment variable: KEYCLOAK_CLIENTS_FILE, +KEYCLOAK_CLIENTS_MARKER, KEYCLOAK_CLIENTS_DOMAIN (default: the host of +CODER_AGENT_URL), KEYCLOAK_CLIENTS_SECRET (default keycloak/workspace-registrar). +""" +import base64 +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.parse +import urllib.request + +TIMEOUT = 10 # seconds per request: this runs before login on every start +SA_NAMESPACE = '/var/run/secrets/kubernetes.io/serviceaccount/namespace' +DEFAULTS = '/usr/local/etc/keycloak-clients.json' +# Fields the file may have that aren't Keycloak's +OWN_FIELDS = ('roles', 'workspaceUrls') + + +class Problem(Exception): + pass + + +# ---------------------------------------------------------------- the project + + +def strip_jsonc(text): + """JSON with comments and trailing commas (devcontainer.json) to JSON.""" + out, i, n = [], 0, len(text) + while i < n: + c = text[i] + if c == '"': + j = i + 1 + while j < n and text[j] != '"': + j += 2 if text[j] == '\\' else 1 + out.append(text[i:j + 1]) + i = j + 1 + elif text.startswith('//', i): + i = text.find('\n', i) + i = n if i < 0 else i + elif text.startswith('/*', i): + end = text.find('*/', i + 2) + i = n if end < 0 else end + 2 + else: + out.append(c) + i += 1 + return re.sub(r',(\s*[}\]])', r'\1', ''.join(out)) + + +def find_config(start): + """The project's devcontainer.json, looking up from start.""" + d = os.path.abspath(start) + while True: + for name in ('.devcontainer/devcontainer.json', '.devcontainer.json'): + if os.path.isfile(os.path.join(d, name)): + return d, os.path.join(d, name) + parent = os.path.dirname(d) + if parent == d: + raise Problem(f'no .devcontainer/devcontainer.json in {start} or above') + d = parent + + +def sign_in_ports(config, marker): + """Ports whose portsAttributes label ends in [marker], in order.""" + tag = f'[{marker}]' + ports = [] + for key, attrs in (config.get('portsAttributes') or {}).items(): + label = str((attrs or {}).get('label', '')).strip() + if key.isdigit() and label.endswith(tag): + ports.append(int(key)) + return sorted(ports) + + +# ---------------------------------------------------------------- the workspace + + +def url_template(env, labels): + """https://{{port}}------., from Coder's + VSCODE_PROXY_URI when this process has it, else from the pod's labels.""" + proxy = env.get('VSCODE_PROXY_URI', '') + if '{{port}}' in proxy: + return proxy.rstrip('/') + workspace = labels.get('com.coder.workspace.name') + owner = labels.get('com.coder.user.username') + if not workspace or not owner: + raise Problem('not a Coder workspace (no com.coder.* labels on this pod)') + domain = env.get('KEYCLOAK_CLIENTS_DOMAIN') or urllib.parse.urlparse(env.get('CODER_AGENT_URL', '')).hostname + if not domain: + raise Problem('no domain: set KEYCLOAK_CLIENTS_DOMAIN (no CODER_AGENT_URL)') + agent = env.get('CODER_WORKSPACE_AGENT_NAME') or 'main' + return f'https://{{{{port}}}}--{agent}--{workspace}--{owner}.{domain}' + + +def origins_for(template, ports): + return [template.replace('{{port}}', str(p)) for p in ports] + + +def workspace_of(url, domain): + """(workspace, owner) of a Coder port URL on domain, else None.""" + host = urllib.parse.urlparse(url).hostname or '' + if not host.endswith('.' + domain): + return None + parts = host[: -len(domain) - 1].split('--') + if len(parts) != 4 or not parts[0].isdigit(): + return None + return parts[2], parts[3] + + +def domain_of(template): + return (urllib.parse.urlparse(template.replace('{{port}}', '0')).hostname or '').split('.', 1)[1] + + +def merge_urls(current, static, mine, alive, domain): + """The file's addresses, then every live workspace's, then this one's: other + workspaces' entries stay while they exist, the rest (and stale ones) go.""" + out = [] + for url in [*static, *current, *mine]: + ws = workspace_of(url, domain) + if url in out or (url in current and ws is None and url not in static): + continue + if ws is not None and ws not in alive and url not in mine: + continue + out.append(url) + return out + + +# ---------------------------------------------------------------- Kubernetes + + +def kubectl(*args): + try: + r = subprocess.run(['kubectl', *args], capture_output=True, text=True, timeout=TIMEOUT) + except FileNotFoundError: + raise Problem('kubectl not found') + except subprocess.TimeoutExpired: + raise Problem('kubectl timed out') + if r.returncode: + raise Problem(f'kubectl {" ".join(args[:3])}: {r.stderr.strip() or r.returncode}') + return json.loads(r.stdout) + + +def own_namespace(): + try: + with open(SA_NAMESPACE) as f: + return f.read().strip() + except OSError: + raise Problem('no Kubernetes service account in this container') + + +def pod_labels(): + return kubectl('get', 'pod', os.environ.get('HOSTNAME', ''), '-n', own_namespace(), '-o', 'json')['metadata'].get('labels', {}) + + +def live_workspaces(): + """(workspace, owner) of every workspace that still exists: its Deployment + stays while it's stopped, and goes when it's deleted.""" + items = kubectl('get', 'deployments', '-n', own_namespace(), '-l', 'com.coder.workspace.name', '-o', 'json')['items'] + return {(d['metadata']['labels']['com.coder.workspace.name'], d['metadata']['labels'].get('com.coder.user.username')) for d in items} + + +def registrar(ref): + namespace, name = ref.split('/', 1) + data = kubectl('get', 'secret', name, '-n', namespace, '-o', 'json')['data'] + return {k: base64.b64decode(v).decode() for k, v in data.items()} + + +# ---------------------------------------------------------------- Keycloak + + +class Keycloak: + def __init__(self, creds): + self.base = f"{creds['server'].rstrip('/')}/admin/realms/{creds['realm']}" + form = urllib.parse.urlencode({'grant_type': 'client_credentials', 'client_id': creds['client_id'], 'client_secret': creds['client_secret']}) + token = self._call('POST', f"{creds['server'].rstrip('/')}/realms/{creds['realm']}/protocol/openid-connect/token", form.encode(), 'application/x-www-form-urlencoded') + self.token = token['access_token'] + + def _call(self, method, url, body=None, kind='application/json'): + headers = {'Content-Type': kind} + if getattr(self, 'token', None): + headers['Authorization'] = f'Bearer {self.token}' + req = urllib.request.Request(url, data=body, method=method, headers=headers) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as r: + raw = r.read() + return json.loads(raw) if raw else None + except urllib.error.HTTPError as e: + raise Problem(f'{method} {url}: {e.code} {e.read().decode(errors="replace")[:200]}') + except (urllib.error.URLError, TimeoutError) as e: + raise Problem(f'{method} {url}: {getattr(e, "reason", e)}') + + def call(self, method, path, body=None): + return self._call(method, self.base + path, None if body is None else json.dumps(body).encode()) + + def client(self, client_id): + found = self.call('GET', '/clients?clientId=' + urllib.parse.quote(client_id)) + return found[0] if found else None + + +def desired(spec): + return {k: v for k, v in spec.items() if k not in OWN_FIELDS} + + +def client_rep(existing, spec, mine, alive, domain): + """What the client should be: Keycloak's current one with the file's + fields, and for workspaceUrls clients the file's addresses plus live + workspaces' and this one's. Web origins and post-logout addresses default + to "+": the same as the redirect addresses.""" + rep = dict(existing or {}) + for key, value in desired(spec).items(): + if key == 'attributes': + rep['attributes'] = {**(rep.get('attributes') or {}), **value} + elif key != 'protocolMappers' or not existing: # (mappers: see sync_client) + rep[key] = value + rep.setdefault('webOrigins', ['+']) + rep['attributes'] = {'post.logout.redirect.uris': '+', **(rep.get('attributes') or {})} + if spec.get('workspaceUrls'): + current = (existing or {}).get('redirectUris', []) + rep['redirectUris'] = merge_urls(current, spec.get('redirectUris', []), [f'{o}/*' for o in mine], alive, domain) + return rep + + +def sync_client(kc, spec, mine, alive, domain): + """Creates or updates one client; returns a line saying what happened.""" + client_id = spec['clientId'] + existing = kc.client(client_id) + rep = client_rep(existing, spec, mine, alive, domain) + if existing: + kc.call('PUT', f"/clients/{existing['id']}", rep) + action = 'updated' + else: + kc.call('POST', '/clients', rep) + action = 'created' + uid = kc.client(client_id)['id'] + # Mappers aren't changed by a client update: add the file's missing ones + if existing: + have = {m['name'] for m in kc.call('GET', f'/clients/{uid}/protocol-mappers/models') or []} + for m in spec.get('protocolMappers', []): + if m['name'] not in have: + kc.call('POST', f'/clients/{uid}/protocol-mappers/models', m) + roles = {r['name'] for r in kc.call('GET', f'/clients/{uid}/roles') or []} + for role in spec.get('roles', []): + if role not in roles: + kc.call('POST', f'/clients/{uid}/roles', {'name': role}) + # Another workspace may have written the same client at the same moment + if spec.get('workspaceUrls') and mine: + after = kc.client(client_id) + if not all(f'{o}/*' in after.get('redirectUris', []) for o in mine): + kc.call('PUT', f'/clients/{uid}', client_rep(after, spec, mine, alive, domain)) + return f'{action} {client_id}' + + +# ---------------------------------------------------------------- main + + +def main(argv): + command = argv[1] if len(argv) > 1 else 'sync' + if command not in ('sync', 'urls'): + print(__doc__.strip().split('\n\n')[1], file=sys.stderr) + return 2 + try: + with open(DEFAULTS) as f: + defaults = json.load(f) + except OSError: + defaults = {} + env = {**{k: v for k, v in defaults.items() if v}, **{k: v for k, v in os.environ.items() if v}} + marker = env.get('KEYCLOAK_CLIENTS_MARKER') or 'sign-in' + root, config_path = find_config(os.getcwd()) + with open(config_path) as f: + config = json.loads(strip_jsonc(f.read())) + ports = sign_in_ports(config, marker) + labels = {} if '{{port}}' in env.get('VSCODE_PROXY_URI', '') else pod_labels() + template = url_template(env, labels) + mine = origins_for(template, ports) + if command == 'urls': + print('\n'.join(mine) if mine else f'no ports labelled [{marker}] in {config_path}') + return 0 + + clients_path = os.path.join(root, env.get('KEYCLOAK_CLIENTS_FILE') or '.devcontainer/keycloak-clients.json') + with open(clients_path) as f: + specs = json.load(f) + kc = Keycloak(registrar(env.get('KEYCLOAK_CLIENTS_SECRET') or 'keycloak/workspace-registrar')) + alive = live_workspaces() + domain = domain_of(template) + for spec in specs: + print('keycloak-clients:', sync_client(kc, spec, mine, alive, domain)) + for url in mine: + print('keycloak-clients: signs in at', url) + return 0 + + +if __name__ == '__main__': + # Runs on every workspace start (the Feature's postStartCommand): never + # block the start, only say what went wrong. + try: + sys.exit(main(sys.argv)) + except (Problem, OSError, ValueError, KeyError) as e: + print(f'keycloak-clients: skipped: {e}', file=sys.stderr) + sys.exit(0 if os.environ.get('KEYCLOAK_CLIENTS_STRICT') != '1' else 1) diff --git a/.devcontainer/features/keycloak-clients/test/test_keycloak_clients.py b/.devcontainer/features/keycloak-clients/test/test_keycloak_clients.py new file mode 100644 index 0000000..3975047 --- /dev/null +++ b/.devcontainer/features/keycloak-clients/test/test_keycloak_clients.py @@ -0,0 +1,135 @@ +"""The pure parts of keycloak-clients: python3 -m unittest discover -s .devcontainer/features/keycloak-clients/test""" +import importlib.machinery +import importlib.util +import json +import os +import tempfile +import unittest + +_path = os.path.join(os.path.dirname(__file__), '..', 'keycloak-clients') +_loader = importlib.machinery.SourceFileLoader('keycloak_clients', _path) +_spec = importlib.util.spec_from_loader('keycloak_clients', _loader) +kc = importlib.util.module_from_spec(_spec) +_loader.exec_module(kc) + +DOMAIN = 'coder.example.org' +TEMPLATE = f'https://{{{{port}}}}--main--rooster--tp.{DOMAIN}' + + +def ws(port, workspace='rooster', owner='tp'): + return f'https://{port}--main--{workspace}--{owner}.{DOMAIN}/*' + + +class Jsonc(unittest.TestCase): + def test_comments_and_trailing_commas(self): + text = '''{ + // a comment + "forwardPorts": [8080, 5173,], /* block + comment */ + "url": "http://x//not-a-comment", "s": "a \\" // still a string", + }''' + self.assertEqual(json.loads(kc.strip_jsonc(text)), {'forwardPorts': [8080, 5173], 'url': 'http://x//not-a-comment', 's': 'a " // still a string'}) + + +class Ports(unittest.TestCase): + def test_only_marked_numeric_ports(self): + config = {'portsAttributes': { + '8080': {'label': 'Blockout server [sign-in]'}, + '5173': {'label': 'Vite [sign-in] '}, + '8000': {'label': 'Docs'}, + '3000-3005': {'label': 'Range [sign-in]'}, + '9000': {}, + }} + self.assertEqual(kc.sign_in_ports(config, 'sign-in'), [5173, 8080]) + self.assertEqual(kc.sign_in_ports({}, 'sign-in'), []) + + +class Urls(unittest.TestCase): + labels = {'com.coder.workspace.name': 'rooster', 'com.coder.user.username': 'tp'} + + def test_from_vscode_proxy_uri(self): + env = {'VSCODE_PROXY_URI': 'https://{{port}}--main--a--b.coder.x/'} + self.assertEqual(kc.url_template(env, {}), 'https://{{port}}--main--a--b.coder.x') + + def test_from_pod_labels(self): + env = {'CODER_AGENT_URL': f'https://{DOMAIN}/'} + self.assertEqual(kc.url_template(env, self.labels), TEMPLATE) + self.assertEqual(kc.url_template({**env, 'CODER_WORKSPACE_AGENT_NAME': 'dev'}, self.labels), TEMPLATE.replace('--main--', '--dev--')) + self.assertEqual(kc.url_template({'KEYCLOAK_CLIENTS_DOMAIN': 'other.org'}, self.labels), 'https://{{port}}--main--rooster--tp.other.org') + + def test_not_a_workspace(self): + with self.assertRaises(kc.Problem): + kc.url_template({'CODER_AGENT_URL': 'https://x'}, {}) + + def test_origins_and_domain(self): + self.assertEqual(kc.origins_for(TEMPLATE, [8080]), [f'https://8080--main--rooster--tp.{DOMAIN}']) + self.assertEqual(kc.domain_of(TEMPLATE), DOMAIN) + + def test_workspace_of(self): + self.assertEqual(kc.workspace_of(ws(8080), DOMAIN), ('rooster', 'tp')) + self.assertIsNone(kc.workspace_of('http://localhost:8080/*', DOMAIN)) + self.assertIsNone(kc.workspace_of(f'https://{DOMAIN}/*', DOMAIN)) + self.assertIsNone(kc.workspace_of(f'https://grafana.{DOMAIN}/*', DOMAIN)) + + +class Merge(unittest.TestCase): + static = ['http://localhost:8080/*'] + + def test_adds_mine_keeps_live_drops_gone(self): + current = [*self.static, ws(8080, 'other'), ws(8080, 'deleted'), 'https://stale.example/*'] + alive = {('rooster', 'tp'), ('other', 'tp')} + merged = kc.merge_urls(current, self.static, [ws(8080), ws(5173)], alive, DOMAIN) + self.assertEqual(merged, [*self.static, ws(8080, 'other'), ws(8080), ws(5173)]) + + def test_idempotent(self): + once = kc.merge_urls([], self.static, [ws(8080)], {('rooster', 'tp')}, DOMAIN) + self.assertEqual(kc.merge_urls(once, self.static, [ws(8080)], {('rooster', 'tp')}, DOMAIN), once) + + def test_own_urls_even_if_not_listed_alive(self): + self.assertEqual(kc.merge_urls([], [], [ws(8080)], set(), DOMAIN), [ws(8080)]) + + +class ClientRep(unittest.TestCase): + spec = {'clientId': 'app', 'publicClient': True, 'attributes': {'pkce.code.challenge.method': 'S256'}, + 'redirectUris': ['http://localhost:8080/*'], 'roles': ['admin'], 'workspaceUrls': True} + mine = [f'https://8080--main--rooster--tp.{DOMAIN}'] + + def test_new_client(self): + rep = kc.client_rep(None, self.spec, self.mine, {('rooster', 'tp')}, DOMAIN) + self.assertNotIn('roles', rep) + self.assertNotIn('workspaceUrls', rep) + self.assertEqual(rep['redirectUris'], ['http://localhost:8080/*', ws(8080)]) + self.assertEqual(rep['webOrigins'], ['+']) + self.assertEqual(rep['attributes'], {'post.logout.redirect.uris': '+', 'pkce.code.challenge.method': 'S256'}) + + def test_existing_keeps_others_and_its_own_fields(self): + existing = {'id': 'u1', 'clientId': 'app', 'redirectUris': ['http://localhost:8080/*', ws(8080, 'other')], + 'webOrigins': ['https://x'], 'attributes': {'post.logout.redirect.uris': 'https://x/*', 'other': '1'}, + 'protocolMappers': [{'name': 'kept'}]} + spec = {**self.spec, 'protocolMappers': [{'name': 'new'}]} + rep = kc.client_rep(existing, spec, self.mine, {('rooster', 'tp'), ('other', 'tp')}, DOMAIN) + self.assertEqual(rep['id'], 'u1') + self.assertEqual(rep['redirectUris'], ['http://localhost:8080/*', ws(8080, 'other'), ws(8080)]) + self.assertEqual(rep['webOrigins'], ['https://x']) + self.assertEqual(rep['attributes']['post.logout.redirect.uris'], 'https://x/*') + self.assertEqual(rep['protocolMappers'], [{'name': 'kept'}]) + + def test_client_without_workspace_urls(self): + spec = {'clientId': 'mobile', 'redirectUris': ['app://*']} + existing = {'id': 'u2', 'redirectUris': ['app://*', ws(8080)]} + self.assertEqual(kc.client_rep(existing, spec, self.mine, set(), DOMAIN)['redirectUris'], ['app://*']) + + +class Config(unittest.TestCase): + def test_find_config_walks_up(self): + with tempfile.TemporaryDirectory() as d: + os.makedirs(os.path.join(d, '.devcontainer')) + os.makedirs(os.path.join(d, 'a', 'b')) + open(os.path.join(d, '.devcontainer', 'devcontainer.json'), 'w').close() + root, path = kc.find_config(os.path.join(d, 'a', 'b')) + self.assertEqual(os.path.realpath(root), os.path.realpath(d)) + self.assertTrue(path.endswith('.devcontainer/devcontainer.json')) + + +if __name__ == '__main__': + unittest.main() diff --git a/.devcontainer/features/playwright-chromium/devcontainer-feature.json b/.devcontainer/features/playwright-chromium/devcontainer-feature.json new file mode 100644 index 0000000..44b558e --- /dev/null +++ b/.devcontainer/features/playwright-chromium/devcontainer-feature.json @@ -0,0 +1,16 @@ +{ + "id": "playwright-chromium", + "version": "1.0.0", + "name": "Playwright Chromium", + "description": "Chromium for Playwright and the Linux libraries it needs, in a shared folder any user can update.", + "options": { + "version": { + "type": "string", + "default": "1.63.0", + "description": "The Playwright version whose Chromium to install. Keep it in step with package-lock.json; postCreateCommand.sh fetches a matching one if it drifts." + } + }, + "containerEnv": { + "PLAYWRIGHT_BROWSERS_PATH": "/ms-playwright" + } +} diff --git a/.devcontainer/features/playwright-chromium/install.sh b/.devcontainer/features/playwright-chromium/install.sh new file mode 100755 index 0000000..fc36167 --- /dev/null +++ b/.devcontainer/features/playwright-chromium/install.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Installs Chromium for Playwright with its Debian packages (fonts, NSS, …). +# Needs Node, which the base image has. +set -euo pipefail + +VERSION="${VERSION:-1.63.0}" +export PLAYWRIGHT_BROWSERS_PATH="${PLAYWRIGHT_BROWSERS_PATH:-/ms-playwright}" + +if ! command -v npx >/dev/null 2>&1; then + echo "playwright-chromium: needs Node (npx) in the base image." >&2 + exit 1 +fi + +export npm_config_update_notifier=false npm_config_cache="$(mktemp -d)" +npx --yes "playwright@${VERSION}" install --with-deps chromium +rm -rf "$npm_config_cache" + +# The container user (node) runs `playwright install` when package-lock.json moves on +chmod -R a+rwX "$PLAYWRIGHT_BROWSERS_PATH" diff --git a/.devcontainer/features/python-venv/devcontainer-feature.json b/.devcontainer/features/python-venv/devcontainer-feature.json new file mode 100644 index 0000000..6298ed8 --- /dev/null +++ b/.devcontainer/features/python-venv/devcontainer-feature.json @@ -0,0 +1,6 @@ +{ + "id": "python-venv", + "version": "1.0.0", + "name": "Python venv", + "description": "python3 with venv and pip, to build the docs site (MkDocs Material)." +} diff --git a/.devcontainer/features/python-venv/install.sh b/.devcontainer/features/python-venv/install.sh new file mode 100755 index 0000000..20fd047 --- /dev/null +++ b/.devcontainer/features/python-venv/install.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Installs python3-venv, for the docs site's virtualenv (see docs/project/installing.md). +set -euo pipefail + +if python3 -c 'import ensurepip' >/dev/null 2>&1; then + echo "python3-venv: already there" + exit 0 +fi + +apt-get update -qq +DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends python3-venv +rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/keycloak-clients.json b/.devcontainer/keycloak-clients.json new file mode 100644 index 0000000..89ace72 --- /dev/null +++ b/.devcontainer/keycloak-clients.json @@ -0,0 +1,54 @@ +[ + { + "clientId": "blockout", + "name": "Blockout", + "description": "The web game. Client role admin: Blockout admins (accounts and roles, teacher codes, schools).", + "publicClient": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "attributes": { "pkce.code.challenge.method": "S256" }, + "redirectUris": ["https://blockout.deepspacecartel.com/*", "http://localhost:8080/*", "http://localhost:5173/*"], + "protocolMappers": [ + { + "name": "identity provider", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "config": { + "user.session.note": "identity_provider", "claim.name": "identity_provider", "jsonType.label": "String", + "id.token.claim": "true", "access.token.claim": "false", "userinfo.token.claim": "false", "introspection.token.claim": "false" + } + } + ], + "roles": ["admin"], + "workspaceUrls": true + }, + { + "clientId": "blockout-mobile", + "name": "Blockout app", + "description": "The phone app. Its tokens name blockout as their audience, so the server accepts them.", + "publicClient": true, + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "attributes": { "pkce.code.challenge.method": "S256" }, + "redirectUris": ["blockout://*", "exp://*", "http://localhost:8081/*"], + "protocolMappers": [ + { + "name": "blockout audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "config": { "included.client.audience": "blockout", "access.token.claim": "true", "id.token.claim": "false", "introspection.token.claim": "true" } + }, + { + "name": "identity provider", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "config": { + "user.session.note": "identity_provider", "claim.name": "identity_provider", "jsonType.label": "String", + "id.token.claim": "true", "access.token.claim": "false", "userinfo.token.claim": "false", "introspection.token.claim": "false" + } + } + ] + } +] diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index a0e6eb9..556e5c6 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -1,55 +1,41 @@ #!/usr/bin/env bash -# Sets up the dev container with everything used to build and test Blockout: -# - npm dev dependencies from package.json: Playwright (browser tests) and jsQR -# (reading the QR codes the game draws) -# - Chromium for Playwright, plus the Linux libraries it needs -# - cloudflared, for sharing the classroom server through a Cloudflare tunnel -# The game itself has no dependencies: it runs from index.html, and the classroom -# server is plain Node (node server/classroom.js). -# -# Safe to run again: each step skips what's already there. +# Finishes setting up the dev container once the workspace is mounted. +# System tools come from the features in devcontainer.json; this installs the +# npm packages and the docs site's Python venv (.venv). set -euo pipefail cd "$(dirname "$0")/.." say() { printf '\n\033[1m▶ %s\033[0m\n' "$*"; } -# sudo when we aren't root (the devcontainer user is "node") -SUDO='' -if [ "$(id -u)" -ne 0 ]; then - if command -v sudo >/dev/null 2>&1; then SUDO='sudo'; else echo "Needs root or sudo for system packages." >&2; exit 1; fi -fi - -say "npm packages (Playwright, jsQR)" -if [ -f package-lock.json ]; then npm ci; else npm install; fi - -say "Chromium for Playwright, with its system libraries" -# --with-deps installs the Debian packages Chromium needs (fonts, NSS, …) -npx --yes playwright install --with-deps chromium - -say "cloudflared (Cloudflare tunnels)" -if command -v cloudflared >/dev/null 2>&1; then - echo "already installed: $(cloudflared --version | head -n 1)" -else - arch="$(dpkg --print-architecture)" # amd64 or arm64 - tmp="$(mktemp -d)" - curl -fsSL -o "$tmp/cloudflared.deb" "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb" - $SUDO dpkg -i "$tmp/cloudflared.deb" - rm -rf "$tmp" - cloudflared --version | head -n 1 -fi - -say "Checking" -node --version +say "npm packages" +npm ci + +say "Chromium for Playwright" +# Already in the image; downloads a new one only if package-lock.json moved Playwright on +npx playwright install chromium + +say "Docs site venv (MkDocs Material)" +[ -d .venv ] || python3 -m venv .venv +.venv/bin/pip install --quiet --disable-pip-version-check -r docs/requirements.txt + +say "Versions" +echo "node $(node --version)" npx playwright --version -node --test test/*.test.js 2>&1 | grep -E '^ℹ (tests|pass|fail)' || true +cloudflared --version | head -n 1 +python3 --version +gh --version | head -n 1 +.venv/bin/mkdocs --version -cat <<'EOF' +cat <<'MSG' Ready. Handy commands: - npm test unit tests - npm run classroom classroom server on http://localhost:8080 + npm test unit tests (every app and package) + npm run lint ESLint + npm run dev Vite dev servers + the API server (game at http://localhost:5173) + npm start build everything and serve it on http://localhost:8080 + .venv/bin/mkdocs serve the docs site on http://localhost:8000 cloudflared tunnel --url http://localhost:8080 share it; then restart the server with - npm run classroom -- --public-url https://.trycloudflare.com -EOF + npm start -- --public-url https://.trycloudflare.com +MSG diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49a58c0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +# Only what the image build needs: sources, package files, the lock file +**/node_modules +**/dist +**/.turbo +.git +.github +.devcontainer +.venv +.claude +data +site +docs +deploy +*.md +Blockout.pdf +Dockerfile +.dockerignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9f6739f --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,35 @@ +# Keeps the pinned action SHAs (with their version comments), npm packages, the +# dev container, and the images (the Dockerfile's Node, and the cluster's +# MongoDB, Valkey and Postgres) current. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + tooling: + patterns: ['*'] + # The ghcr.io features in .devcontainer/devcontainer.json. Not the image (its + # 5-26-trixie tag already floats within image version 5) or the local features in + # .devcontainer/features, whose versions are bumped by hand. + - package-ecosystem: devcontainers + directory: / + schedule: + interval: weekly + # Image tags and digests in the Dockerfile (the chart's images: the helm entry below) + - package-ecosystem: docker + directories: [/] + schedule: + interval: weekly + groups: + images: + patterns: ['*'] + - package-ecosystem: helm + directory: /deploy/blockout + schedule: + interval: weekly diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67ffca1 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +# Lint, test and build every app and package, and build the docs site, on each +# push and pull request. +name: ci + +on: + pull_request: + push: + branches: [main, 'v*'] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + # Not ubuntu-latest: from Ubuntu 26 its kernel (6.19 to 7.0.13) stops every + # MongoDB 8 at start-up (SERVER-121912). Move on once runners have 7.0.14+. + runs-on: ubuntu-24.04 + timeout-minutes: 15 + # The stores' tests also run against real MongoDB and Valkey (Redis) + services: + mongo: + image: mongo:8.0.34 + ports: ['27017:27017'] + valkey: + image: valkey/valkey:8.1.10-alpine + ports: ['6379:6379'] + env: + BLOCKOUT_MONGO_URL: mongodb://localhost:27017/blockout + BLOCKOUT_REDIS_URL: redis://localhost:6379 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 26 + cache: npm + - run: npm ci + # The dev container's Chromium matches the Playwright in package-lock.json + - run: node .devcontainer/check.mjs + - run: npm run lint + - run: npm test + # The dev container's keycloak-clients script (Python, standard library only) + - run: python3 -m unittest discover -s .devcontainer/features/keycloak-clients/test + - run: npm run build + + # The container image builds, and has no known high or critical + # vulnerabilities that a fix exists for. Not pushed: release.yml does that. + image: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + load: true + tags: blockout:ci + cache-from: type=gha + cache-to: type=gha,mode=max + # Runs the image: without its settings it must refuse to start (and say why) + - name: Refuses to start without settings + run: | + if docker run --rm blockout:ci; then echo "started without settings" >&2; exit 1; fi + # (pinned by commit: see the March 2026 trivy-action advisory before bumping) + - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: blockout:ci + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '1' + + # The Helm chart is well-formed and renders for every cluster's values + chart: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + - run: | + helm repo add codecentric https://codecentric.github.io/helm-charts + helm dependency build deploy/blockout + # (placeholders for the values only a deploy has) + - run: | + secrets=(--set keycloak.google.clientSecret=ci --set backup.rclone.RCLONE_CONFIG_STORE_TYPE=local) + helm lint --strict deploy/blockout -f deploy/blockout/values-workspace.yaml "${secrets[@]}" + for values in deploy/blockout/values-*.yaml; do + helm template blockout deploy/blockout -f "$values" "${secrets[@]}" > /dev/null + done + + docs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: docs/requirements.txt + - run: pip install -r docs/requirements.txt + # --strict: a broken link or a page missing from the nav fails the build + - run: mkdocs build --strict diff --git a/.github/workflows/devcontainer.yml b/.github/workflows/devcontainer.yml new file mode 100644 index 0000000..414c78c --- /dev/null +++ b/.github/workflows/devcontainer.yml @@ -0,0 +1,55 @@ +# Builds the dev container (image, features, postCreateCommand.sh) and checks the +# tools work inside it, whenever something it installs changes, and weekly to catch +# a base image or feature that broke upstream. +name: devcontainer + +on: + pull_request: + paths: + - .devcontainer/** + - package-lock.json + - docs/requirements.txt + - .github/workflows/devcontainer.yml + push: + branches: [main, 'v*'] + paths: + - .devcontainer/** + - package-lock.json + - docs/requirements.txt + - .github/workflows/devcontainer.yml + schedule: + - cron: '17 6 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: devcontainer-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: devcontainers/ci@513af61f4de4f75d37e4438f184ba4358f0fc1ca # v0.3.1900000450 + with: + push: never + runCmd: | + set -e + node .devcontainer/check.mjs + gh --version + cloudflared --version + coder version + kubectl version --client + helm version + op --version + test -n "$(ls ~/.claude/skills)" + node -e "require('playwright').chromium.launch().then(b => b.close())" + npm run lint + npm test + .venv/bin/mkdocs build --strict diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7f6fd86 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,101 @@ +# A tag like v0.4.0 publishes a release: +# - the image ghcr.io//blockout: (and :), with an SBOM and a +# signed build provenance attestation +# - the Helm chart oci://ghcr.io//charts/blockout: +# - a GitHub release with that version's CHANGELOG section +name: release + +on: + push: + tags: ['v*'] + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: write # the GitHub release + packages: write # ghcr.io + id-token: write # signing the attestation + attestations: write + env: + VERSION: ${{ github.ref_name }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Names + id: names + # (a tag is only accepted as a version: v1.2.3, optionally -something) + run: | + [[ "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "not a version tag: $VERSION" >&2; exit 1; } + owner="${GITHUB_REPOSITORY_OWNER,,}" + echo "image=ghcr.io/$owner/blockout" >> "$GITHUB_OUTPUT" + echo "charts=oci://ghcr.io/$owner/charts" >> "$GITHUB_OUTPUT" + echo "version=${VERSION#v}" >> "$GITHUB_OUTPUT" + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ steps.names.outputs.image }} + tags: | + type=semver,pattern={{version}} + type=sha,format=long + - id: build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ steps.names.outputs.version }} + REVISION=${{ github.sha }} + sbom: true + provenance: mode=max + cache-from: type=gha + - uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 + with: + subject-name: ${{ steps.names.outputs.image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + - uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + # (values reach the shell as environment variables, never pasted into the script) + - name: Helm chart + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + CHART_VERSION: ${{ steps.names.outputs.version }} + CHARTS: ${{ steps.names.outputs.charts }} + run: | + echo "$GH_TOKEN" | helm registry login ghcr.io --username "$ACTOR" --password-stdin + helm repo add codecentric https://codecentric.github.io/helm-charts + helm dependency build deploy/blockout + helm package deploy/blockout --version "$CHART_VERSION" --app-version "$CHART_VERSION" -d /tmp/chart + helm push /tmp/chart/blockout-*.tgz "$CHARTS" + - name: GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE: ${{ steps.names.outputs.version }} + IMAGE: ${{ steps.names.outputs.image }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + # this version's section of CHANGELOG.md + awk -v v="$RELEASE" ' + index($0, "## [" v "]") == 1 { on = 1; next } + on && /^## \[/ { exit } + on { print }' CHANGELOG.md > /tmp/notes.md + printf '\nImage: `%s:%s` (`%s`)\n' "$IMAGE" "$RELEASE" "$DIGEST" >> /tmp/notes.md + gh release create "$VERSION" --title "$VERSION" --notes-file /tmp/notes.md --verify-tag diff --git a/.gitignore b/.gitignore index c2658d7..8f40a76 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,16 @@ node_modules/ +/data/ +dist/ +.turbo/ +.venv/ +site/ +# the chart's dependencies (helm dependency build; Chart.lock pins them) +deploy/blockout/charts/ +# values with secrets, passed at install time +secrets*.yaml + +# Expo (the phone app) on this machine +.expo/ + +# Python bytecode (the dev container's keycloak-clients tests) +__pycache__/ diff --git a/Blockout.pdf b/Blockout.pdf new file mode 100644 index 0000000..6695d61 Binary files /dev/null and b/Blockout.pdf differ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..572d463 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,185 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed +- The speed-ups come with the Journey and switch on by themselves once the mechanics are learned: fast CPU explanations on 4×4, auto roll on 5×5, auto placing on 6×6, instant CPU turns on 7×7 (which also unlocks "Press a button after CPU turns"). They're no longer bought, and they can be switched back in Settings. Whoever bought them keeps them; players already past those boards get them, with their settings left as they are. +- Sign-in on deepspacecartel.com goes through the cluster's shared Keycloak (realm `dev`) instead of one of Blockout's own. Anyone can sign in with Google or register with an email address. An address that isn't verified (no email is sent yet) doesn't put an account in a school, so it can't join that school's classes; Google accounts are verified. Blockout admins are the `blockout` client's `admin` role in Keycloak, no longer a realm role ([ADR-0016](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0016-shared-keycloak.md)). + +## [0.11.0] - 2026-10-17 + +### Changed +- Today's practice deals three cards to pick from, like the cards of a level-up: a table that needs practice, one to learn, a taste of the next table, your Journey's level, or (from 6×6) a board to win. Doing any one of them counts. Don't like them? New cards cost 10 points, then 20, 40… that day. + +## [0.10.0] - 2026-10-17 + +### Changed +- Practice's second mode is **Auto** instead of Free practice: no tables or difficulty to pick, the game asks what you need from every table you've reached (your facts to practice, then the ones that need practice, then Learning and new facts mixed; mastered facts only once everything else is), with the length, level and timer you choose. + +### Fixed +- Homework for a times table started a Journey round instead of that table, and homework for a game played the Journey's level instead of its board. Table homework now starts its round straight away, and game homework plays in Free play. + +### Removed +- Four settings: *First rectangle* (the corner rule), *Dice* (real dice), *Only roll what fits* and *Click to place*. Every game now rolls in the app, only rolls what fits (nobody passes while anything fits), and lets the first rectangle go anywhere; placing is *Draw it* or *Auto*. Players who bought Click to place get Auto (or their points back if they had it); Real dice and the *No room* options are refunded. In the web game, the phone app and live classes (teachers no longer pick *Only rolls that fit*). Old invite links still open. + +## [0.9.2] - 2026-10-17 + +### Changed +- Better rolls: no 1 × N (like 1 × 1 or 1 × 7) while a roll without a 1 fits, and no repeats of a rectangle already rolled that game while a new one fits (3 × 4 and 4 × 3 count as the same). The dice roll again, in every game from single player to live classes. A 1 × N or a repeat still comes when it's all that fits, like the last 1-wide gaps. Real dice rolled at the table are left as they are. Games are about a fifth shorter: fewer tiny rectangles. + +## [0.9.1] - 2026-10-17 + +### Changed +- The Journey's first boards, 1×1 to 3×3, have one stage each: beat the CPU with 4 out of 5 answers right first try. Three stages start on 4×4, so the Journey has 36 levels. Before, 1×1 was played three times, and 2×2's last stage (more than half the board) was mostly luck ([ADR-0014](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0014-first-boards-one-stage.md)). Players keep their board and stage. +- A board's stage 3 asks for every answer right first try, instead of more than half the board. That was mostly luck, and players got stuck on "So close! Now claim more than half the board" even after winning. + +## [0.9.0] - 2026-10-17 + +### Changed +- Expert in Practice's Journey ([ADR-0013](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0013-practice-expert-rounds.md)): each times table now has five rounds, 5, 10 and 20 questions in Learn, then 10 in Expert, then 10 in Expert against a timer that gets shorter along each difficulty's tables (30 s, 10 s, 5 s). Expert practice and its timers are no longer bought: reaching them unlocks them for Free practice. Players keep their table and round. + +## [0.8.0] - 2026-10-17 + +### Changed +- Smaller steps on both Journeys ([ADR-0012](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0012-journey-stages.md)). Each board now has three stages: beat the CPU with 4 out of 5 answers right first try, then without “Help me count”, then also claiming more than half the board (42 levels in all). Each times table in Practice has three rounds, 5, 10 and 20 questions (36 levels). 10- and 20-question rounds are no longer bought: reaching them on Practice's Journey unlocks them. Players keep their place: they start on the first stage of the board or table they were on. + +### Fixed +- Practice's Journey asked every table up to × 12 (like 3 × 12 on the ×6 level). Its facts now go up to the table's dice, as Practice always did: 6 for ×1–×6, 8 for ×7 and ×8, 12 from ×9. + +## [0.7.0] - 2026-10-17 + +### Added +- Practice's Journey: Practice goes one times table at a time, from ×1 to ×12, with a little review of the tables before. A round with 4 out of 5 right first try moves you up and unlocks the next table; Free practice (pick your own tables) opens at Practice Level 2. In the web game and the phone app ([ADR-0011](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0011-practice-journey.md)). + +### Changed +- Practice mode is free, and times tables are no longer bought: Practice's Journey unlocks them. Existing players start on the highest table they own and keep everything. +- The menu's Journey, Free play, Learn, Expert and Play buttons and the Sign in chip use icons instead of emojis. + +## [0.6.0] - 2026-10-17 + +### Added +- The Journey: single player now climbs one level at a time, from a 1×1 board with a 1-sided die to 12×12 with a 12-sided die, then challenge levels (16×16, Tricky, 20×20, Master, Legend, 24×24). Beat the CPU with 4 out of 5 answers right first try to move up; each level up earns points, and reaching Level 6, Level 12 and the end earn achievements. The menu shows one card and "Play Level N"; Free play (pick a difficulty and board) opens at Level 6. In the web game and the phone app ([ADR-0010](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0010-journey-progression.md)). +- In the phone app, bonuses, achievements and stickers no longer pop up during a game or practice round: they're listed on its results, the same kind stacked into one ("⚡ Speedy ×3 · +9 bonus points"). Pop-ups elsewhere stack repeats too. +- Today's practice: one small task a day at the top of Homework (now there for guests too, its badge counting today's task), picked from the player's own Times Table (facts that need practice, the next table to master, a taste of the next table, or a game that stretches a little), worth 30 points and 10 more for each day in a row. In the web game and the phone app, for guests too. +- Blockout for phones and tablets (`apps/mobile`): a React Native app, built with Expo, that runs in Expo Go. You against the CPU or two players on one phone, Easy to Hard, drawing by dragging, a keypad, "Help me count", points and achievements, and signing in with Google, with the save synced with the web game. +- The Wardrobe in the phone app (menu › Looks): every look from the web game, worn in its games and practice, from dice, keypads and boards to the CPU's character and the win celebration. +- Accounts in the phone app: homework (with Play ›), joining a class with its code, a code for a grown-up with its QR code, using a teacher's or a grown-up's code, and deleting a teacher's or grown-up's account. +- Live classes in the phone app: join with the class code (or its link) and play the whole class's games, pairs and tournaments, with the same rewards as on the web. +- Grown-ups in the phone app: a teacher's My classes (roster, homework, planned tournaments, teacher codes) and hosting a live class, a parent's My kids, and the admin page. +- The rules of a game, turn by turn, without a screen (`@blockout/engine/match`), shared by the phone app and ready for the web game. +- A Keycloak client for the app, `blockout-mobile` (`keycloak.mobile.redirectUrls`), whose tokens the server accepts like the web game's. +- Keycloak's admin console can be public (`keycloak.httpRoute.adminConsole`; on in the workspace cluster, at `https://id.deepspacecartel.com/admin`). Its `master` realm gets brute-force protection either way. + +### Changed +- Boards and difficulties are no longer bought in the Shop or gated by Times Table mastery: the Journey unlocks them. Existing players start on the level their boards, difficulties and wins reach (Level 6 at least once they've played) and keep everything they had. +- After “Help me count”, a right answer earns its squares (or practice point) but no bonus points: no first-try, speedy, streak or timer bonus, and the streak ends. Speedy, Big block and Twelve times now need the answer right first time too. +- The phone app's backspace key is red, like the web game's. + +## [0.5.0] - 2026-10-17 + +### Changed +- One Helm chart installs everything a deployment needs: the servers, MongoDB, Valkey, Keycloak with its Postgres, nightly backups and a monthly restore check. Passwords are made by the chart and kept; the Google client secret, image credentials, mail and backup storage are values. `deploy/data` and `deploy/keycloak` are gone ([ADR-0009](https://github.com/DeepSpaceCartel/blockout/blob/main/docs/decisions/0009-one-chart.md)). +- The secrets only a person has can come from the environment, for example from 1Password with `op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh `. They reach Helm on its standard input, never its command line. +- Development signs in with the workspace's Keycloak at `https://id.deepspacecartel.com`, and `npm run services` forwards the chart's development release (`values-dev.yaml`). + +### Fixed +- A student's long poll is no longer answered with the version of the class it already has (a late message about an older change answered it): it waits for the next change. +- CI's tests with MongoDB stay on Ubuntu 24.04: MongoDB 8 doesn't start on the kernels of Ubuntu 26's runners (6.19 to 7.0.13). + +## [0.4.1] - 2026-10-16 + +### Added +- The Helm chart can pull a private image (`imagePullSecrets`). +- A load test for live classes (`apps/server/scripts/loadtest.js`): 30 classes of 30 students on two servers get each roll to every student in under a second. + +### Fixed +- When a whole class answers in the same instant, no answer is lost: the server tries longer to save each one, and the game sends an answer again if the class was busy. + +## [0.4.0] - 2026-10-15 + +The first release since 0.2.0 (0.3.0 wasn't released on its own): accounts and homework, more games, and everything needed to run Blockout as a public service. Details and decisions: [the v0.3.0 work log](https://github.com/DeepSpaceCartel/blockout/blob/v0.4.0/docs/v0.3.0-log.md) and the [decision records](https://github.com/DeepSpaceCartel/blockout/tree/v0.4.0/docs/decisions). + +### Added +- Your data: grown-ups can download everything kept about their child (and teachers and grown-ups their own), and accounts nobody has used for 13 months are deleted by themselves (`BLOCKOUT_RETENTION_DAYS`). The privacy page says where everything is kept and for how long. +- Operations: Prometheus metrics on a port of their own (`METRICS_PORT`), alert rules for the cluster's Prometheus, nightly encrypted backups of MongoDB and Keycloak's Postgres to object storage with a monthly restore drill, and a "Run it day to day" guide. +- A container image (`Dockerfile`) and a Helm chart (`deploy/blockout`) for running Blockout in Kubernetes, published to GitHub's registry on each release with an SBOM and signed provenance. CI builds and scans the image and checks the chart on every push. +- Anyone can sign in as a student; one-time codes do the rest. A **teacher code** (from an admin, or a teacher inviting a colleague under **🏫 My classes**) makes a teacher; a **grown-up's code** (made by the child on their account page, or by their teacher next to the student in the roster) makes a child's grown-up and links them. Codes work once, for 7 or 14 days, with a QR code and a link. +- An admin page at `/admin/` for accounts with the Keycloak `admin` role: find accounts, change roles, delete accounts, make teacher codes and add schools. +- Deleting accounts: a grown-up can delete their child's account, and teachers and grown-ups their own; admins any account. Everything in the account goes with it. +- Teachers see each student's grown-ups and can take off one who shouldn't be there. +- Structured logs: JSON lines in production (one per request, plus an audit record for each account change, by id only), short text lines at a terminal. `LOG_LEVEL` and `LOG_FORMAT` set them. +- Health endpoints for Kubernetes probes: `/health/startup`, `/health/live` and `/health/ready`. On `SIGTERM` the server finishes cleanly: it stops taking connections, ends live-update streams so browsers reconnect, answers waiting polls and exits. +- Accounts for students, teachers and parents, signed in with a practice "dev sign-in" for now and ready for Keycloak (and school Google accounts) later. Guests can still play everything except hosting a class. +- Teachers: classes that students join with a code, a class stats page, homework and scheduled class or school tournaments whose lobby opens by itself. +- Parents: link a child with a code, see their Times Table and homework, and set extra homework. +- Homework only offers what the child can already play, and "Play ›" starts it straight away. +- A signed-in student's progress follows them to any device; the first time on a device, that device's guest progress is added to the account. +- Tricky, Master and Legend difficulties after Hard, and the IDDQD and FIVEMOREMINUTESMOM cheat codes, each with a secret achievement. +- Demos (on the main menu): 26 classic games turned into quick math games to try, from Times Tetris and Fact Invaders to Times Bingo, Fact Racer and Multiplier Bakery. +- Number Snake (on More games): a snake game that eats numbers instead of apples: the answer to a times-table question, every multiple of a number, or (Add it up) any number you like, added to a running total that you then have to give. Movement (Auto, or Manual: one square per press), speed (Slow, Normal, Fast) and walls (Stop, Wrap, Lose a heart, Game over) are chosen on its menu. +- Classroom: in Pairs (and tournament matches), you see your partner's roll, the rectangle they're placing and the one they're answering as it happens. In Whole class, students see the boards of the classmates nearest them on the leaderboard, and the teacher's screen shows every student's board. +- Classroom: everyone's Wardrobe avatar and title show next to their name, on the lobby, leaderboards, match cards and results. +- More games: addition, subtraction, division and four fraction games, reachable from the main menu. Each has Single player, Home (two players on one screen) and Practice, its own points, a shop (Medium, then Hard; 20-question rounds, then Expert practice; Home multiplayer), achievements and stats. + +### Changed +- Accessibility: buttons and small orange text use a slightly deeper orange so they're readable (WCAG AA contrast, light and dark), locked menu cards are no longer faded, and the dice and the admin page's fields have names for screen readers. An axe-core pass over the main screens finds nothing. +- Roles are kept by Blockout, not taken from the sign-in provider: Keycloak's `student`, `teacher` and `parent` roles are ignored, and only `admin` counts. +- Live classes can be kept in Redis (Valkey, `BLOCKOUT_REDIS_URL`), so several servers can run them together: a class carries on when a server restarts, crashes or is updated, and its rate limits are shared. A production server requires it. +- Accounts can be kept in MongoDB (`BLOCKOUT_MONGO_URL`), which several servers can share; a production server requires it. Without it, accounts are kept in memory only (`data/blockout.db` is copied into MongoDB with `apps/server/scripts/sqlite-to-mongo.js`). `deploy/data/install.sh` installs MongoDB and Valkey in the cluster, and `npm run services` forwards them for development. +- In the other games, Medium, Hard, 20-question practice, Expert practice and Home multiplayer are now unlocked with points. +- Hosting a class now needs a teacher sign-in. +- Legend deals teens (like 14 × 7) for half of its rolls and Tricky facts for the rest; Tricky, Master and Legend games now last about as long as Hard ones. +- The project is now a Turborepo of Vite apps and shared packages. `npm start` builds and serves everything; the other games moved from `prototypes/*.html` to `//`. +- Running the server now needs Node 26 or newer (it was 22.12). +- On Chromebooks, laptops and iPads held sideways, the main menu is laid out in two columns (modes and menu on the left, settings on the right), so Start is on screen without scrolling. Phones and portrait screens keep the one column. + +### Removed +- Permanent parent codes: grown-ups link a child with a one-time code instead. + +### Fixed +- A class no longer shows "lost" when the server is restarting or busy for a moment: the page switches to polling and keeps trying until the class answers, and only gives up when the class is really gone. +- A web address with a broken `%` escape (like `/%E0`) no longer stops the server for everyone; it gets a `400` answer. Request bodies that are cut off or arrive too slowly are answered too, instead of waiting forever. + +### Security +- Keycloak access tokens last 15 minutes instead of 8 hours, and the game renews them with the refresh token while the Keycloak session lasts. Dev sign-in tokens last 12 hours instead of 30 days. +- Rate limits: guessing class, room or parent codes is stopped after a run of wrong ones, and dev sign-in and joining are limited per address and account. Behind a proxy, set `TRUST_PROXY`. +- A Content Security Policy: pages run only Blockout's own scripts (the theme script moved out of the page into `theme.js`) and talk only to Blockout and the sign-in provider. +- Every response, redirects and live-class streams included, now carries the security headers, plus a `Permissions-Policy` (no camera, microphone or location) and, over HTTPS, `Strict-Transport-Security`. A production server no longer tells anyone its own network addresses. +- With `NODE_ENV=production` the server refuses to start with the practice dev sign-in or without an `https://` public address, and every setting is checked at start-up. +- API errors no longer carry internal details, and every response has basic browser security headers. +- Only a student's first name, last initial, role and email domain are stored; see [Privacy](https://github.com/DeepSpaceCartel/blockout/blob/v0.4.0/docs/privacy.md). + +## [0.2.0] - 2026-09-23 + +### Added +- Classroom multiplayer: a whole class on the same rolls, pairs on a shared board, and tournaments (knockout, double elimination, round robin, Swiss, king of the hill), run from the teacher's screen with a join code, link and QR code. +- Home multiplayer for 2–4 players on one screen. +- Shop with skill-tree unlocks, Wardrobe, stickers, achievements shown one at a time, stats and the Times Table. +- Practice mode with Learn and Expert levels, timers and finishing bonuses. +- Username checks for classroom names. + +## [0.1.0] - 2026-09-23 + +### Added +- The first playable Blockout: roll two dice, draw the rectangle, answer the multiplication, against the CPU. + + +[Unreleased]: https://github.com/DeepSpaceCartel/blockout/compare/v0.11.0...HEAD +[0.11.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.10.0...v0.11.0 +[0.10.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.9.2...v0.10.0 +[0.9.2]: https://github.com/DeepSpaceCartel/blockout/compare/v0.9.1...v0.9.2 +[0.9.1]: https://github.com/DeepSpaceCartel/blockout/compare/v0.9.0...v0.9.1 +[0.9.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.8.0...v0.9.0 +[0.8.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.7.0...v0.8.0 +[0.7.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.5.0...v0.6.0 +[0.5.0]: https://github.com/DeepSpaceCartel/blockout/compare/v0.4.1...v0.5.0 +[0.4.1]: https://github.com/DeepSpaceCartel/blockout/compare/v0.4.0...v0.4.1 +[0.4.0]: https://github.com/DeepSpaceCartel/blockout/compare/4921120...v0.4.0 +[0.2.0]: https://github.com/DeepSpaceCartel/blockout/compare/382b38c...4921120 +[0.1.0]: https://github.com/DeepSpaceCartel/blockout/tree/382b38c diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e8d344b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,158 @@ +# Blockout + +A multiplication-as-area game for 3rd graders, based on "Blockout" from Math for Love. Two dice are rolled, you draw an a × b rectangle on a grid and answer a × b to claim it. It runs mainly on classroom Chromebooks. + +**A Turborepo of plain JavaScript** (ES modules, no framework), built with Vite. npm workspaces: `apps/*` and `packages/*`. The browser's only runtime dependency is `qrcode-generator`. The server's are the official `mongodb` and `redis` clients ([ADR-0006](docs/decisions/0006-mongodb-and-redis.md)); everything else is Node built-ins. + +## Layout + +| Path | What | +|---|---| +| `apps/web` | Every web page, one Vite multi-page app ([ADR-0015](docs/decisions/0015-one-web-app.md)): `index.html` is the main game at `/`, each `/index.html` a page at `//`, its code in `src//` (tests in `test//`). One dev server (`:5173`, proxying `/api`), one build (`apps/web/dist`). | +| `apps/web/src/multiplication` | The main game, at `/`. `index.html` (in `apps/web`) has every screen and dialog (`.overlay > .dialog`). `main.js` loads the styles, `game/index.js`, then `accounts.js`. | +| `apps/web/src/multiplication/game/*.js` | The game UI, one module per area: `base` (constants, `el`), `wallet`, `settings`, `wardrobe`, `play` (game state and turns), `stats`, `shop`, `achievements`, `practice`, `invites`, `classroom`, `cheats`, `checkin`, `input`, `render`. See "How the game modules load" below. | +| `apps/web/src/multiplication/accounts.js` | Account screens: sign in, student homework (Play › starts the game), teacher My classes (roster, grown-ups, Invite a teacher), parent My kids, one-time codes (`#code=` links, `codeCard` with a QR code), deleting accounts, live tournament banner. Imports what it needs from `game/api.js`. | +| `apps/web/src/admin` | The admin page (`/admin/`, its own bundle): accounts (search, role, delete), teacher codes, schools. Only for accounts with the `blockout` client's Keycloak role `admin` (`resource_access.blockout.roles`, not a realm role: the realm is shared); the server checks every request. | +| `apps/web/src/multiplication/invite.js` | Invite links (`validate` whitelists every setting). | +| `apps/web/src/demos` | Demos (`/demos/`): 26 classic mechanics as quick math games. `catalog.js` lists them; each is `games/.js` exporting `mount(stage, ctx)`, where `ctx` (from `kit.js`) gives facts, the status bar, keypad, canvas, and timers and listeners that clean themselves up. `window.Demo.state()` for automated checks. | +| `apps/web/src/snake` | Number Snake (`/snake/`): a snake game that eats numbers. Rules in `rules.js` (pure, tested), the page in `main.js`; `window.SnakeGame` for automated checks. | +| `apps/mobile` | Blockout for phones and tablets: React Native with Expo (SDK 57: the newest the App Store's Expo Go runs; plain JS with JSX). `src/Game.js` plays turns from `@blockout/engine/match`; `src/Board.js` (SVG, drag by `PanResponder`); `src/account.js` (`expo-auth-session` PKCE as Keycloak client `blockout-mobile`, tokens in `expo-secure-store`, save sync like `@blockout/auth`); the Wardrobe's looks in a React context (`src/looks.js`: how each is drawn; `src/effects.js`: the moving ones); `src/AccountScreen.js` and `HomeworkScreen.js` (a student's account, as on the web); `src/ClassScreen.js` (a live class as a student; `Game.js` plays its games with the `live` prop) and `src/classroom.js` (the client, long polling); grown-ups: `MyClassesScreen.js`, `StudentPage.js`, `HostScreen.js`, `MyKidsScreen.js`, `AdminScreen.js`, with `forms.js` (chips for choices, fields, asking first); pure, tested: `sync.js`, `rewards.js`, `boardMath.js`, `celebration.js`, `links.js` (codes from web and app links). `npm run tunnel --workspace @blockout/mobile` for Expo Go; in the web build `window.BLOCKOUT_FAST` and `window.BlockoutMobile` for automated checks. Guide: `docs/guides/run-the-phone-app.md`. | +| `apps/web//` (7), `apps/web/src/more-games` | The other games (`/addition/`, …): pages whose `` names the game, all run by `src/operation.js` with `@blockout/game-kit`; plus the More games hub (`/more-games/`). | +| `apps/server/src/index.js` | Node server: the built pages (`apps/web/dist`; multiplication at `/`), JSON API, SSE plus `/poll` for classrooms, accounts routes. | +| `apps/server/src/live.js`, `roombackend/` | Live classes across servers: rooms kept in a backend (`redis.js` with Lua scripts, `memory.js` for one server and tests); every change goes through `withRoom` (read, change, write if unchanged, else retry); one server owns each room's timers by lease, others take over; presence beside the room, not in it. Tests run two runtimes on one backend (`test/live.test.js`). | +| `apps/server/src/metrics.js`, `log.js` | Prometheus metrics by hand (served on `METRICS_PORT`, never the public port) and structured JSON logs with OpenTelemetry field names; audit records for account changes, by id only. | +| `apps/server/src/rooms.js`, `tournament.js` | Pure room logic: whole class, pairs, tournaments (knockout, double elimination, round robin, Swiss, king of the hill). | +| `apps/server/src/moderation.js` | Username checks: `blocklist.txt`, plus OpenAI moderation only when `OPENAI_API_KEY` is set (fails open). | +| `apps/server/src/store/`, `auth.js`, `accounts.js` | Accounts: stores behind one async interface (`mongo.js` for servers, `memory.js` for tests, `sqlite.js` only reads old `data/blockout.db` files for `scripts/sqlite-to-mongo.js`; `index.js` picks one from `BLOCKOUT_STORE`/`BLOCKOUT_MONGO_URL`; every store passes `test/store.contract.js`; MongoDB schema steps in `migrations.js`); sign-in providers behind one interface (dev HMAC JWT, Keycloak RS256 via JWKS; `identity()` keeps only first name, last initial, role, email domain); the rules (classes, rosters, homework, parent links, scheduled tournaments, roles and one-time invites, admins, deleting accounts). | +| `packages/engine` | `@blockout/engine`: board, placement, dice, `rollForBoard`, difficulties (`DIFFICULTIES`, `diceFaces`, `createPairDice`, `fitsDifficulty`), CPU. `@blockout/engine/match`: one game turn by turn (roll, place, answer, help, CPU turn, end), no screen or timers. `@blockout/engine/{fraction,boards,variants}`: the other operations' rules. Pure. | +| `packages/progress` | `@blockout/progress`: wallet, `SHOP` (unlocks), achievements, fact stats, bonuses, practice picking, homework goals and options, cheat codes, `normalize` (save migrations). `@blockout/progress/cosmetics`: the Wardrobe's looks (registers its items into `SHOP`: import it before reading `SHOP` for every unlock); `/wardrobe`: what's worn and the Wardrobe's rows. `/homework`: a student's list as shown and the menu choice that plays one. `/classroom`: a student in a live class (waiting words, catching a pairs board up, results, rewards and history). `/grownups`: teachers' and parents' screens (activity, homework form, codes, planned tournaments). `/hosting`: hosting a live class (settings, buttons, roster, report). `/daily`: today's practice (the task, ticking it off, the reward). `/journey`: single player's Journey (the level, what it plays, its words; the ladder in `levels.js`). `@blockout/progress/shop`: the Shop's rules and words for every client (rows and why lines, `unlockOffer`, `purchase`, what's new, `activation`). Pure. | +| `packages/ui` | `@blockout/ui`: CSS in `src/css/*` (tokens, base, menu, game, dice skins, results, panels, rewards, wardrobe, players), the Lucide icon sprite (`icons.svg`), DOM helpers, `@blockout/ui/rewards` (toasts, achievement banners, confetti), and `@blockout/ui/vite` (`blockoutPage()`: adds the theme script and the sprite to every page). | +| `packages/game-kit` | `@blockout/game-kit`: `startGame(variantId)`, the shared page engine of the other games, and their `style.css`. `@blockout/game-kit/meta`: each game's save (`blockout.game.`): points, shop chains, achievements, question stats. Pure, tested. | +| `packages/classroom`, `packages/auth` | Clients: classroom server (`core.js`: `createClient({ base, token, storage, EventSource })`, long polling when there's no `EventSource`; `client.js`: the browser's, SSE with long-poll fallback, session in sessionStorage); sign-in (dev or Keycloak PKCE), bearer token, per-account progress key and sync. | +| `Dockerfile`, `deploy/blockout` | The image (build, then only the server's production dependencies and the built apps; runs as `node`) and the one Helm chart for a deployment ([ADR-0009](docs/decisions/0009-one-chart.md)): the servers (Deployment with the three health probes, HTTPRoute on the shared Gateway with no timeout for event streams, PDB, optional HPA, CiliumNetworkPolicy), MongoDB (replica set of one, started by a `replica-set` container), Valkey, optionally its own Keycloak (`keycloak.enabled`; off in the workspace cluster, which has a shared one, [ADR-0016](docs/decisions/0016-shared-keycloak.md): the `codecentric/keycloakx` dependency, `Chart.lock`, with its Postgres, its HTTPRoute and a post-install/upgrade realm Job), nightly encrypted backups and the monthly restore check (rclone's crypt remote). Passwords are made once by the chart (`lookup`) and kept; secrets only a person has are values (`-f secrets.yaml`, gitignored). `files/realm-blockout.json` is the realm (imported only when it doesn't exist). `values-workspace.yaml`, `values-production.yaml`, `values-dev.yaml` (only MongoDB and Valkey: `npm run services` forwards them), `values.schema.json`; `install.sh` (`helm upgrade --install --atomic`). Nothing is created by hand. `.github/workflows/release.yml` publishes the image and chart on a `v*` tag. Guides: `docs/guides/deploy.md`, `set-up-keycloak.md`, `operate.md`. | +| `docs/` | The MkDocs Material site (`mkdocs.yml`): `home/`, `guides/`, `concepts/`, `reference/`, `project/`, `decisions/` (ADRs), `privacy.md`, screenshots in `assets/screenshots/`. The v0.3.0 plan and log are kept out of the nav. | +| `.github/` | CI (`workflows/ci.yml`: lint, test and build on Node 26, strict docs build; `workflows/devcontainer.yml`: builds the dev container; actions pinned to commit SHAs) and Dependabot (npm, actions, dev container features). | + +Tests live next to their code (`packages/*/test`, `apps/*/test`, `node:test`). + +### How the game modules load +Every module in `src/game/` only declares things when it's imported. What used to run at load (event listeners, the initial render, `const el = {…}`) is in each module's `export function run()`, and `src/game/index.js` calls every `run()` in a fixed order. Keep it that way: +- **No load-time work outside `run()`,** except declarations that run nothing. +- **Cross-module writes go through a setter:** `setGame`, `setGameId`, `setPractice` and `setWardrobeFilter`; the imported binding is read-only. +- **Imports between modules are circular.** That's fine as long as nothing is used before `run()`. + +## Commands + +```sh +npm test # every package's tests through Turborepo (must stay green) +npm run lint # ESLint (eslint.config.js) +npm run build # Vite builds every page into apps/web/dist +npm start # build, then serve everything on http://localhost:8080 +npm start -- --public-url https://.trycloudflare.com # behind a Cloudflare tunnel +npm run dev # one Vite dev server for every page (:5173, proxying /api) + the server with --watch +cloudflared tunnel --url http://localhost:8080 +op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh 0.11.0 # install or update everything (values-workspace.yaml); secrets from 1Password +npm run services # forward the development MongoDB and Valkey (release blockout-dev) to localhost; prints the URLs +# Keycloak: the cluster's shared one (rts-terraform), realm dev; admin console https://id.deepspacecartel.com/admin (user admin; 1Password item "Keycloak") +BLOCKOUT_AUTH=keycloak npm start # the dev container sets KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID +keycloak-clients sync # Blockout's clients (.devcontainer/keycloak-clients.json) and this workspace's [sign-in] port URLs; runs on every start +``` + +- **The dev server:** the user usually keeps one running on 8080 behind a Cloudflare quick tunnel. The tunnel URL changes, so ask for it or reuse the last one given. +- **Memory:** the workspace container has 4 GiB (`free` shows the whole node), shared with the editor; a full parallel `npm test` + `npm run build` next to Metro or Playwright got it OOM-killed once. Use `npx turbo run --concurrency=2`, run builds one at a time, and Playwright only when nothing else heavy is running. `devcontainer.json` caps each Node process's heap at 1.5 GiB (`NODE_OPTIONS`). +- **After client changes:** rebuild (`npm run build`). Built assets are hashed and `index.html` is served `no-cache`, so a page reload picks them up. +- **After server changes:** restart the server. +- **Setup:** Dev Container Features install the GitHub CLI, kubectl and helm (the official features), and, from `.devcontainer/features/`, cloudflared, Playwright's Chromium with its libraries (in `/ms-playwright`), python3-venv, the DeepSpaceCartel Agent Skills (user-level, `~/.claude/skills`), the `coder` CLI from coder.deepspacecartel.com, the 1Password CLI (`op`, from 1Password's signed apt repository), and `keycloak-clients` (creates Blockout's clients in the shared Keycloak and adds this workspace's URLs on every start, ADR-0016; Python, tested by `python3 -m unittest discover -s .devcontainer/features/keycloak-clients/test`); `.devcontainer/postCreateCommand.sh` installs the npm packages and the docs venv (`.venv`). Feature versions are pinned in `devcontainer.json`: when Playwright moves on in `package-lock.json`, bump `playwright-chromium` to match (`node .devcontainer/check.mjs` checks, and CI runs it). `.github/workflows/devcontainer.yml` builds the container and smoke-tests it. + +## Browser testing (Playwright) + +Write throwaway scripts in the session scratchpad, not the repo. Build first (`npm run build`), require Playwright from `/workspaces/blockout/node_modules/playwright`, and spawn a private server on a spare port (8134+) that you kill at the end. The pattern: + +```js +const { chromium } = require('/workspaces/blockout/node_modules/playwright'); +const srv = spawn('node', ['/workspaces/blockout/apps/server/src/index.js', '8135'], { stdio: 'ignore', env: { ...process.env, BLOCKOUT_STORE: 'memory' } }); +// seed a save before the page loads (once per tab): +await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionStorage.setItem('s', 1); + localStorage.setItem('blockout.progress', JSON.stringify(pr)); } }, progress); +``` + +- **Seed the migration flags.** A seeded progress must include `boardsV2, practiceV2, placeAutoV1, gameExpertV1, practiceTimersV1, journeyV1, journeyV2, journeyV3, practiceJourneyV1, practiceJourneyV2, practiceJourneyV3, settingsGoneV1, boardWinsFromHistory: true` (and `journey: { level: 10 }` to start on 6×6, where the old free board was; `practiceJourney: { level: n }` with `unlocks` up to its table), or the old-save migrations in `Progress.normalize` grant extra unlocks and skew the test. Add `checkIn: { last: , streak: 1 }` to skip the daily check-in popup. +- **To unlock everything,** seed `unlocks: Progress.SHOP.map(i => i.id)`, or type the IDDQD cheat (~ key). +- **Useful ids:** + - `#start-btn`, `#roll-btn`, `#unlock-yes` (the quick-unlock confirm), `#cheats-input` + - `[data-mode="single|multi|practice"]`, `[data-setting="difficulty"] [data-value=…]`, `#board-size [data-size=…]` +- **Check for errors:** collect `pageerror` events and check there are none. Screenshot and look at the result. +- **Classroom flows:** use several browser contexts (a teacher plus students). Hosting needs a teacher sign-in: POST `/api/auth/dev` with `{ role: 'teacher', firstName, schoolId: 'cadence-park' }` and store `{ token, user }` in `localStorage['blockout.auth']`, or use the sign-in dialog. +- **Accounts:** start the test server with `BLOCKOUT_STORE=memory` so runs don't share data. +- **Other games:** they're at `//`. `window.PROTO_FAST = true` (an init script) makes the CPU quick, and `window.ProtoGame` exposes `state()`, `piece()` and `boards`. + +## Storage + +- **localStorage:** + - `blockout.auth`: `{ token, user }` when signed in; `blockout.progress.` is that account's save (cache), `blockout.mergedInto` the accounts this device's guest save went into + - `blockout.progress`: wallet, unlocks, achievements, facts per player key, counters, boardWins, helpedFacts, cheats, migration flags + - `blockout.settings`, `blockout.history`, `blockout.className`, `blockout.classHost` +- **sessionStorage** (per tab): `blockout.class`, the classroom session. +- **Save changes need a migration.** Anything that changes what a save means gets a one-time flag in `Progress.normalize`, so existing players never lose what they had. + +## Game design conventions + +- **Unlocks are skill-tree chains** (`SHOP` items with `requires`): + - Only the next step shows its name and price. Later steps are blurred "skeleton" placeholders with a lock (`Progress.isHidden`, `setLockBadge`). + - Boards and difficulties aren't sold: they're `journey` items, granted by reaching a Journey level (below). + - `perk` text appears in the unlock dialog and the shop. + - Buying from a menu control activates it (`activation` in `@blockout/progress/shop` says what; each client applies it). +- **The Journeys** (`@blockout/progress/journey`, levels in `levels.js`; ADRs [0010](docs/decisions/0010-journey-progression.md), [0011](docs/decisions/0011-practice-journey.md), [0012](docs/decisions/0012-journey-stages.md)): + - *Game:* `STEPS` are n×n boards with an n-sided die (1–12), then 6 challenges; boards from 4×4 have 3 stages, 1×1–3×3 one (`STAGES_FROM`; `LEVELS`, 36): win with ≥ 80 % first-try answers, then also without "Help me count", then every answer right first try (`levelResult`). `recordLevelGame` moves you up and grants a board's ids on its first stage, including the speed-ups (cpuSpeed 4×4, autoRoll 5×5, placeAuto 6×6, cpuInstant/cpuEnd 7×7), whose settings it also returns as `switches` for the client to turn on (`SWITCHES` in `levels.js`). Free play opens on 6×6 (`levelOfStep(6)`). The menu is a Journey card with "Play Level N". + - *Practice:* free; each ×n table has 5 rounds: 5, 10, 20 questions in Learn, 10 in Expert, 10 in Expert with a timer (`TIMERS`: shorter along each difficulty's tables) — `PRACTICE_LEVELS`, 60 ([ADR-0013](docs/decisions/0013-practice-expert-rounds.md)). Facts up to the table's dice (6/8/12), ~1 in 5 reviewing earlier tables (`journeyQueue`); the round sets length, Expert and timer (`createRound` with `journeyLevel`). `recordPracticeLevel` (in `Practice.finish`) grants `table`, the 10/20 lengths, Expert and the timers, each with the first round that brings it. Practice's other mode is Auto (opens on ×2; `practicePlay: 'free'`): `Practice.autoQueue` asks facts to practice, then Needs practice, then Learning and Not seen yet mixed, from the reached tables (`autoGroups`). +- **Per-difficulty unlocks:** each difficulty has its own Expert level, timers and practice question counts (`gameExpertId`, `gameTimerId`, `practiceCountId`, `practiceTimerId`, `practiceExpertId`). +- **Difficulties:** + + | Difficulty | Dice | + |---|---| + | Easy | d6 | + | Medium | d8 | + | Hard | d12 | + | Tricky | faces 3, 4, 6, 7, 8, 9, 12 | + | Master | d12 weighted to the player's weak facts | + | Legend | half teen × 2–9 (11–19), half Tricky facts; needs a board ≥ 12 and uses split explanations | + + `rollForBoard` prefers rolls with no 1 and not already rolled that game (`bag.seen`, shapes like `3x4`, saved with a live class's bag), rolling again, then falls back: no 1 even if repeated, then anything that fits. + Tricky, Master and Legend use pair dice (`createPairDice`) and virtual dice only. Pair dice make big rectangles rarer on smaller boards (`PAIR_ROOM`), and near the end they fall back to small facts that fit, so games last about as long as on Hard (a test checks this). Classroom stays on Easy, Medium and Hard. +- **Classroom games are unlock-free:** every lobby setting is free and cheats are off. +- **Bonuses** are in `@blockout/progress` (`answerBonus`, `timerBonus`, `boardFinishBonus`, `practiceFinishBonus`). Tell the player about them in the menu hints and the unlock dialogs. +- **Achievements:** + - They show one at a time as a WoW-style banner (`celebrate`, then a queue). + - Results screens stay minimal: no points line, stickers row or achievement list. Actions go on one row of equal buttons. The phone app is the exception: pop-ups would cover the game, so it holds them while playing and lists them, stacked, on the results (`src/notes.js`); urgent ones (a skipped roll, a failed send) still pop up. + - Secret achievements (`hidden: true`, group "Secrets") show as "???" until earned. +- **Cheat codes:** press ~. + - They're in `Progress.CHEAT_CODES`, with `cheatOn`/`cheatOff` storing what each changed so it can be undone. + - The menu lists active cheats by description, never by code. + - Each code gets its own secret achievement `cheat_` (a test enforces this). +- **Dialogs** have a bottom `.dialog-actions` button panel, like the Shop. +- **Accounts are optional:** guests must always be able to play everything except hosting a class. Store as little about children as possible (see docs/privacy.md). +- **Roles are Blockout's** ([ADR-0008](docs/decisions/0008-roles-in-blockout.md)): every new account is a student; one-time invites (hashed, expiring) make teachers and grown-ups; only `admin` comes from the token. An account in use keeps its role. +- **Today's practice** (`@blockout/progress/daily`): three cards a day to pick from, like a level-up's (`pickChoices` from `candidates`: the most useful first, two at random), from the player's own Times Table and Journey; doing any one does it. New cards cost points (`reroll`: 10, then doubling that day). Kept in the save (`daily.choices`) so they stay all day; a card may give a taste of the next, still locked, table. It's at the top of Homework (for guests too), not on the menu. +- **Homework** only asks for what the child can already play (`Progress.availableOptions`, `sharedOptions` for a class). Tapping it sets up the menu and starts the game. +- **The audience is 8-year-olds:** use short, friendly, concrete wording ("Help me count", "Needs practice"). Target devices are Chromebooks, and it must work at phone width too. + +## Docs, changelog and commits + +- **Docs** follow the site's structure: + - guides for doing things (with screenshots) + - `concepts/` for why things are the way they are + - `reference/` for facts to look up (`CONFIGURATION.md`, `HTTP-API.md`) + + Every page starts with a ``. Check with `mkdocs build --strict` (Python venv with `docs/requirements.txt`). +- **Screenshots** in `docs/assets/screenshots/` come from Playwright runs of the real game on a fresh server (1280 px wide). Retake them when a pictured screen changes. +- **CHANGELOG.md** follows Keep a Changelog: user-facing changes under `[Unreleased]`, by category. The version follows SemVer; it's 0.x, so a minor bump for new features. +- **Decisions:** a hard-to-reverse decision gets an ADR in `docs/decisions/`. Never edit an accepted ADR; supersede it with a new one. +- **Commits** follow Conventional Commits (`feat(accounts): …`, `fix(server): …`, `docs: …`, `refactor: …`, `build: …`, `ci: …`). +- **Server errors** are RFC 9457 problem details (`sendProblem`), keeping the `error` and `message` members the clients read. + +## Code style + +- **Match the surrounding code:** modern JavaScript ES modules, 2-space indent, single quotes, `const` arrow helpers, short comments explaining *why*. +- **No frameworks.** Keep runtime dependencies to a minimum: in the server, only the database clients. +- **Keep logic testable:** pure logic belongs in `packages/engine`, `packages/progress` or `apps/server/src/{rooms,tournament,accounts}.js`, with unit tests. The game modules stay the UI. +- **Keep the tests and lint green.** Add tests for new rules, and run `npm test && npm run lint` before finishing. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c3e6377 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1.10 +# The Blockout server with every app built into it. Three stages: +# build everything installed, then npm run build (apps/web/dist) +# deps only the server's production dependencies (mongodb, redis) and +# the workspace links to @blockout/engine and @blockout/progress +# runtime those, the server, and the built apps; no compilers, no npm cache +ARG NODE=node:26.12.0-trixie-slim@sha256:0d1e33586362bb9e608c4e751e7a9e7edb55c9ac11461ed5666af696cc461dd4 + +FROM ${NODE} AS build +WORKDIR /src +# package files first, so the install layer is reused until dependencies change +COPY package.json package-lock.json ./ +COPY apps/ apps/ +COPY packages/ packages/ +RUN find apps packages -mindepth 2 -maxdepth 2 ! -name package.json -exec rm -rf {} + \ + && npm ci --no-audit --no-fund +COPY . . +RUN npm run build + +FROM ${NODE} AS deps +WORKDIR /app +COPY --from=build /src/package.json /src/package-lock.json ./ +# (every workspace's package.json: npm ci checks them against the lock file) +COPY --from=build /src/apps/ /tmp/apps/ +COPY --from=build /src/packages/ /tmp/packages/ +RUN cd /tmp && find apps packages -name package.json -not -path '*/node_modules/*' -exec install -D {} /app/{} \; \ + && cd /app && npm ci --omit=dev --workspace @blockout/server --no-audit --no-fund \ + && rm -rf /tmp/apps /tmp/packages /root/.npm + +FROM ${NODE} AS runtime +ARG VERSION=dev +ARG REVISION=unknown +LABEL org.opencontainers.image.title="Blockout" \ + org.opencontainers.image.description="Multiplication as area, for 3rd graders: the games, live classes and accounts" \ + org.opencontainers.image.source="https://github.com/DeepSpaceCartel/blockout" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.licenses="UNLICENSED" +ENV NODE_ENV=production PORT=8080 +WORKDIR /app +COPY --from=deps --chown=root:root /app/ ./ +COPY --from=build --chown=root:root /src/apps/server/src/ apps/server/src/ +COPY --from=build --chown=root:root /src/packages/engine/src/ packages/engine/src/ +COPY --from=build --chown=root:root /src/packages/progress/src/ packages/progress/src/ +# the built pages (apps/web/dist), served by the server +COPY --from=build --chown=root:root /src/apps/web/dist/ apps/web/dist/ +# The server never runs npm: without it (and npx, corepack), the image has +# less to patch and nothing to install packages with +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack +# Owned by root, run as node: the server can't change its own code +USER node +EXPOSE 8080 +CMD ["node", "apps/server/src/index.js"] diff --git a/README.md b/README.md index fb85542..af5bc21 100644 --- a/README.md +++ b/README.md @@ -1 +1,54 @@ # Blockout + +A multiplication game for 3rd graders, based on "Blockout" from [Math for Love](https://mathforlove.com/). Roll two dice, draw that rectangle on the grid, say how many squares it is, and claim the most of the board. It runs in the browser, mostly on classroom Chromebooks. + +It has: +- **Single player** against the CPU, **Practice**, and **Home** multiplayer on one screen. +- **Classroom:** whole class on the same rolls, pairs, and tournaments. +- **Optional accounts:** teachers see class stats and set homework; parents follow their child. Guests can always play. +- **More games** for other operations: addition, subtraction, division and fractions. + +## Getting started + +```sh +npm install +npm start # build everything and serve it on http://localhost:8080 +npm run dev # Vite dev server (http://localhost:5173) + the API server with reload +npm test # unit tests for every app and package +npm run lint # ESLint +``` + +- **Node:** needs Node 26 or newer. The server uses the built-in `node:sqlite`. +- **Sharing through a tunnel:** run `cloudflared tunnel --url http://localhost:8080`, then `npm start -- --public-url https://<name>.trycloudflare.com` so the class join links and QR codes use the tunnel's address. + +## Layout + +A [Turborepo](https://turbo.build/) of plain JavaScript (ES modules, no framework), built with [Vite](https://vite.dev/). + +| Path | What | +|---|---| +| `apps/web` | Every web page: the main game at `/`; the other games, More games, Snake, Demos and Admin at `/<name>/` | +| `apps/mobile` | Blockout for phones and tablets (React Native with Expo) | +| `apps/server` | Serves the built games; runs classrooms (live updates over SSE, with long polling as a fallback) and accounts (sqlite) | +| `packages/engine` | Board, dice, placement and CPU; the other operations' rules | +| `packages/progress` | Wallet, shop (skill-tree unlocks), achievements, fact stats, bonuses, homework goals, Wardrobe | +| `packages/ui` | Shared look: colour tokens, CSS, icon sprite, a Vite plugin for every page | +| `packages/game-kit` | The shared page engine for the other games | +| `packages/classroom` | Browser client for the classroom server | +| `packages/auth` | Browser sign-in (dev sign-in now; Keycloak with PKCE later) and progress sync | + +## Documentation + +The docs site is in `docs/` ([MkDocs Material](https://squidfunk.github.io/mkdocs-material/)); build it with `mkdocs serve` (see [Installing](docs/project/installing.md#the-docs-site)). Good places to start: +- [Welcome](docs/home/index.md) and [Quickstart](docs/home/quickstart.md) +- the guides for [teachers](docs/guides/teachers.md) and [parents](docs/guides/parents.md) +- [Architecture](docs/concepts/architecture.md), [Privacy](docs/privacy.md) and the [decision log](docs/decisions/index.md) +- [CHANGELOG.md](CHANGELOG.md) + +`CLAUDE.md` has the conventions for working on the code. + +## Accounts and sign-in + +- **For now, dev sign-in:** pick a name and a role (student, teacher or parent). +- **Keycloak:** set `BLOCKOUT_AUTH=keycloak`, `KEYCLOAK_ISSUER` and `KEYCLOAK_CLIENT_ID` (the dev container sets the last two, for the cluster's shared Keycloak). Google sign-in and registration; a verified school address (the first school is Cadence Park, `@iusd.org`) puts an account in that school. +- **Stored:** first name, last initial, role and email domain. See [Privacy](docs/privacy.md). diff --git a/WIP.md b/WIP.md new file mode 100644 index 0000000..761ca55 --- /dev/null +++ b/WIP.md @@ -0,0 +1,645 @@ +# Work in progress + +Where things stand on the `v0.3.0` branch, for picking the work up again. No secrets, tokens, passwords, IDs or addresses are recorded here: they live in the cluster's Secrets or in 1Password. + +The full production-readiness plan (approved 2026-10-15) is at the end of this file. + +## Where it stopped (2026-10-23) + +- **The workspace cluster was rebuilt (2026-10-22).** Blockout, its Keycloak and their data are gone; nothing is deployed. The deployment notes below ("How it's deployed now") describe the old cluster. `deploy/blockout/secrets.yaml` is gone too. +- **Everything on deepspacecartel.com is dev.** Production gets its own cluster and Keycloak later. +- **One web app (done, ADR-0015):** `apps/web`, `apps/server`, `apps/mobile`. `npm run dev` starts one Vite server plus the server. +- **A shared Keycloak (written, not applied, ADR-0016):** + - In rts-terraform, branch `shared-keycloak`: `platform/keycloak.tf` (Keycloak and its Postgres at `id.deepspacecartel.com`) and `platform/keycloak-realm.tf` (realm `dev`: Google, self-registration, the `workspace-registrar` client). See its `docs/keycloak.md`. + - In Blockout: + - the dev container's `keycloak-clients` Feature creates Blockout's clients (`.devcontainer/keycloak-clients.json`) and adds this workspace's `[sign-in]` port URLs on every start + - admins are the `blockout` client's `admin` role, not a realm role + - an unverified email gets no school + - `values-workspace.yaml` uses the shared Keycloak +- **To finish it:** + 1. Add `https://id.deepspacecartel.com/realms/dev/broker/google/endpoint` to the Google OAuth client's redirect URIs. + 2. In 1Password, create the item "Keycloak" (vault "Deep Space Cartel") with `admin_password`, `postgres_password` and `google_client_secret`. + 3. Merge the rts-terraform branch, then `op run --env-file=../../../../.env.1password -- terraform plan` and `apply` in `environments/dev/kubernetes/platform`. This needs Terraform: this container has none, but a checksum-verified 1.17.0 can go in `~/.local/bin`. `op` isn't signed in here yet. + 4. `keycloak-clients sync`, then `BLOCKOUT_AUTH=keycloak npm start` and sign in on the 8080 Coder URL. Grant yourself `blockout` › `admin` in the admin console. +- **Then: replace `keycloak-clients sync` with Terraform.** The plan is one Terraform config per project (e.g. `deploy/keycloak/`): + - its state is shared by every workspace, in a Kubernetes Secret (`backend "kubernetes"`, namespace `keycloak`, with a Lease lock so workspaces take turns) + - it declares the project's clients and roles + - it computes the redirect URIs from every workspace that exists (`data "kubernetes_resources"` on the `workspaces` Deployments' `com.coder.*` labels, times the project's ports) + - a small Feature runs `terraform apply` on every start + + Each apply rebuilds the whole list, so deleted workspaces drop out and there's no merge code to maintain. The costs: + - about 15–30 s more at start (`init` each time; cache providers in the persisted home) + - Terraform in every project's dev container + - the ports listed in HCL, since Terraform can't read `devcontainer.json`'s comments +- **After that, toward a first production release:** a release (0.12.0), deploying Blockout again, backups (object storage and its keys), and a classroom test on the public address. Postponed: build tooling and previews, SMTP, Alertmanager, and the production cluster. + +## Status by milestone + +| | Milestone | State | +|---|---|---| +| M0–M3 | Housekeeping, server hardening, MongoDB and Valkey, roles and invites | Done | +| M4 | Container image, Helm chart, CI image scan, release workflow | Done. Releases publish to GHCR (`blockout` is internal to the org, with Actions write access for this repo) | +| M5 | First public deployment in the workspace cluster | **Done (2026-10-17):** 0.5.0 at `https://blockout.deepspacecartel.com`, Keycloak at `https://id.deepspacecartel.com`, Google sign-in tested by the owner | +| M6 | Metrics, alert rules, backups, restore check, runbooks | Done in the chart; **backups are off** until there's object storage | +| M7 | Retention, data export, privacy page, accessibility | Done | +| M8 | Production cluster: operators, GitOps, cutover | Load test done. The rest waits for the production cluster | + +## How it's deployed now (0.5.0) + +- **One chart, `deploy/blockout`, one release `blockout` in namespace `blockout`** ([ADR-0009](docs/decisions/0009-one-chart.md)): the servers, MongoDB, Valkey, Keycloak (`keycloakx` 7.3.2 as a dependency) with its Postgres, Keycloak's HTTPRoute, the realm Job (post-install/upgrade hook), backup CronJobs and the monthly restore check. Nothing is created by hand; `deploy/data` and `deploy/keycloak` are gone. +- **Passwords** are made by the chart once and kept (`lookup`, `helm.sh/resource-policy: keep`). **Secrets only a person has** come from the environment (`op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh <version>`) or a gitignored `secrets.yaml`. +- **The workspace cluster currently installs with `deploy/blockout/secrets.yaml`** (the owner's file: the Google client secret and a GHCR token). The 1Password items named in `.env.1password` don't exist yet. +- **Development:** release `blockout-dev` (namespace `blockout-dev`, `values-dev.yaml`): only MongoDB and Valkey; `npm run services` forwards them. Local servers sign in with the workspace Keycloak (`localhost:8080` and `:5173` are allowed redirects). +- **rts-terraform PR #43** (merged, applied): Gateway listeners `blockout-deepspacecartel-com` and `id-deepspacecartel-com`, the shared certificate's SANs, and the DNS records. + +## The phone app: porting the rest (started 2026-10-17) + +The owner chose: phases 1–6 and 8 (not the other games), and **extract shared rules into pure packages, then both the web game and the app use them**. One phase at a time, each step tested in the app's web build (390×844 and 375×667) and on the owner's iPhone through Expo Go. + +1. **Progress: done (2026-10-17).** Shop, achievements and stickers (with sticker drops), stats (the phone's own history), the daily check-in and settings, each shared through `@blockout/progress/{shop,views,stats,settings}` and used by both clients. The app's Settings list only what its game honours; real dice, tap-to-place and the answer timer come in phase 2. +2. **The rest of the game: done (2026-10-17).** Every difficulty (Master's weighting and Legend's board rule in `@blockout/progress/setup`), Expert levels and timers, 3–4 players, boards to 24, real dice, tap to place and auto placing, every setting. The web game now calls the shared rules on its own state, which is shaped like a match (judging a drawing, awarding points, misses, when an answer is decided, the counting plan, Legend's split, the end of the game, tap-to-place); its turn flow (animations, classroom and pairs turns driven by the server) stays in `play.js`. +3. **Practice: done (2026-10-17).** `@blockout/progress/practice` (setup rules and words, the round question by question); the web game's practice and the app's Practice screen both use it. +4. **Wardrobe: done (2026-10-17).** `@blockout/progress/wardrobe` (what's worn, the rows of chips, titles, what's new, how each player looks). The app's Wardrobe screen (menu › Looks) and every look in its games: colours, dice and dots, keypads, boards, rectangle patterns, your avatar and title, the CPU's character, roll styles, placing effects, streak flames, and win celebrations with the confetti look (`src/celebration.js` works the paths out ahead, so the native driver plays them). +5. **Student accounts: done (2026-10-17).** `@blockout/progress/homework` (how the list reads, the menu choice that plays one), used by the web game and the app. The app's menu row: name, 📚 Homework (badge) and Account. Account: classes and joining by code, a grown-up code (QR of the web link, Share…), "I have a code", deleting a teacher's or grown-up's account (a student's is removed by their grown-up, as on the web), sign out. Links: the web's `#code=`/`#roster=` and the app's `blockout://code/…`, `…/--/roster/…` (`src/links.js`). In Expo Go only typed codes and the app's own links work; a web link opens the website. +6. **Live classes as a student: done (2026-10-17).** `@blockout/classroom/core` (`createClient({ base, token, storage, EventSource })`; `client.js` is the browser's) and `@blockout/progress/classroom` (the waiting words, catching a pairs board up, the results, the rewards and history), used by the web game and the app. The app: 🏫 Class beside Play, joining by code or `#class=` link, the whole class's rolls, pairs and tournaments (your partner's moves as they make them), results until the next game, rejoining after the app was closed, the tournament banner on the menu. +8. **Grown-ups' screens: done (2026-10-17).** `@blockout/progress/grownups` (how activity, homework, codes and planned tournaments read; the homework form's choices and goal; whose homework comes off) and `@blockout/progress/hosting` (the host's choices and settings, its buttons, roster, pairs cards, podium and report), used by the web game, the admin page and the app. The app: 🏫 My classes (classes, join code and QR, roster, class homework, a student's page, grown-ups, planned tournaments, teacher codes), hosting a live class (setup, lobby with settings and pairs, the game, the end; the odd one out in pairs plays the CPU or the teacher's other device), 👪 My kids (link a child, their page, delete their account) and 🛠 Admin (accounts, teacher codes, schools). + +The phone app has everything the web game's multiplication has, except the other games (the owner's choice). + +Working notes: the workspace container is limited to **4 GiB** (`free` shows the node's 7.7 GB). On 2026-10-17 it was OOM-killed: the editor's servers had crept to ~3 GiB, then a full parallel `turbo` test and build (Rolldown workers) filled the rest. Run Turborepo with `--concurrency=2`, builds one at a time, Playwright only when nothing else heavy runs. `devcontainer.json` gives every Node process at most 1.5 GiB of heap (`NODE_OPTIONS`, after a rebuild). Raising the limit to 6 GiB (request ~3 GiB) is a change in rts-terraform's Coder template. The Expo dev server: `npm run tunnel --workspace @blockout/mobile` (needs `npx expo login`). + +## What's open + +1. **Backups:** object storage and its keys (`RCLONE_CONFIG_*`, see `docs/guides/operate.md`). Until then, losing the MongoDB volume loses every account. +2. **Secrets in 1Password:** create the items `.env.1password` points to, then install with `op run` and retire `secrets.yaml`. Use a dedicated `read:packages` token for the cluster. +3. **Later, External Secrets Operator** with 1Password in rts-terraform; the chart then needs `existingSecret` for the Google secret and the pull secret (today the Google secret is a value only, because the keycloakx subchart mounts it under a fixed name). +4. **Mail for Keycloak:** an SMTP relay on port 587 (25 and 465 are blocked from the cluster), as `keycloak.smtp` or `BLOCKOUT_SMTP_*`. Without it, address checks and password resets stay off. +5. **Classroom smoke test on the public address** with two students and a server killed mid-round. Done so far: health stayed 200 through deleting server pods and a rolling restart. +6. **Production cluster (M8):** provider and region, operators, Alertmanager (the workspace cluster has none: alerts notify nobody). + +## Things to know + +- **MongoDB 8 and the Linux kernel:** every MongoDB 8 refuses to start on kernels 6.19 to 7.0.13 (SERVER-121912). The Talos nodes run 6.18: check the kernel before any Talos upgrade. CI's `check` job is pinned to `ubuntu-24.04` for the same reason (Ubuntu 26 runners have an affected kernel; `ubuntu-latest` moves to 26 from 2026-10-19). +- **Keycloak reads its address and the Google secret at start:** after changing `keycloak.url` or the secret, `kubectl -n blockout rollout restart statefulset/blockout-keycloakx`. +- **Helm 4** warns that `--atomic` is deprecated (`--rollback-on-failure`); `install.sh` keeps `--atomic` so Helm 3 still works. +- **`helm template` and Argo CD** can't look up the kept passwords and would make new ones: a GitOps setup must provide those Secrets. +- **The uncommitted realm change** from before the plan is gone: the realm keeps `registrationAllowed: false`. Google sign-in is the way in. +- **trivy-action** is pinned by commit; check its release against Aqua's March 2026 supply-chain advisory before trusting or bumping it. +- **`npm audit`** reports one high-severity advisory in `source-map-js`, a build-time dependency of Vite (not in the image or the pages); Dependabot should bring the fix. +- **Docs screenshots:** `other-game-win` still shows the old, brighter orange (it needs a game-kit game played to the end). +- **Still to do from before the plan:** the landscape layout for game-kit's menus. + + +# Appendix: the production-readiness plan + +## The plan (as approved, with later decisions noted above) + +### Context + +Blockout (v0.3.0 branch) runs well as a single dev server behind a Cloudflare quick tunnel. It is not ready to be a public service for schools: + +- **A crash bug.** One malformed URL (`GET /%E0`) kills the process. `decodeURIComponent` runs outside the handler's `try`, at `apps/server/src/index.js:83` and `:407`. +- **Unsafe sign-in defaults.** Dev sign-in is the default and lets anyone pick any role. Keycloak only works on localhost. +- **No role management.** Roles come from the token on every request. There is no admin, and parent codes are permanent and reusable. +- **One process only.** Live classes live in process memory and sqlite is a local file. A restart loses every class, and the server can't run on more than one replica. +- **Nothing to deploy with.** There is no container image, chart, release pipeline, backups, rate limiting, CSP, graceful shutdown, structured logs, retention policy or deletion path. + +**Goal:** Blockout runs as 2+ stateless replicas in Kubernetes: first in the workspace cluster, later in a separate production cluster. It uses: +- MongoDB for accounts and Redis for live rooms and pub/sub +- Keycloak on a public HTTPS hostname +- a role model where anyone can sign in as a student, admins and teachers promote people to teacher or parent with one-time invites (admins can also change roles directly), and students hand out one-time parent invites for themselves + +**Decisions made with the user:** +- Roles live in Blockout's DB. Only `admin` comes from a Keycloak realm role. +- Parent codes become one-time, expiring invites. Admins and teachers can make teacher and parent invites; students can make parent invites for themselves. +- Packaging is a GHCR image plus a Helm chart. +- Live rooms are persisted and the server scales horizontally. +- MongoDB replaces sqlite for accounts. Redis holds room state and pub/sub. + +This breaks two current rules: "server uses Node built-ins only" and ADR-0003 (sqlite). Both get superseding ADRs, and CLAUDE.md gets updated. + +### Milestones + +Each milestone ends with `npm test && npm run lint` green, `mkdocs build --strict`, a CHANGELOG entry and Conventional Commits. + +- **0. Housekeeping:** commit the WIP. +- **1. Hardening:** fix the server and make it safe on the public internet. No architecture change. +- **2. MongoDB and Redis:** async store, rooms in Redis, horizontal scaling. +- **3. Roles and invites:** student by default, admin, teacher and parent invites, admin screen, deletion. +- **4. Packaging and release:** container image, CI publish, Helm chart. +- **5. Workspace-cluster deployment:** MongoDB, Redis and Blockout in the cluster; public Keycloak and Blockout over HTTPS. +- **6. Operations:** backups, logs and metrics, alerts, runbooks. +- **7. Compliance:** retention, export, privacy docs. +- **8. Production cluster:** operators, HA, cutover. + +Order rationale: +- M1 fixes live bugs at once, at little cost. +- M2 moves storage to MongoDB and Redis first, so M3's invite, admin and deletion store methods are written once, against the async store and its contract suite. + +Versioning: +- Release 0.3.0 after M1. It's the first tagged release; the image pipeline arrives in M4. +- 0.4.0 after M2 and M3. +- 0.5.0 after M4 and M5 (first public deployment). +- 1.0.0 after M8. + +--- + +### M0. Housekeeping + +- **Commit the WIP.md "Done, not committed" work** as separate commits: + - `feat(ui): two-column landscape menu` + - `build(devcontainer): 1Password CLI feature` + - `feat(auth): sign out of Keycloak too` + - `build(keycloak): …` +- **Hold back the uncommitted realm change** (`registrationAllowed: true`, `resetPasswordAllowed` with no SMTP). It's redone properly in M5. +- **Commit `.claude/settings.json`** (`chore: project Claude Code settings`). Check it holds no secrets or machine-specific paths first. + +--- + +### M1. Server hardening (no architecture change) + +All in `apps/server/src/index.js` unless noted. Tests go in `apps/server/test/server.test.js`. + +1. **Crash fixes** + - Wrap the decode in `serveStatic` (:83) and `matchAccountRoute` (:407) so a malformed URL returns 400 `bad-request`. + - Move `route(req)` inside the `try`. + - `readJson`: + - listen for `'aborted'` and `'error'` + - add a 10 s body timeout, answering 408 + - Add `process.on('unhandledRejection'|'uncaughtException')`. They log and exit non-zero (Kubernetes restarts the pod) instead of crashing silently. + - Tests: `GET /%E0`, `GET /api/classes/%E0`, and an aborted body all leave the server answering `/api/health`. +2. **Production guard** + - New `apps/server/src/config.js` centralizes every env var that `index.js`, `auth.js`, `store.js` and `moderation.js` read today. + - With `NODE_ENV=production`, it refuses to start unless all of these hold: + - `BLOCKOUT_AUTH=keycloak` + - `PUBLIC_URL` starts with `https://` + - (after M2) Mongo and Redis URLs are set + - It fails fast with a clear message. Unit-tested as a pure `loadConfig(env, argv)`. +3. **Security headers** + - Apply `SECURITY_HEADERS` to the 301 (:86) and to the event stream (:239). + - Add `Permissions-Policy: camera=(), microphone=(), geolocation=()`. + - Add `Strict-Transport-Security: max-age=31536000` when `PUBLIC_URL` is https. +4. **CSP:** `default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; connect-src 'self' <KEYCLOAK_ISSUER origin>; frame-ancestors 'none'; base-uri 'none'; form-action 'self' <issuer origin>`. To get there: + - Move the inline theme script out of `packages/ui/src/vite-plugin.js` into an emitted `theme.js`, loaded with a blocking `<script src>` in `<head>` so the theme still applies before first paint. + - Replace `style="display: none"` on the sprite (`packages/ui/src/icons.svg:4`) with a `hidden` attribute or class. + - Replace the `style=` strings in innerHTML at `apps/demos/src/games/rhythm.js:24` and `packages/game-kit/src/index.js:1021` with `el.style` assignments. + - Ship first as `Content-Security-Policy-Report-Only` for one Playwright pass over every app, collecting `securitypolicyviolation` events. Then enforce. +5. **Information leaks** + - `lanUrls()` goes out in `/api/health` and room creation only when not in production. Production returns `PUBLIC_URL` only. + - Room probing (`/info`, `/events`, `/poll`) gives the same 404 for unknown and closed rooms. +6. **Codes:** `rooms.js:39` `newCode` uses `crypto.randomInt` (rng still injectable for tests). `accounts.js:24` code generation uses rejection sampling, removing the modulo bias. +7. **Rate limiting** + - New pure `apps/server/src/ratelimit.js`, a token bucket keyed by `(bucket, clientIp)`, with an in-memory backend now and Redis in M2. + - `clientIp(req)` reads `X-Forwarded-For` only when `TRUST_PROXY=<hops>` is set. + - Buckets: + - room join and probe: 20/min per IP + - class join and parent link: 10/min per user and 30/min per IP + - dev sign-in: 10/min + - everything else: 600/min per IP + - Answers 429 problem+json with `Retry-After`. Fully unit-tested. +8. **Event-stream limits:** at most 200 streams per room and 20 per IP. +9. **Health and shutdown** + - Three endpoints, one per Kubernetes probe (no auth, `Cache-Control: no-store`, tiny JSON bodies, no LAN URLs, excluded from request logs and rate limits): + - `GET /health/startup`: 503 until startup is done (config loaded, store opened and migrated, and after M2 Redis connected and Lua scripts loaded), then 200. Covers slow migrations without a long liveness delay. + - `GET /health/live`: 200 while the event loop answers; 503 only if the process is wedged (e.g. event-loop lag above 5 s measured by a timer). Never checks MongoDB or Redis, so an outage there doesn't restart every pod. + - `GET /health/ready`: 200 when it can serve: store reachable (after M2: Mongo `ping` and Redis `PING`, cached 2 s); 503 while draining or when a dependency fails. + - Keep `GET /api/health` for existing clients, answering like `/health/ready` without the LAN URLs in production. + - `SIGTERM`/`SIGINT`: + 1. mark draining + 2. stop scheduled-opener timers + 3. send `event: reconnect` to every stream and end it + 4. answer held polls + 5. `server.close()` + 6. `store.close()` + 7. exit; force exit after 20 s + - Clients already reconnect. Until M2 the room is gone, so they see "lost". Document deploying outside class hours until then. +10. **Structured logging** + - New `apps/server/src/log.js`: JSON lines with OTel field names, following the structured-logging skill: + - `timestamp`, `severity_text`, `event.name`, `http.request.method`, `http.route`, `http.response.status_code`, `duration_ms` + - `service.name=blockout`, `service.version` + - One request log per request (`http.route`, never raw paths with codes). + - Errors carry `exception.type` and `exception.stacktrace`. + - Redact: never log tokens, codes, keys or names. + - Audit events for account actions: `class.created`, `student.removed`, `homework.created/deleted`, `parent.linked/unlinked`, `schedule.created/deleted`. Later `role.changed`, `invite.created/redeemed`, `user.deleted`. Each carries actor id, target id and role. +11. **Fewer DB writes:** `upsertUser` writes `last_seen` at most once a minute per user (cache by sub with a short TTL). Today every request writes. +12. **Token refresh** (`packages/auth/src/client.js`) + - Use the stored refresh token: refresh when expiry is less than 60 s away, and once on a 401 before signing out. + - Then `realm-blockout.json`: `accessTokenLifespan` 900 (15 min). The SSO session stays 8–10 h. Dev tokens drop from 30 days to 12 h. +13. **Docs:** update `docs/concepts/security.md` (gaps closed, CSP, rate limits), `docs/reference/CONFIGURATION.md` (`NODE_ENV`, `TRUST_PROXY`, `/health/startup`, `/health/live`, `/health/ready`) and `HTTP-API.md` (429). + +--- + +### M2. MongoDB for accounts, Redis for live rooms (horizontal scaling) + +**ADR-0006** "MongoDB and Redis" supersedes ADR-0003. **ADR-0007** "Live classes are kept in Redis for up to 4 hours" is a privacy change: guest names leave process memory. Update `privacy.md` and CLAUDE.md (runtime dependencies: `qrcode-generator` in the browser; `mongodb` and `redis` on the server). + +Two blockers come first: +- **Room state isn't serializable.** `room.bag` and `m.bag` hold dice closures from `Core.createDice`. +- **The classroom client gives up on transient errors.** It reports `lost` on any non-200, so every rollout would boot classes (`packages/classroom/src/client.js`). + +### Layers + +- **Pure logic, unchanged:** `rooms.js` and `tournament.js`. Changes are limited to dice state and codes. +- **New `apps/server/src/live.js`, the room runtime:** + - `withRoom` and the mutation queue + - owner leases and timers + - local listeners (streams and polls) and the views sent to them + - presence overlay, live moves, drain +- **Storage and messaging behind one interface:** `apps/server/src/roombackend/{memory,redis}.js`. Two runtimes sharing one memory backend act as two replicas in one test process. +- **Accounts store:** `apps/server/src/store/{index,memory,mongo,migrations}.js`. Both implementations are async. `openStore()` picks one from `BLOCKOUT_STORE`, defaulting to `mongo` when `BLOCKOUT_MONGO_URL` is set and `memory` otherwise. Production requires Mongo (M1 guard). + +### Room runtime design + +- **Keys** (hash-tagged so they're cluster-safe): + - `room:{C}`: a hash with `v` (version) and `s` (snapshot JSON) + - `room:{C}:presence`, `room:{C}:live`, `room:{C}:owner` + - `closed:{C}` (30 min TTL) + - `rooms:active` (a ZSET by last touch) + - channel `room:{C}` + - Room keys expire after `ROOM_IDLE_MS` (4 h), refreshed on every write. This replaces the idle sweep. +- **`withRoom(code, fn)`:** + 1. read `v` and `s`, apply the presence overlay + 2. run `fn(room)` (synchronous; anything async such as moderation runs before it) + 3. bump the version and strip the overlay + 4. a **Lua compare-and-set** on `v` writes the snapshot, refreshes the TTL, `ZADD`s `rooms:active`, and `PUBLISH`es `{t:'v', v}` + + It retries up to 8 times with jitter, then answers 503 `busy`. A promise chain per code serializes mutations within one process. Lua is used instead of WATCH/MULTI because WATCH is per connection and node-redis multiplexes requests over one. +- **Owner lease:** `SET room:{C}:owner <replicaId> NX PX 15000`, renewed every 5 s with Lua. + - Only the owner runs timers. + - `reconcile(room)` reuses the existing `scheduleCpu`/`scheduleRound`/`maybeAutoNext`. They're keyed by code and match, and each callback re-checks the lease and a state guard (`round`, `turn`, `roundReady`, `autoNext`) inside `withRoom`, so a duplicate fire is a no-op. + - The owner runs `reconcile` on every update message. Route-level `maybeAutoNext`/`cancelAutoNext` calls go away. + - **Takeover:** every replica sweeps `rooms:active` every 5 s and claims rooms that have no owner. A request for an unowned room also claims it. Timers restart with their full delay, at most about 5 s late. +- **Presence:** `p.connections` and `p.seenAt` are no longer saved. They become an overlay from `room:{C}:presence`: + - each replica sets `player|replica` fields with a 30 s expiry, refreshed every 10 s + - polls set `player|poll` + - so `connected()`, `allDone` and `isHere` stay as they are + - the owner's presence sweep writes `presenceSig` through `withRoom` when it changes, which wakes everyone as today +- **Fan-out:** messages carry only the version. + - A replica subscribes to a room's channel while it has local listeners (the owner always does). + - On a message it waits 60 ms to coalesce, reads the snapshot once (or reuses a cached one with the same version), and pushes `teacherView`/`studentView` with the existing `pushView`/`answerPoll`. + - `openPoll` compares `?v` against `HGET v`. +- **Live moves** (`broadcastMatch`, about 12 a second per pair): no compare-and-set and no version bump. + - `Rooms.setLive` validates the move on the cached snapshot. + - `HSET room:{C}:live`, then `PUBLISH {t:'live', match, ids, live}`. + - Receivers push only to the match's players, with the 80 ms throttle. +- **Close:** one Lua script deletes the room keys, sets `closed:{C}`, removes it from `rooms:active` and publishes `closed`. The `closedRooms` map goes away. +- **Codes:** `newCode` uses `crypto.randomInt`. Creating a room is a create-if-absent in Lua that fails when `room:{C}` or `closed:{C}` exists, retrying with a new code. +- **Scheduled tournaments:** + - A `lease:scheduler` leader runs `accounts.openDue`, now async. + - `store.markOpened` is conditional on `openedAt: null` and returns whether it succeeded. A loser closes its orphan room. + - Because rooms now persist, scheduled room codes stay valid across restarts. That fixes today's dead codes. +- **SIGTERM drain:** + 1. `/health/ready` returns 503, then `server.close()` + 2. clear timers and release leases; publish `owner-gone` so takeover is immediate + 3. each stream gets `retry: <1000–4000 random>` and then `end()`, so EventSource reconnects through the gateway to another pod + 4. answer polls + 5. delete this replica's presence fields, close Redis and Mongo +- **Client** (`packages/classroom/src/client.js`): + - When the stream closes, check `roomInfo(code)` and report `lost` only on a 404; otherwise fall back to polling. + - A poll that gets a non-404 error retries after `RETRY_MS`. + +### Accounts store design + +- **Interface:** every method async. Groups: + - lifecycle: `migrate`, `close`, `exportAll` + - meta, schools + - users, including `signIn(identity)` (inserts as student and never overwrites the role), `setRole`, `touchUser`, `listUsers`, `deleteUser` (full cascade) + - progress (with batched `progressOfMany`/`usersByIds` to avoid N+1 in `classDetail`) + - classes and members + - parent links, homework + - schedules (with conditional `markOpened`) + - invites: `redeemInvite` is an atomic `findOneAndUpdate` on `{codeHash, usedAt: null, expiresAt > now}` +- **Mongo collections and indexes:** + + | Collection | Indexes | + |---|---| + | `users` | `sub` unique; `{role, firstName}`; `schoolId` | + | `schools` | `domain` unique | + | `progress` | `_id` = user id | + | `classes` | `code` unique; `teacherId` | + | `classMembers` | `{classId, userId}` unique; `userId` | + | `parentLinks` | `{parentId, childId}` unique; `childId` | + | `assignments` | `classId`; `studentId`; `createdBy` | + | `schedules` | `{openedAt, startsAt}`; `teacherId` | + | `invites` | `codeHash` unique; TTL on `expiresAt` (+7 d) | + | `meta` | `_id` is the key | + + Ids stay 16-character hex strings in `_id`. `deleteUser` uses a transaction, which is why MongoDB runs as a replica set even with one member. +- **Migrations** (`store/migrations.js`): ordered `{version, up(db)}` steps. A lock on the `meta` `schema` document (`findOneAndUpdate` with `lockedUntil`) ensures one replica migrates while others wait. Migration 1 creates the indexes and the default schools. +- **Auth:** + - `Auth.identity` stays sync, with a schools cache (60 s) passed in. + - `Auth.fromEnv` becomes async. + - `index.js` uses top-level `await openStore(); await store.migrate();`. +- **Migrating existing data:** `apps/server/scripts/sqlite-to-mongo.js` is the only `node:sqlite` code left, documented as one-off. It maps snake_case to camelCase, parses the JSON columns, upserts by `_id` (re-runnable), compares counts, and supports `--dry-run`. + +### Dev loop + +- **By default:** `npm run dev`, `npm start` and the Playwright pattern in CLAUDE.md use the memory store and memory room backend, with no services needed. The server logs a warning that data isn't kept. +- **For real services:** the M5 `deploy/data/` install also works in a `blockout-dev` namespace. `npm run services` port-forwards MongoDB (27017) and Redis (6379), like `npm run keycloak`, and you set `BLOCKOUT_MONGO_URL` and `BLOCKOUT_REDIS_URL`. +- **Multi-replica testing:** a scratch script runs two servers on 8135 and 8136 behind a small round-robin proxy. + +### Ordered commits (green at each) + +1. `refactor(engine): serializable dice state` + - `createDice`/`createPairDice` return plain state. + - `Core.dice(state, rng)` wraps it with `roll`/`rollPair`. + - `rooms.js` wraps at the use sites. + - Test: JSON round-trip a room mid-game and keep playing. +2. `refactor(server): crypto room codes, injectable taken predicate`. +3. `refactor(server): async store interface`, still on sqlite. + - `accounts.js`, `auth.js` and `index.js` await it. + - `accounts.test.js` gets awaits. + - No behaviour change. +4. `feat(server): memory store + store contract suite`. + - `test/store.contract.js` exports `runStoreContract(name, open)`. + - The privacy test uses `exportAll()`. +5. `feat(server): mongo store + migration runner`. + - Adds the `mongodb` dependency. + - CI gets `mongo:8` and `redis:8` service containers. + - The Mongo contract test is skipped when `BLOCKOUT_MONGO_URL` is unset. +6. `feat(server): sqlite→mongo script`, then remove sqlite from the runtime. + - ADR-0006. + - Update CLAUDE.md, `CONFIGURATION.md`, and the backup guide stub. +7. `fix(classroom): don't report lost on transient errors`. +8. `refactor(server): room runtime over a memory backend`. `live.js` and `roombackend/memory.js`; the memory backend JSON round-trips every snapshot. +9. `feat(server): redis room backend`. + - The Lua scripts. + - The `redis` dependency. + - A backend contract suite run on both backends. + - Rate-limit buckets move to Redis. +10. `feat(server): leases, takeover sweep, scheduler leader, SIGTERM drain`. +11. `docs`: ADR-0007, privacy, architecture diagram, CHANGELOG. + +(M3's invites, `listUsers`, `setRole` and `deleteUser` are added to this store interface and its contract suite in M3.) + +### Tests for the risks + +All use an injected clock and timers in `live.js`, so nothing waits on real time. + +- **Lost updates:** 30 concurrent answers through two runtimes on one backend are all counted, and the version rises by 30. Repeat on Redis. +- **Owner death:** runtime A owns the room and stops without releasing. After the lease expires, B claims it, and the CPU turn, auto-next and tournament round still happen. +- **Double fire:** both owners' timer callbacks run; only one advance happens. +- **Cross-replica views:** the teacher acts on A while a student streams or polls on B, and B gets the new view. +- **Live moves:** `v` doesn't change, and only the match's players receive them. +- **Crashed replica's presence:** it expires, and `allDone` stops waiting for that player. +- **Code collision:** a seeded rng forces a collision with a live or closed room; creation retries. +- **Schedules:** two schedulers produce exactly one room. +- **Concurrent migrations:** `migrate()` twice in parallel is safe. +- **Deletion:** after `deleteUser`, `exportAll()` holds no reference to that user. +- **Drain:** streams end with `retry`, the other runtime takes over, and the client keeps playing. +- **Redis down:** rooms answer 503, and clients retry instead of reporting "lost". + +--- + +### M3. Roles, invites and admin + +**ADR-0008** "Roles live in Blockout's database". It supersedes the roles part of ADR-0002. + +### Rules (pure, in `apps/server/src/accounts.js`, unit-tested) + +- **Default role and admin** + - Every new account is a `student`. + - `identity()` (`auth.js:143-158`) no longer derives a role. It keeps `sub`, name, initial, domain and school, and adds `isAdmin = claims roles include 'admin'`. + - The domain fallback (school domain → student, otherwise parent) and the `ROLES` precedence go away. +- **Role comes from the DB:** `store.upsertUser` (`store.js:144-159`) stops writing `role`. It sets `role` only on insert, to `'student'`, or to the dev picker's role with dev sign-in. +- **Migration:** existing rows keep their current role. There's no data change; only the overwrite stops. +- **Admin:** `isAdmin` is computed per request from the token and never stored. Dev sign-in gets an "admin" checkbox (dev only). `need(user, 'admin')` checks `user.isAdmin`. +- **Invites** (the `invites` collection from M2) + - Fields: `{ id, kind: 'teacher'|'parent', code_hash (sha256), created_by, child_id?, school_id?, expires_at, used_by?, used_at?, created_at }`. + - Codes are 8 characters from `CODE_LETTERS`, shown once and stored hashed. They are single-use. + - Expiry: teacher invites last 7 days, parent invites 14 days. + - Creating an invite for the same child again revokes that creator's older unused invites. +- **Who creates invites** + - **Admins** create teacher invites (optionally tied to a school) and parent invites for any student. + - **Teachers** create: + - teacher invites, to bring a colleague in; tied to the teacher's own school, at most 10 unused at a time and 20 a day + - parent invites for students in their classes (`canSee`) + - **Students** create parent invites for themselves (it replaces "For a grown-up"). + - Teachers promote through invites, not by changing someone's role directly. A teacher can't look up arbitrary accounts (ADR-0004), and the person redeeming proves they want the role. Direct role changes stay admin-only. + - Every redemption records `invitedBy` on the user, so an admin can see who brought each teacher in and revoke a bad chain (demote, delete the invites). +- **Redeeming** (`POST /api/invites/redeem {code}`) + - Teacher invite: the account must be a student with no class memberships and no parent links. Otherwise 409 with a friendly message ("This account already plays in a class. Sign in with a different account to teach."). The role becomes `teacher`. + - Parent invite: + - a student with no classes or links → becomes `parent` and is linked to the child + - already a parent → just linked + - a teacher → 409 + - Expired, used or unknown codes all get the same 404, and each attempt counts toward the M1 rate limit. +- **Admin role changes:** an admin can set an account to student, teacher or parent. Demoting a teacher who still has classes or schedules → 409 ("Move or delete their classes first"). +- **Retiring parent codes:** remove `users.parent_code` and `ensureParentCode`. The migration nulls them. `POST /api/children {code}` becomes an alias of redeem for a parent invite, so the client changes stay small. +- **Deletion** + - `DELETE /api/admin/users/:id` removes the user with everything they own: progress, memberships, links, their assignments, schedules and invites. + - `DELETE /api/me` is for teacher and parent accounts. Teachers must have no classes, which mirrors the demotion rule. + - A linked parent can delete their child's account, with a confirm dialog. + +### Routes (`index.js` accountRoutes, documented in `HTTP-API.md`) + +| Route | Who | +|---|---| +| `POST /api/invites/redeem` | signed in | +| `POST /api/me/parent-invites` | student | +| `POST /api/students/:id/parent-invites` | teacher who can see the student, or admin | +| `GET/POST /api/me/teacher-invites`, `DELETE /api/me/teacher-invites/:id` | teacher (own invites, with limits) | +| `GET /api/students/:id/parents`, `DELETE /api/students/:id/parents/:parent` | teacher of the child (who is linked; unlink a wrong parent) | +| `GET/POST /api/admin/invites`, `DELETE /api/admin/invites/:id` | admin | +| `GET /api/admin/users?q=&role=&page=` | admin. Returns first name, initial, role, domain, created and last seen only | +| `PUT /api/admin/users/:id/role` | admin | +| `DELETE /api/admin/users/:id` | admin | +| `GET/POST /api/admin/schools` | admin (uses the existing `store.addSchool`) | +| `DELETE /api/me`, `DELETE /api/students/:id` | as above | + +Remove `GET /api/me/parent-code`. + +### UI + +- **`apps/multiplication/src/accounts.js`** + - Student account page: "For a grown-up" gets a **Make a grown-up code** button. It shows the code, its expiry, and a QR/link `/#invite=CODE`. + - Teacher roster (:662): the parent-code column becomes a **Parent code** button per student (with a QR) and a "Grown-ups" list with unlink. + - Any signed-in student menu gets **I have a code** (redeem), for a new teacher or parent. + - Teacher "My classes" gets **Invite a teacher**: make a code with a QR/link, see the codes still open, and revoke them. + - `#invite=CODE` links open redeem after sign-in (like `rosterLink` at :887). + - The dev sign-in picker keeps roles and adds admin. + - Wording for 8-year-olds and grown-ups follows CLAUDE.md conventions. +- **New `apps/admin` Vite app** at `/admin/` (a separate bundle, so kids never download it): + - users table with search and role change + - teacher invites (create with an optional school, list, revoke) + - schools + - delete user, with a typed confirmation + - It reuses `@blockout/ui` and `@blockout/auth`. Non-admins see "Admins only". +- **Keycloak** (`deploy/keycloak/realm-blockout.json`) + - Add realm role `admin`. + - Remove the `student`/`teacher`/`parent` realm roles: they're now ignored. + - Keep self-registration (anyone can sign in) only together with M5's SMTP and email verification. + +### Tests + +`accounts.test.js`: +- a new user is a student, and token role claims are ignored +- admin from the realm role +- invite create, redeem, expiry, single-use and revocation +- teacher-made teacher invites: tied to the teacher's school, limits enforced, `invitedBy` recorded +- the 409 cases +- deletion cascades +- the list for an admin returns no extra fields (ADR-0004) + +`server.test.js` covers the routes and the 429 on redeem. A Playwright run covers: a student makes a grown-up code; a second context signs in, redeems it and sees the child under My kids; an admin invites a teacher, that teacher invites a second teacher, and the second one creates a class. + +### Docs + +- New guide `docs/guides/admins.md`. +- Update `teachers.md` and `parents.md` (with screenshots), `HTTP-API.md`, `privacy.md` (invites, admin, deletion) and the role section of `set-up-keycloak.md`. +- ADR-0008. + +--- + +### M4. Container image, CI publish, Helm chart + +- **`Dockerfile`** (repo root, per the container-images skill) + - Build stage: `node:26-trixie-slim@sha256:…`, `npm ci`, `npm run build`. + - Runtime stage: same base by digest, or `gcr.io/distroless/nodejs26-debian13` if available. Copy only: + - `apps/server/src` + - `packages/engine`, `packages/progress` + - `apps/*/dist` + - root `package.json`, `package-lock.json` + - Then `npm ci --omit=dev --workspace @blockout/server` (only the server's dependencies, keeping the workspace symlinks). + - `USER node` (uid 1000), exec-form `CMD ["node","apps/server/src/index.js"]`, `NODE_ENV=production`, `EXPOSE 8080`, OCI labels (`org.opencontainers.image.source/version/revision`). + - Add a `.dockerignore`. + - The image must not contain `data/`, `docs/`, `.devcontainer/` or `node_modules` from the host. +- **`.github/workflows/release.yml`** + - On tag `v*`: build `linux/amd64` (plus `arm64` if the clusters need it) with `docker/build-push-action` (pinned by SHA). + - Push `ghcr.io/<owner>/blockout:<semver>` and `:<sha>` with SBOM and provenance attestations. + - `permissions: contents: read, packages: write, id-token: write, attestations: write`. + - Create a GitHub Release from the CHANGELOG section. +- **`ci.yml`:** add an image build without push on PRs. Add Trivy (pinned) failing on HIGH/CRITICAL with a fix available. +- **Dependabot:** add `docker` (Dockerfile) and `helm` (`deploy/blockout`). Group them. +- **`deploy/blockout/` Helm chart** (per the helm-charts skill) + - Templates: + - `Deployment`: `replicas: 2`, `topologySpreadConstraints`, rolling update `maxUnavailable: 0`, `terminationGracePeriodSeconds: 30` + - `startupProbe` on `/health/startup` (period 2 s, failureThreshold 60), `livenessProbe` on `/health/live`, `readinessProbe` on `/health/ready`, plus a `preStop` sleep of 5 s + - `securityContext`: `runAsNonRoot`, `readOnlyRootFilesystem`, drop ALL, seccomp RuntimeDefault + - `resources` + - `Service` + - `HTTPRoute` (Gateway API, the cluster's shared gateway, per WIP) with long request timeouts for SSE; an optional `Ingress` + - `PodDisruptionBudget` (`minAvailable: 1`) + - optional `HorizontalPodAutoscaler` (CPU) + - `NetworkPolicy`: ingress from the gateway only; egress to Mongo, Redis, the Keycloak issuer, DNS, and OpenAI if enabled + - a ConfigMap for non-secret env, with checksum annotations + - Secrets referenced by name (`blockout-mongo`, `blockout-redis`, `blockout-openai`), never templated. + - `values.schema.json`. + - Per-cluster values: `values-workspace.yaml`, `values-production.yaml`. + - CI: `helm lint` and `helm template | kubeconform`. +- **`deploy/blockout/install.sh`** mirrors `deploy/keycloak/install.sh`: idempotent `helm upgrade --install --atomic --wait`, using the image tag passed in. + +--- + +### M5. Workspace-cluster deployment (first public version) + +- **Data services** (`deploy/data/`, namespace `blockout-data`), workspace cluster: single instances like `deploy/keycloak/postgres.yaml`, with the same pod hardening. + - **MongoDB 8**: a single-member replica set (needed for transactions and change streams), auth enabled, a 10 Gi PVC. + - **Redis 8** or **Valkey 8**: AOF on, `requirepass`, no PVC needed (room state is disposable). + - An install script creates the Secrets (random passwords) and prints nothing secret. +- **Keycloak on a public hostname** (`deploy/keycloak/`) + - `values.yaml`: `KC_HOSTNAME=https://id.<domain>`, `KC_PROXY_HEADERS=xforwarded`, `KC_HTTP_ENABLED=true` behind the gateway, an HTTPRoute and cert. + - `realm-blockout.json` gets rendered per environment by `install.sh` (`envsubst` on `${BLOCKOUT_URL}`) for `redirectUris`, `webOrigins` and post-logout URIs. Keep the localhost entries for dev values only. + - **Self-registration** (the user's "anyone can sign in"): + - `registrationAllowed: true`, `verifyEmail: true`, `resetPasswordAllowed: true` + - SMTP configured from a Secret (provider to choose; see open questions) + - brute-force detection on + - password policy `length(10)` + - Google IdP: add the public broker redirect URI in Google Console. + - Decide whether to set `hostedDomain` (open question). + - Read the Google secret from 1Password with `op read` when `OP_SERVICE_ACCOUNT_TOKEN` is set, falling back to the prompt (WIP item 2). + - Realm import runs only once, so existing realms get these changes through a small idempotent `kcadm.sh` step in `install.sh` (redirect URIs, roles, SMTP). Document it. +- **Blockout:** `deploy/blockout/install.sh` with `values-workspace.yaml`: + - `blockout.<domain>` + - `BLOCKOUT_AUTH=keycloak`, `KEYCLOAK_ISSUER=https://id.<domain>/realms/blockout` + - `PUBLIC_URL`, `TRUST_PROXY=1`, `NODE_ENV=production` +- **DNS and TLS:** gateway listener plus a certificate (cert-manager if present). DNS records for `blockout.` and `id.`. +- **Bootstrap the first admin:** in Keycloak, give your own account the `admin` realm role. Documented in `docs/guides/set-up-keycloak.md` and the new `docs/guides/deploy.md`. +- **Smoke test** (scripted Playwright against the public URL): + - sign in with Google + - redeem a teacher invite + - host a class and have two students join + - **kill one Blockout pod mid-round; the class continues** + - roll the deployment; the class continues + +--- + +### M6. Operations + +- **Backups** + - MongoDB: a CronJob running nightly `mongodump --archive --gzip`, encrypted with `age` (public key in values, private key in 1Password), uploaded to S3-compatible object storage (Hetzner Object Storage or similar). + - Keep 30 dailies and 12 monthlies. + - Same for the Keycloak Postgres (`pg_dump`). + - Images pinned by digest. + - Redis isn't backed up: rooms are disposable. + - A monthly **restore drill** runbook: restore into a scratch namespace and run `mongosh` counts. Rewrite `docs/guides/back-up-the-database.md` around this. +- **Metrics:** `GET /metrics` (Prometheus text, built by hand, cluster-internal only via NetworkPolicy or a separate port): + - requests by route and status, latency histogram + - active rooms, streams and polls + - rate-limit rejections + - Mongo and Redis errors + - A ServiceMonitor if the cluster runs Prometheus. +- **Logs:** JSON logs go to the cluster's log stack. Keep labels low-cardinality (`service`, `severity`). +- **Alerts:** + - readiness failing more than 2 min + - 5xx rate above 1 % + - p95 latency above 1 s + - backup job failed or older than 26 h + - certificate expiring in under 14 days + - An external uptime check on `/health/ready` and Keycloak's `/realms/blockout`. +- **Runbooks** in `docs/guides/operate.md`: deploy and roll back (`helm rollback`), restore, rotate secrets, promote an admin, delete a user on request, what to do when Mongo or Redis is down. + +--- + +### M7. Compliance (FERPA, COPPA, SOPIPA, per `docs/privacy.md`) + +- **Retention** + - A daily job (the scheduler leader from M2) deletes accounts not seen for `BLOCKOUT_RETENTION_DAYS` (default 395, about 13 months). It uses the M3 cascade. + - Unused and expired invites go after 30 days. + - Rooms in Redis go after 4 h (TTL). +- **Export:** `GET /api/me/export` and `GET /api/students/:id/export` (linked parent) return a JSON download of everything stored. +- **`privacy.md` rewrite** + - fix the stale paths + - MongoDB, Redis and Keycloak (where they run, who operates them) + - retention periods + - deletion and export, and how a school requests them + - the OpenAI check only for guest names + - The `hostedDomain` and self-registration decisions. +- **Before a school goes live, outside the code:** a school data-privacy agreement (many districts use the Student Data Privacy Consortium template), a named contact for data requests, and a short legal review of `privacy.md`. +- **Accessibility pass** (keyboard, contrast, screen-reader labels on the main game and dialogs), worth doing for school procurement. + +--- + +### M8. Production cluster + +- **Operators instead of single pods** + - MongoDB: MongoDB Controllers for Kubernetes (community) or the Percona Operator. A 3-member replica set across zones, with operator-managed backups to object storage. + - Redis: a 3-node Sentinel setup (Valkey or Redis operator). + - Keycloak: 2 replicas with a shared cache. Postgres via CloudNativePG (or keep the StatefulSet with nightly dumps; decide at this point). +- **Secrets:** 1Password Operator or External Secrets Operator syncing every Secret. No secret is created by hand. +- **GitOps:** Argo CD or Flux pulling `deploy/blockout` with `values-production.yaml`. Image updates by PR. The workspace cluster stays manual (`install.sh`). +- **Load test** (k6 or a Playwright swarm script): 30 classes × 30 students on SSE with 2 replicas. Check p95 latency and Redis CPU. Size requests and limits and the HPA from it. +- **Cutover:** migrate MongoDB from the workspace cluster (`mongodump`/`mongorestore`) during a quiet window, switch DNS, keep the old deployment read-only for a week. +- Tag **1.0.0**. + +--- + +### Open questions (needed before the milestone named) + +- **SMTP provider** for Keycloak email verification and password reset. Needed for M5. +- **Google `hostedDomain`.** Should Google sign-in be limited to school domains (e.g. `iusd.org`), or open to any account? Needed for M5. +- **Domain names** for Blockout and Keycloak. Needed for M5. +- **Object storage for backups.** Needed for M6. +- **The production cluster's provider and region.** Needed for M8. + +### Verification + +- **Every milestone:** + - `npm test && npm run lint` + - `.venv/bin/mkdocs build --strict` + - the Playwright scenarios named in that milestone, run against a fresh build on a private port (CLAUDE.md pattern), collecting `pageerror` and CSP violations +- **M1:** curl `/%E0` and the server stays up; 429 after the limit; `kill -TERM` drains in under 20 s; zero CSP violations across all 11 apps. +- **M2:** the two-replica tests in one process (see M2). Locally, `docker`/`kubectl` Mongo and Redis plus two `node` processes on 8135/8136 behind a tiny round-robin proxy script. A class survives killing either process. +- **M4:** `docker build`, `docker run` with `NODE_ENV=production` and missing config fails fast; Trivy clean; `helm lint`; `kubeconform`. +- **M5:** the scripted public smoke test above, including pod kill and rollout mid-class. +- **M6:** a restore drill into a scratch namespace succeeds, and an alert fires on a forced backup failure. diff --git a/apps/mobile/.gitignore b/apps/mobile/.gitignore new file mode 100644 index 0000000..d914c32 --- /dev/null +++ b/apps/mobile/.gitignore @@ -0,0 +1,41 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ +expo-env.d.ts + +# Native +.kotlin/ +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo + +# generated native folders +/ios +/android diff --git a/apps/mobile/app.json b/apps/mobile/app.json new file mode 100644 index 0000000..ee7a4ec --- /dev/null +++ b/apps/mobile/app.json @@ -0,0 +1,36 @@ +{ + "expo": { + "name": "Blockout", + "slug": "blockout", + "scheme": "blockout", + "version": "0.5.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "automatic", + "ios": { + "supportsTablet": true, + "bundleIdentifier": "com.deepspacecartel.blockout" + }, + "android": { + "package": "com.deepspacecartel.blockout", + "adaptiveIcon": { + "backgroundColor": "#F6F1E7", + "foregroundImage": "./assets/android-icon-foreground.png", + "backgroundImage": "./assets/android-icon-background.png", + "monochromeImage": "./assets/android-icon-monochrome.png" + } + }, + "web": { + "favicon": "./assets/favicon.png" + }, + "plugins": [ + "expo-web-browser", + "expo-secure-store", + "expo-status-bar" + ], + "extra": { + "server": "https://blockout.deepspacecartel.com", + "keycloakClientId": "blockout-mobile" + } + } +} diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png new file mode 100644 index 0000000..5ffefc5 Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png new file mode 100644 index 0000000..3a9e501 Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png new file mode 100644 index 0000000..77484eb Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png new file mode 100644 index 0000000..408bd74 Binary files /dev/null and b/apps/mobile/assets/favicon.png differ diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png new file mode 100644 index 0000000..7165a53 Binary files /dev/null and b/apps/mobile/assets/icon.png differ diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png new file mode 100644 index 0000000..03d6f6b Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ diff --git a/apps/mobile/index.js b/apps/mobile/index.js new file mode 100644 index 0000000..e1fe193 --- /dev/null +++ b/apps/mobile/index.js @@ -0,0 +1,6 @@ +import { registerRootComponent } from 'expo'; + +import App from './src/App'; + +// Registers the app as "main", in Expo Go and in a native build alike +registerRootComponent(App); diff --git a/apps/mobile/package.json b/apps/mobile/package.json new file mode 100644 index 0000000..48209e2 --- /dev/null +++ b/apps/mobile/package.json @@ -0,0 +1,38 @@ +{ + "name": "@blockout/mobile", + "version": "0.11.0", + "private": true, + "description": "Blockout for phones and tablets: React Native with Expo (runs in Expo Go).", + "main": "index.js", + "scripts": { + "start": "expo start", + "tunnel": "expo start --tunnel", + "web": "expo start --web", + "test": "node --test", + "lint": "eslint src test index.js" + }, + "dependencies": { + "@blockout/classroom": "*", + "@blockout/engine": "*", + "@blockout/progress": "*", + "@expo/metro-runtime": "~57.0.17", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "~57.0.30", + "expo-auth-session": "~57.0.16", + "expo-constants": "~57.0.22", + "expo-crypto": "~57.0.4", + "expo-secure-store": "~57.0.4", + "expo-status-bar": "~57.0.1", + "expo-web-browser": "~57.0.3", + "qrcode-generator": "^2.0.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + "react-native-svg": "15.15.4", + "react-native-web": "^0.21.4" + }, + "devDependencies": { + "@expo/ngrok": "^4.1.3" + } +} diff --git a/apps/mobile/src/AccountScreen.js b/apps/mobile/src/AccountScreen.js new file mode 100644 index 0000000..3236d00 --- /dev/null +++ b/apps/mobile/src/AccountScreen.js @@ -0,0 +1,307 @@ +// Your account, as on the web game: who you are, and for a student their +// classes (join one with a code), a code for a grown-up, and "I have a code" +// (a teacher's or a grown-up's one-time code). Teachers and grown-ups can +// delete their account; a child's is removed by their grown-up. +import qrcode from 'qrcode-generator'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, TextInput, View } from 'react-native'; +import Svg, { Path, Rect } from 'react-native-svg'; +import Constants from 'expo-constants'; +import * as Account from './account'; +import { codeLink, spaced } from './links'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +export const ROLE = { + student: { emoji: '🎒', label: 'Student' }, + teacher: { emoji: '🍎', label: 'Teacher' }, + parent: { emoji: '👪', label: 'Parent' }, +}; + +const SERVER = Constants.expoConfig.extra.server; +const shortDay = (t) => new Date(t).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + +// user: the signed-in account; onUser(user) when a code changes it; +// toast(title, text); onSignOut(); onDeleted() +export default function AccountScreen({ user, toast, onUser, onSignOut, onDeleted, onClose }) { + const t = useTheme(); + const role = ROLE[user.role] || ROLE.student; + const grownUp = user.role === 'teacher' || user.role === 'parent'; + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Your account" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page} keyboardShouldPersistTaps="handled"> + <View style={[styles.card, { backgroundColor: t.paper, borderColor: t.line }]}> + <Text testID="account-name" style={[styles.big, { color: t.ink }]}> + {role.emoji} {user.name} + </Text> + <Text style={[styles.sub, { color: t.muted }]}> + {role.label} + {user.isAdmin ? ' · Admin' : ''} + {user.school ? ` · ${user.school.name}` : ''} + </Text> + </View> + {user.role === 'student' ? <StudentClasses toast={toast} /> : null} + {user.role === 'student' ? <GrownUpCode /> : null} + {user.role === 'student' ? <Redeem toast={toast} onUser={onUser} /> : null} + {user.role === 'teacher' ? <Text style={[styles.help, { color: t.muted }]}>Your classes, homework, tournaments and codes for other teachers are under 🏫 My classes in the web game.</Text> : null} + {user.role === 'parent' ? <Text style={[styles.help, { color: t.muted }]}>Follow your children under 👪 My kids in the web game.</Text> : null} + {grownUp ? <DeleteAccount onDeleted={onDeleted} /> : null} + <Button testID="sign-out" kind="secondary" title="Sign out" onPress={onSignOut} /> + </ScrollView> + </View> + ); +} + +function Section({ title, help, children }) { + const t = useTheme(); + return ( + <View style={styles.section}> + <Text style={[styles.heading, { color: t.muted }]}>{title}</Text> + {help ? <Text style={[styles.help, { color: t.muted }]}>{help}</Text> : null} + {children} + </View> + ); +} + +// A code box and its button, with the server's answer under it +function CodeForm({ placeholder, maxLength, button, testID, onSubmit }) { + const t = useTheme(); + const [code, setCode] = useState(''); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const submit = async () => { + if (!code.trim() || busy) return; + setError(null); + setBusy(true); + try { + await onSubmit(code.replace(/\s+/g, '').toUpperCase()); + setCode(''); + } catch (e) { + setError(e.message || 'That didn’t work.'); + } finally { + setBusy(false); + } + }; + return ( + <View style={styles.formWrap}> + <View style={styles.form}> + <TextInput + testID={`${testID}-input`} + value={code} + onChangeText={setCode} + onSubmitEditing={submit} + placeholder={placeholder} + placeholderTextColor={t.muted} + maxLength={maxLength} + autoCapitalize="characters" + autoCorrect={false} + returnKeyType="go" + style={[styles.input, { color: t.ink, backgroundColor: t.surface, borderColor: t.line }]} + /> + <Button testID={`${testID}-go`} title={busy ? '…' : button} onPress={submit} disabled={busy || !code.trim()} style={styles.formButton} /> + </View> + {error ? ( + <Text testID={`${testID}-error`} style={[styles.error, { color: t.bad }]}> + {error} + </Text> + ) : null} + </View> + ); +} + +function StudentClasses({ toast }) { + const t = useTheme(); + const [classes, setClasses] = useState(null); + const [error, setError] = useState(null); + const load = async () => { + try { + setClasses((await Account.api('GET', '/api/me/classes')).classes); + setError(null); + } catch (e) { + setError(e.message); + } + }; + useEffect(() => { + load(); + }, []); + return ( + <Section title="Your classes"> + {error ? <Text style={[styles.help, { color: t.muted }]}>{error}</Text> : !classes ? <ActivityIndicator color={t.accent} /> : null} + {classes && !classes.length ? <Text style={[styles.help, { color: t.muted }]}>Not in a class yet. Type the code your teacher gives you.</Text> : null} + {(classes || []).map((c) => ( + <Text key={c.id || c.name} testID="class-row" style={[styles.item, { color: t.ink, backgroundColor: t.surface, borderColor: t.line }]}> + 🏫 {c.name} + </Text> + ))} + <CodeForm + testID="join" + placeholder="Class code" + maxLength={8} + button="Join" + onSubmit={async (code) => { + const r = await Account.api('POST', '/api/me/classes', { code }); + toast('🏫 Joined!', r.class.name); + await load(); + }} + /> + </Section> + ); +} + +function GrownUpCode() { + const t = useTheme(); + const [invite, setInvite] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const make = async () => { + setError(null); + setBusy(true); + try { + setInvite(await Account.api('POST', '/api/me/parent-invites')); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + return ( + <Section title="For a grown-up" help="Make a code for your mom, dad or another grown-up, so they can see how you’re doing and add homework."> + {invite ? <CodeCard invite={invite} who="Give it to one grown-up." /> : <Button testID="grown-up-code" kind="secondary" title={busy ? 'Making a code…' : 'Make a grown-up code'} onPress={make} disabled={busy} />} + {error ? <Text style={[styles.error, { color: t.bad }]}>{error}</Text> : null} + </Section> + ); +} + +// A one-time code: its QR code (the web link, so any phone's camera opens +// it), the code to type, and Share +export function CodeCard({ invite, who }) { + const t = useTheme(); + const link = codeLink(SERVER, invite.code); + return ( + <View style={[styles.codeCard, { backgroundColor: t.paper, borderColor: t.line }]}> + <Qr text={link} size={132} /> + <View style={styles.codeText}> + <Text testID="invite-code" style={[styles.code, { color: t.ink }]} selectable> + {spaced(invite.code)} + </Text> + <Text style={[styles.help, { color: t.muted }]}> + {who} Works once, until {shortDay(invite.expiresAt)}. + </Text> + <Pressable + testID="share-code" + accessibilityRole="button" + onPress={() => Share.share({ message: `My Blockout code: ${spaced(invite.code)}\n${link}` }).catch(() => {})} + style={({ pressed }) => [styles.share, { backgroundColor: t.soft, borderColor: t.line, opacity: pressed ? 0.7 : 1 }]} + > + <Text style={[styles.shareText, { color: t.ink }]}>Share…</Text> + </Pressable> + </View> + </View> + ); +} + +// A QR code (black on white, with its quiet edge), drawn as one path +export function Qr({ text, size }) { + const qr = qrcode(0, 'M'); + qr.addData(text); + qr.make(); + const n = qr.getModuleCount(); + const quiet = 4; + let d = ''; + for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (qr.isDark(r, c)) d += `M${c + quiet} ${r + quiet}h1v1h-1z`; + const box = n + quiet * 2; + return ( + <Svg testID="qr" width={size} height={size} viewBox={`0 0 ${box} ${box}`} accessibilityLabel="QR code"> + <Rect x={0} y={0} width={box} height={box} fill="#ffffff" /> + <Path d={d} fill="#000000" /> + </Svg> + ); +} + +// "I have a code": a teacher's or a grown-up's one-time code +function Redeem({ toast, onUser }) { + return ( + <Section title="Teachers and grown-ups" help="Got a code to become a teacher, or to follow a child? Type it here."> + <CodeForm testID="redeem" placeholder="Code" maxLength={9} button="Use code" onSubmit={(code) => redeemCode(code, { toast, onUser })} /> + </Section> + ); +} + +// Use a one-time code; the account changes (a new role, a linked child) +export async function redeemCode(code, { toast, onUser }) { + const r = await Account.api('POST', '/api/invites/redeem', { code }); + const who = await Account.refreshUser(); + const role = ROLE[who.role] || ROLE.student; + toast(r.child ? '👪 Linked' : `${role.emoji} Welcome`, r.child ? r.child.name : `You’re a ${role.label.toLowerCase()} now.`); + onUser(who); +} + +// Deleting: typed, because it can't be undone +function DeleteAccount({ onDeleted }) { + const t = useTheme(); + const [asking, setAsking] = useState(false); + const [typed, setTyped] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + if (!asking) return <Button testID="delete-account" kind="secondary" title="Delete my account" onPress={() => setAsking(true)} />; + const sure = typed.trim().toLowerCase() === 'delete'; + const go = async () => { + setError(null); + setBusy(true); + try { + await Account.deleteAccount(); + onDeleted(); + } catch (e) { + setError(e.message); + setBusy(false); + } + }; + return ( + <View style={[styles.danger, { borderColor: t.bad }]}> + <Text style={[styles.dangerText, { color: t.ink }]}>Delete your Blockout account and everything in it? This can’t be undone. Type delete to go ahead.</Text> + <TextInput testID="delete-typed" value={typed} onChangeText={setTyped} autoCapitalize="none" autoCorrect={false} placeholder="delete" placeholderTextColor={t.muted} style={[styles.input, { color: t.ink, backgroundColor: t.surface, borderColor: t.line }]} /> + {error ? <Text style={[styles.error, { color: t.bad }]}>{error}</Text> : null} + <View style={styles.form}> + <Button kind="secondary" title="Cancel" onPress={() => setAsking(false)} style={styles.flex} /> + <Pressable + testID="delete-confirm" + accessibilityRole="button" + accessibilityState={{ disabled: !sure || busy }} + disabled={!sure || busy} + onPress={go} + style={[styles.deleteBtn, { backgroundColor: t.bad, opacity: sure && !busy ? 1 : 0.45 }]} + > + <Text style={styles.deleteText}>{busy ? 'Deleting…' : 'Delete'}</Text> + </Pressable> + </View> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 22, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + card: { borderWidth: 2, borderRadius: 14, padding: 14, gap: 4 }, + big: { fontSize: 22, fontWeight: '900' }, + sub: { fontSize: 15, fontWeight: '600' }, + section: { gap: 8 }, + heading: { fontSize: 14, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5 }, + help: { fontSize: 15, lineHeight: 21 }, + item: { fontSize: 16, fontWeight: '700', padding: 12, borderWidth: 2, borderRadius: 12, overflow: 'hidden' }, + formWrap: { gap: 6 }, + form: { flexDirection: 'row', gap: 8 }, + input: { flex: 1, minWidth: 0, height: 52, borderWidth: 2, borderRadius: 12, paddingHorizontal: 12, fontSize: 18, fontWeight: '700', letterSpacing: 1 }, + formButton: { minWidth: 104 }, + error: { fontSize: 15, fontWeight: '600' }, + codeCard: { flexDirection: 'row', gap: 12, borderWidth: 2, borderRadius: 14, padding: 12, alignItems: 'center' }, + codeText: { flex: 1, gap: 6 }, + code: { fontSize: 26, fontWeight: '900', letterSpacing: 2, fontVariant: ['tabular-nums'] }, + share: { alignSelf: 'flex-start', paddingHorizontal: 14, height: 40, borderRadius: 10, borderWidth: 2, justifyContent: 'center' }, + shareText: { fontSize: 15, fontWeight: '800' }, + danger: { borderWidth: 2, borderRadius: 14, padding: 12, gap: 10 }, + dangerText: { fontSize: 15, lineHeight: 21 }, + flex: { flex: 1 }, + deleteBtn: { flex: 1, height: 52, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, + deleteText: { color: '#fff', fontSize: 17, fontWeight: '800' }, +}); diff --git a/apps/mobile/src/AdminScreen.js b/apps/mobile/src/AdminScreen.js new file mode 100644 index 0000000..6d179e6 --- /dev/null +++ b/apps/mobile/src/AdminScreen.js @@ -0,0 +1,258 @@ +// The admin page, on the phone (as the web's /admin/): find accounts and +// change their role or delete them, make teacher codes, add schools. Only for +// accounts with Keycloak's admin role; the server checks every request. +import { useEffect, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Grownups from '@blockout/progress/grownups'; +import * as Account from './account'; +import { CodeCard } from './AccountScreen'; +import { Chips, ErrorLine, Field, Help, Row, Section, SmallButton, TextBox } from './forms'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +const PAGE = 20; +const ROLES = Object.entries(Grownups.ROLE).map(([role, r]) => [role, `${r.emoji} ${r.label}`]); +const day = (t) => (t ? new Date(t).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '—'); + +export default function AdminScreen({ user, onClose }) { + const t = useTheme(); + const [tab, setTab] = useState('accounts'); + const [error, setError] = useState(null); + // every action: clear the last error, show a new one + const act = async (fn) => { + setError(null); + try { + await fn(); + } catch (e) { + setError(e.message); + } + }; + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="🛠 Admin" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page} keyboardShouldPersistTaps="handled"> + <Chips + testID="admin-tab" + options={[ + ['accounts', 'Accounts'], + ['codes', 'Teacher codes'], + ['schools', 'Schools'], + ]} + value={tab} + onChange={(v) => { + setError(null); + setTab(v); + }} + /> + <ErrorLine error={error} testID="admin-error" /> + {tab === 'accounts' ? <Accounts me={user} act={act} /> : tab === 'codes' ? <Codes act={act} /> : <Schools act={act} />} + </ScrollView> + </View> + ); +} + +function Accounts({ me, act }) { + const t = useTheme(); + const [q, setQ] = useState(''); + const [role, setRole] = useState(''); + const [page, setPage] = useState(0); + const [data, setData] = useState(null); + const [deleting, setDeleting] = useState(null); // { user, typed } + const load = (p = page, r = role) => + act(async () => { + const params = new URLSearchParams({ q: q.trim(), page: String(p), limit: String(PAGE) }); + if (r) params.set('role', r); + setData(await Account.api('GET', `/api/admin/users?${params}`)); + }); + useEffect(() => { + load(); + }, []); + const search = (p = 0, r = role) => { + setPage(p); + load(p, r); + }; + return ( + <Section> + <View style={styles.form}> + <TextBox testID="admin-q" value={q} onChangeText={setQ} placeholder="First name" maxLength={40} style={styles.flex} onSubmitEditing={() => search()} returnKeyType="search" /> + <Button testID="admin-search" title="Find" onPress={() => search()} style={styles.button} /> + </View> + <Chips + testID="admin-role" + options={[['', 'Everyone'], ...ROLES]} + value={role} + onChange={(r) => { + setRole(r); + search(0, r); + }} + /> + {!data ? <ActivityIndicator color={t.accent} /> : null} + {data ? ( + <Help> + {data.total} account{data.total === 1 ? '' : 's'} + </Help> + ) : null} + {(data ? data.users : []).map((u) => ( + <View key={u.id} testID="admin-user" style={[styles.card, { backgroundColor: t.surface, borderColor: t.line }]}> + <Text style={[styles.strong, { color: t.ink }]}> + {u.name} {u.id === me.id ? <Text style={[styles.small, { color: t.muted }]}>(you)</Text> : null} + </Text> + <Text style={[styles.small, { color: t.muted }]}> + {u.domain ? `@${u.domain} · ` : ''}joined {day(u.createdAt)} · last seen {day(u.lastSeen)} + </Text> + <Chips testID={`admin-role-${u.id}`} options={ROLES} value={u.role} onChange={(r) => act(async () => { + try { + await Account.api('PUT', `/api/admin/users/${u.id}/role`, { role: r }); + } finally { + await load(); + } + })} /> + {u.id === me.id ? null : deleting && deleting.user === u.id ? ( + <View style={styles.danger}> + <Text style={[styles.small, { color: t.ink }]}>Delete {u.name}’s account and everything in it (progress, classes they teach, homework, links)? This can’t be undone. Type DELETE to go ahead.</Text> + <TextBox testID="admin-delete-typed" value={deleting.typed} onChangeText={(typed) => setDeleting({ user: u.id, typed })} placeholder="DELETE" autoCapitalize="characters" /> + <View style={styles.form}> + <SmallButton title="Cancel" onPress={() => setDeleting(null)} /> + <SmallButton + testID="admin-delete-confirm" + kind="primary" + title="Delete" + disabled={deleting.typed.trim() !== 'DELETE'} + onPress={() => + act(async () => { + await Account.api('DELETE', `/api/admin/users/${u.id}`); + setDeleting(null); + await load(); + }) + } + /> + </View> + </View> + ) : ( + <SmallButton testID="admin-delete" title="Delete" onPress={() => setDeleting({ user: u.id, typed: '' })} /> + )} + </View> + ))} + {data ? ( + <View style={styles.form}> + {page > 0 ? <SmallButton title="← Newer" onPress={() => search(page - 1)} /> : null} + {(page + 1) * PAGE < data.total ? <SmallButton title="Older →" onPress={() => search(page + 1)} /> : null} + </View> + ) : null} + </Section> + ); +} + +function Codes({ act }) { + const t = useTheme(); + const [schools, setSchools] = useState([]); + const [school, setSchool] = useState(''); + const [invites, setInvites] = useState(null); + const [made, setMade] = useState(null); + const load = () => + act(async () => { + const [s, i] = await Promise.all([Account.api('GET', '/api/admin/schools'), Account.api('GET', '/api/admin/invites?kind=teacher')]); + setSchools(s.schools); + setInvites(i.invites); + }); + useEffect(() => { + load(); + }, []); + return ( + <Section help="A teacher code makes one account a teacher (at a school, if you pick one)."> + <Field label="For"> + <Chips testID="admin-code-school" options={[['', 'Any school (or none)'], ...schools.map((s) => [s.id, s.name])]} value={school} onChange={setSchool} /> + </Field> + {made ? <CodeCard invite={made} who="For one teacher." /> : null} + <Button + testID="admin-make-code" + kind="secondary" + title="Make a teacher code" + onPress={() => + act(async () => { + setMade(await Account.api('POST', '/api/admin/invites', { schoolId: school || null })); + await load(); + }) + } + /> + {!invites ? <ActivityIndicator color={t.accent} /> : !invites.length ? <Help>Nothing here.</Help> : null} + {(invites || []).map((i) => ( + <Row + key={i.id} + testID="admin-code" + right={ + i.usedAt ? null : ( + <SmallButton + title="Remove" + onPress={() => + act(async () => { + await Account.api('DELETE', `/api/me/invites/${i.id}`); + await load(); + }) + } + /> + ) + } + > + <Text style={[styles.strong, { color: t.ink }]}> + {day(i.createdAt)} · by {i.by} + </Text> + <Text style={[styles.small, { color: t.muted }]}> + Used by: {Grownups.adminCodeState(i, day)} · until {day(i.expiresAt)} + </Text> + </Row> + ))} + </Section> + ); +} + +function Schools({ act }) { + const t = useTheme(); + const [schools, setSchools] = useState(null); + const [name, setName] = useState(''); + const [domain, setDomain] = useState(''); + const load = () => act(async () => setSchools((await Account.api('GET', '/api/admin/schools')).schools)); + useEffect(() => { + load(); + }, []); + return ( + <Section> + {!schools ? <ActivityIndicator color={t.accent} /> : !schools.length ? <Help>Nothing here.</Help> : null} + {(schools || []).map((s) => ( + <Row key={s.id} testID="admin-school"> + <Text style={[styles.strong, { color: t.ink }]}>{s.name}</Text> + <Text style={[styles.small, { color: t.muted }]}>@{s.domain}</Text> + </Row> + ))} + <Field label="Add a school"> + <TextBox testID="admin-school-name" value={name} onChangeText={setName} placeholder="School name" maxLength={60} /> + <TextBox testID="admin-school-domain" value={domain} onChangeText={setDomain} placeholder="email domain, like iusd.org" maxLength={80} autoCapitalize="none" keyboardType="url" /> + </Field> + <Button + testID="admin-add-school" + title="Add school" + disabled={!name.trim() || !domain.trim()} + onPress={() => + act(async () => { + await Account.api('POST', '/api/admin/schools', { name, domain }); + setName(''); + setDomain(''); + await load(); + }) + } + /> + </Section> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 14, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + form: { flexDirection: 'row', gap: 8, alignItems: 'center' }, + button: { minWidth: 80 }, + card: { borderWidth: 2, borderRadius: 12, padding: 12, gap: 8 }, + strong: { fontSize: 16, fontWeight: '800' }, + small: { fontSize: 13, lineHeight: 18 }, + danger: { gap: 8 }, +}); diff --git a/apps/mobile/src/App.js b/apps/mobile/src/App.js new file mode 100644 index 0000000..d24acd0 --- /dev/null +++ b/apps/mobile/src/App.js @@ -0,0 +1,527 @@ +// Blockout on a phone: the menu and the game, the save, and signing in. +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { StatusBar } from 'expo-status-bar'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { ActivityIndicator, Animated, Linking, StyleSheet, Text, View } from 'react-native'; +import { SafeAreaProvider, SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as Progress from '@blockout/progress'; +import * as Shop from '@blockout/progress/shop'; +import * as Views from '@blockout/progress/views'; +import * as Settings from '@blockout/progress/settings'; +import * as Wardrobe from '@blockout/progress/wardrobe'; +import * as Homework from '@blockout/progress/homework'; +import * as Daily from '@blockout/progress/daily'; +import * as Journey from '@blockout/progress/journey'; +import * as Account from './account'; +import Game from './Game'; +import Home from './Home'; +import * as Setup from '@blockout/progress/setup'; +import { BOARDS, DIFFICULTIES, boardOpen, difficultyOpen, modeOpen, playersOpen } from './rewards'; +import { ShopScreen, UnlockSheet } from './Shop'; +import { AchievementsScreen, StickersScreen } from './Collection'; +import StatsScreen from './StatsScreen'; +import PracticeScreen from './PracticeScreen'; +import * as Practice from '@blockout/progress/practice'; +import SettingsScreen from './SettingsScreen'; +import WardrobeScreen from './Wardrobe'; +import AccountScreen, { redeemCode } from './AccountScreen'; +import HomeworkScreen from './HomeworkScreen'; +import ClassScreen from './ClassScreen'; +import HostScreen from './HostScreen'; +import MyClassesScreen from './MyClassesScreen'; +import MyKidsScreen from './MyKidsScreen'; +import AdminScreen from './AdminScreen'; +import * as Classroom from './classroom'; +import { parseLink } from './links'; +import * as Notes from './notes'; +import { DEFAULT_LOOKS, Looks } from './looks'; +import { ThemeSetting, useTheme } from './theme'; + +const CHOICE_KEY = 'blockout.mobile.choice'; +const SETTINGS_KEY = 'blockout.settings'; // (on this phone only, like the web game's) +// play: single player's 'journey' (level by level) or 'free' (pick the board); +// practicePlay: Practice's 'journey' (a table a level) or 'free' (Auto: the facts you need) +const DEFAULT_CHOICE = { mode: 'single', play: 'journey', practicePlay: 'journey', difficulty: 'easy', size: 6, level: 'learn', timer: 0, players: 2 }; + +// The game to start from the menu's choice (falling back to what's unlocked). +// Single player has its Level (Expert: no help, bonus ×2) and Expert's timer; +// Home multiplayer has the Settings' answer timer, as on the web. The players +// wear the Wardrobe's looks: the CPU's character, your avatar and title. On +// the Journey the level picks the board and the dice (journeyLevel, sides). +function setupFor(choice, progress, settings, looks) { + const mode = modeOpen(progress, choice.mode) ? choice.mode : 'single'; + const journeyLevel = mode === 'single' && Journey.onJourney(progress, choice.play) ? Journey.journeyLevel(progress) : null; + const journey = journeyLevel ? Journey.levelSetup(journeyLevel) : null; + const difficulty = journey ? journey.difficulty : difficultyOpen(progress, choice.difficulty) ? choice.difficulty : 'easy'; + const size = journey ? journey.size : boardOpen(progress, choice.size) ? choice.size : 6; + const count = mode === 'multi' && playersOpen(progress, choice.players) ? choice.players : 2; + const people = mode === 'multi' ? Array.from({ length: count }, (_, i) => ({ name: `Player ${i + 1}` })) : [{ name: 'You' }, { name: 'CPU', cpu: true }]; + const players = people.map((p) => ({ ...p, statsName: p.name, ...Wardrobe.playerLook(looks, p, mode) })); + const { level, timer } = mode === 'single' ? Setup.gameLevel(progress, difficulty, choice) : { level: 'learn', timer: Number(settings.answerTime) || 0 }; + return { + mode, + difficulty, + size, + ...(journey ? { journeyLevel, sides: journey.sides } : {}), + players, + expert: level === 'expert', + bonusMultiplier: level === 'expert' ? Setup.GAME_EXPERT_BONUS : 1, + timer, // seconds to answer (0: none); the bonus goes by it, the clock by timerMs + timerMs: Setup.timerMs(progress, timer), + weight: difficulty === 'master' ? Setup.masterWeight(progress, players.find((p) => !p.cpu).name) : null, + }; +} + +export default function App() { + const [theme, setTheme] = useState('auto'); // (Settings › Theme, for every screen) + return ( + <SafeAreaProvider> + <ThemeSetting.Provider value={theme}> + <Main onTheme={setTheme} /> + </ThemeSetting.Provider> + </SafeAreaProvider> + ); +} + +function Main({ onTheme }) { + const t = useTheme(); + const [ready, setReady] = useState(false); + const [user, setUser] = useState(null); + const [progress, setProgress] = useState(null); + const [choice, setChoice] = useState(DEFAULT_CHOICE); + const [settings, setSettings] = useState(Settings.DEFAULT_SETTINGS); + const [game, setGame] = useState(null); // { key, setup } + const [screen, setScreen] = useState('menu'); // 'menu' | 'shop' | 'wardrobe' | 'stats' | 'achievements' | 'stickers' | 'settings' (a game is on top) + const [wardrobeNew, setWardrobeNew] = useState([]); // (the looks new when the Wardrobe opened) + const [homeworkCount, setHomeworkCount] = useState(0); // (a student's homework still to do) + const [inClass, setInClass] = useState(null); // a live class: { code } to join, or { resume } (a session from last time) + const [liveClasses, setLiveClasses] = useState([]); // (a student's scheduled tournaments, open now) + const [hosting, setHosting] = useState(null); // a live class this teacher hosts: { host } (null in it: setting one up) + const pendingLink = useRef(null); // (a code's link that came before signing in) + const live = useRef({}); // (the latest user, for link events) + live.current.user = user; + const [offer, setOffer] = useState(null); // the unlock sheet: Shop.unlockOffer's answer + const [signingIn, setSigningIn] = useState(false); + const [accountError, setAccountError] = useState(null); + const [, setTick] = useState(0); + const toasts = useToasts(); + // The looks worn: the ones chosen (Settings › cosmetics), if they're owned + const unlocked = progress ? progress.unlocks.length : 0; // (buying changes what can be worn) + const looks = useMemo(() => (progress ? Wardrobe.chosenLooks(settings.cosmetics, progress) : DEFAULT_LOOKS), [settings.cosmetics, progress, unlocked]); + + // At start: the last account, its save (synced when online), the last choice + useEffect(() => { + (async () => { + const who = await Account.loadSession(); + setUser(who); + let save = await Account.loadProgress(); + if (who) save = await Account.syncProgress().catch(() => save); // (offline: play on, sync later) + // the first visit of the day: points, and the streak achievements + const visit = Views.dailyCheckIn(save); + if (visit) { + toasts.show(...visit.toast); + for (const a of visit.earned) toasts.show(`${a.icon} ${a.name}`, `Achievement! +${a.reward} points`); + for (const drop of Views.grantStickers(save, visit.earned)) toasts.show(...drop.toast); + Account.saveProgress(save); + } + Daily.today(save); // (a new day: today's practice) + setProgress(save); + try { + setChoice({ ...DEFAULT_CHOICE, ...JSON.parse(await AsyncStorage.getItem(CHOICE_KEY)) }); + } catch (_) {} + let saved = {}; + try { + saved = JSON.parse(await AsyncStorage.getItem(SETTINGS_KEY)) || {}; + } catch (_) {} + const s = Settings.enforce(saved, save); // (anything not unlocked back to its default) + setSettings(s); + onTheme(s.theme); + const left = await Classroom.loadSaved(); // (the app was closed in the middle of a class) + if (left.host) setHosting({ host: left.host }); + else if (left.session) setInClass({ resume: left.session }); + setReady(true); + })(); + }, []); + + // A student's homework badge: at start, after signing in, and back on the menu + useEffect(() => { + if (!ready || !user || user.role !== 'student' || game || screen !== 'menu') return; + Account.api('GET', '/api/me/homework') + .then(({ homework }) => setHomeworkCount(Homework.openCount(homework))) + .catch(() => {}); // (offline: no badge) + }, [ready, user, game, screen]); + + // A student's tournaments open now (scheduled by their teacher), while on the menu + useEffect(() => { + if (!ready || !user || user.role !== 'student' || game || inClass || hosting || screen !== 'menu') return setLiveClasses([]); + const look = () => + Account.api('GET', '/api/me/live') + .then(({ live }) => setLiveClasses(live)) + .catch(() => {}); // (offline) + look(); + const every = setInterval(look, 20 * 1000); + return () => clearInterval(every); + }, [ready, user, game, inClass, hosting, screen]); + + // Links into the app: a one-time code, or a class to join (links.js) + useEffect(() => { + if (!ready) return undefined; + Linking.getInitialURL() + .then((url) => followLink(parseLink(url))) + .catch(() => {}); + const sub = Linking.addEventListener('url', ({ url }) => followLink(parseLink(url))); + return () => sub.remove(); + }, [ready]); + + function followLink(link) { + if (!link) return; + const who = live.current.user; + if (link.kind === 'class') return setInClass({ code: link.code }); // (no account needed) + if (link.kind === 'code') { + if (!who) { + pendingLink.current = link; + return toasts.show('🔑 Sign in first', 'Then the code is used for your account.'); + } + return redeemCode(link.code, { toast: toasts.show, onUser: setUser }).catch((e) => toasts.show('🔑 That code didn’t work', e.message)); + } + if (!who || who.role !== 'student') { + if (!who) pendingLink.current = link; + return toasts.show(`🏫 To join class ${link.code}`, 'Sign in with your school account.'); + } + Account.api('POST', '/api/me/classes', { code: link.code }) + .then((r) => toasts.show('🏫 You joined', r.class.name)) + .catch((e) => toasts.show('🏫 Couldn’t join', e.message)); + } + + const save = () => { + Account.saveProgress(progress); + setTick((n) => n + 1); // (the wallet changed) + }; + + const choose = (part) => { + const next = { ...choice, ...part }; + setChoice(next); + AsyncStorage.setItem(CHOICE_KEY, JSON.stringify(next)); + }; + + const setSetting = (key, value) => { + setSettings((now) => { + const next = { ...now, [key]: value }; + AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(next)); + return next; + }); + if (key === 'theme') onTheme(value); + }; + + // Put a look on (or a title) + const wear = (categoryId, optionId) => setSetting('cosmetics', { ...looks, [categoryId]: optionId }); + + const openWardrobe = () => { + const keys = Shop.wardrobeKeys(progress); + setWardrobeNew(keys.filter((k) => !Shop.seenList(progress, 'wardrobe').includes(k))); + Shop.markSeen(progress, 'wardrobe', keys); + save(); + setScreen('wardrobe'); + }; + + // New achievements: a banner each, and the sticker each big one drops (as on the web) + function celebrate(earned) { + for (const a of earned) toasts.show(`${a.icon} ${a.name}`, `Achievement! +${a.reward} points`); + for (const drop of Views.grantStickers(progress, earned)) toasts.show(...drop.toast); + } + + // Tapped a locked thing, or a Shop row: what can be done about it + const ask = (itemId) => setOffer(Shop.unlockOffer(progress, itemId)); + + // Buy it, say so, and switch it on (the choices the app has so far) + function buy(itemId) { + setOffer(null); + const bought = Shop.purchase(progress, itemId); + if (!bought.ok) return; + toasts.show(...bought.toast); + celebrate(bought.earned); + save(); + const on = Shop.activation(itemId) || {}; + const part = {}; + if (on.mode === 'single' || (on.mode === 'multi' && (on.players || 2) === 2)) part.mode = on.mode; + if (DIFFICULTIES.some((d) => d.id === on.difficulty)) part.difficulty = on.difficulty; + if (BOARDS.includes(on.size)) part.size = on.size; + if (Object.keys(part).length) choose(part); + if (on.setting) setSetting(on.setting[0], on.setting[1] === 'true' ? true : on.setting[1]); + const item = Progress.shopItem(itemId); + if (item.cosmetic) { + wear(...item.cosmetic); // put it on + Shop.markSeen(progress, 'wardrobe', [item.cosmetic.join(':')]); + } + } + + async function signIn() { + setAccountError(null); + setSigningIn(true); + try { + const who = await Account.signIn(); + if (who) { + setUser(who); + const synced = await Account.syncProgress(); + setProgress(synced); + setSettings((s) => Settings.enforce(s, synced)); // (this save's unlocks) + live.current.user = who; + const link = pendingLink.current; + pendingLink.current = null; + followLink(link); // (a code's link that came before signing in) + } + } catch (e) { + setAccountError(e.message || 'Signing in didn’t work.'); + } finally { + setSigningIn(false); + } + } + + async function signOut() { + setScreen('menu'); + await Account.signOut(); + setUser(null); + setHomeworkCount(0); + const guest = await Account.loadProgress(); // back to this phone's guest save + setProgress(guest); + setSettings((s) => Settings.enforce(s, guest)); // (its unlocks) + } + + live.current.choice = choice; + if (!ready) { + return ( + <View style={[styles.center, { backgroundColor: t.bg }]}> + <ActivityIndicator size="large" color={t.accent} /> + </View> + ); + } + + // The account was deleted: back to this phone's guest save + async function deleted() { + setScreen('menu'); + setUser(null); + const guest = await Account.loadProgress(); + setProgress(guest); + setSettings((s) => Settings.enforce(s, guest)); + toasts.show('👋 Account deleted', 'Everything in it is gone.'); + } + + // Today's practice, card i: its round straight away (a taste of the next + // table too, though it's still locked), or the menu set up for its game + // (the Journey's level, or a board in Free play) + function playDaily(i) { + const p = Daily.playFor(Daily.today(progress).choices[i]); + if (p.practice) return setGame({ key: Date.now(), practice: p.practice }); + const { journey, ...game } = p.game; + const next = { ...choice, ...game, play: journey ? 'journey' : 'free' }; + choose(next); + play(next); + } + + // Homework's Play ›: a times table's practice straight away; otherwise set + // the menu up for it (a game in Free play, not the Journey's level), then start + function playHomework(goal) { + const c = Homework.homeworkChoice(progress, goal); + if (c.tables) return setGame({ key: Date.now(), practice: { difficulty: c.difficulty || 'easy', count: 5, tables: c.tables, level: 'learn', timer: 0 } }); + const next = { ...choice, mode: c.mode, ...(c.mode === 'single' ? { play: 'free' } : {}) }; + if (c.difficulty) next.difficulty = c.difficulty; + if (c.size) next.size = c.size; + choose(next); + setScreen('menu'); + play(next); + } + + // Play a game, or a practice round (the round's options from the menu) + const play = (choice = live.current.choice) => { + if (choice.mode === 'practice' && Journey.onPracticeJourney(progress, choice.practicePlay)) { + // Practice's Journey: the ×n table of the level reached + const journeyLevel = Journey.practiceLevel(progress); + // (the round sets the length, Learn or Expert and the timer) + const round = Journey.practiceSetup(journeyLevel); + return setGame({ key: Date.now(), practice: { difficulty: Journey.practiceTier(journeyLevel), count: round.count, tables: [], level: round.expert ? 'expert' : 'learn', timer: round.timer, journeyLevel } }); + } + if (choice.mode === 'practice' && modeOpen(progress, 'practice')) { + // Auto: the facts you need from every table you've reached, with the + // menu's length, Learn or Expert and timer (as far as they're unlocked) + const d = Practice.autoDifficulty(progress); + const pr = Practice.practiceChoice(progress, d, choice.practice || {}); + return setGame({ key: Date.now(), practice: { difficulty: d, count: pr.count, tables: [], level: pr.level, timer: pr.timer, auto: true } }); + } + setGame({ key: Date.now(), setup: setupFor(choice, progress, settings, looks) }); + }; + + return ( + <Looks.Provider value={looks}> + <SafeAreaView style={[styles.fill, { backgroundColor: t.bg }]}> + <StatusBar style="auto" /> + {hosting ? ( + <HostScreen + host={hosting.host} + toast={toasts.show} + onExit={() => { + setHosting(null); + setScreen(user && user.role === 'teacher' ? 'classes' : 'menu'); + }} + /> + ) : inClass ? ( + <ClassScreen + key={inClass.code || 'class'} + user={user} + resume={inClass.resume || null} + code={inClass.code || ''} + progress={progress} + settings={settings} + onSave={save} + toast={toasts.show} + onEarned={celebrate} + earned={toasts.held} + onHold={toasts.hold} + onExit={() => setInClass(null)} + /> + ) : game && game.practice ? ( + <PracticeScreen key={game.key} options={game.practice} progress={progress} settings={settings} onSave={save} toast={toasts.show} onEarned={celebrate} earned={toasts.held} onHold={toasts.hold} onExit={() => setGame(null)} onAgain={() => play()} /> + ) : game ? ( + <Game key={game.key} setup={game.setup} settings={settings} progress={progress} onSave={save} onTurnOn={(switches) => switches.forEach(([key, value]) => setSetting(key, value))} toast={toasts.show} onEarned={celebrate} earned={toasts.held} onHold={toasts.hold} onExit={() => setGame(null)} onAgain={() => play()} /> + ) : screen === 'shop' ? ( + <ShopScreen progress={progress} onAsk={ask} onClose={() => setScreen('menu')} /> + ) : screen === 'account' && user ? ( + <AccountScreen user={user} toast={toasts.show} onUser={setUser} onSignOut={signOut} onDeleted={deleted} onClose={() => setScreen('menu')} /> + ) : screen === 'homework' ? ( + <HomeworkScreen + progress={progress} + student={Boolean(user && user.role === 'student')} + onPlay={playHomework} + onDaily={(i) => { + setScreen('menu'); + playDaily(i); + }} + onReroll={() => { + const ok = Daily.reroll(progress).ok; + if (ok) save(); + return ok; + }} + onCount={setHomeworkCount} + onClose={() => setScreen('menu')} + /> + ) : screen === 'classes' && user ? ( + <MyClassesScreen user={user} toast={toasts.show} onHost={() => setHosting({ host: null })} onOpenLobby={(host) => setHosting({ host })} onClose={() => setScreen('menu')} /> + ) : screen === 'admin' && user ? ( + <AdminScreen user={user} onClose={() => setScreen('menu')} /> + ) : screen === 'kids' && user ? ( + <MyKidsScreen user={user} toast={toasts.show} onClose={() => setScreen('menu')} /> + ) : screen === 'wardrobe' ? ( + <WardrobeScreen progress={progress} newKeys={wardrobeNew} onWear={wear} onAsk={ask} onClose={() => setScreen('menu')} /> + ) : screen === 'stats' ? ( + <StatsScreen progress={progress} settings={settings} onClose={() => setScreen('menu')} /> + ) : screen === 'settings' ? ( + <SettingsScreen progress={progress} settings={settings} onSet={setSetting} onLocked={ask} onClose={() => setScreen('menu')} /> + ) : screen === 'achievements' ? ( + <AchievementsScreen progress={progress} onClose={() => setScreen('menu')} /> + ) : screen === 'stickers' ? ( + <StickersScreen + progress={progress} + onClose={() => setScreen('menu')} + onSeen={(ids) => { + Shop.markSeen(progress, 'stickers', ids); + save(); + }} + onSwap={() => { + const swap = Views.swapSpares(progress, Shop.formatPoints); + if (!swap.points) return; + toasts.show(...swap.toast); + save(); + }} + /> + ) : ( + <Home + progress={progress || Progress.createProgress()} + user={user} + choice={choice} + onChoose={choose} + onPlay={() => play()} + onClass={(code) => setInClass({ code })} + liveClasses={liveClasses} + onSignIn={signIn} + onAccount={() => setScreen('account')} + onHomework={() => setScreen('homework')} + onClasses={() => setScreen('classes')} + onKids={() => setScreen('kids')} + onAdmin={() => setScreen('admin')} + homeworkCount={homeworkCount + (Daily.today(progress).done ? 0 : 1)} + signingIn={signingIn} + accountError={accountError} + onLocked={ask} + onShop={() => setScreen('shop')} + onWardrobe={openWardrobe} + onAchievements={() => setScreen('achievements')} + onStats={() => setScreen('stats')} + onSettings={() => setScreen('settings')} + onStickers={() => setScreen('stickers')} + /> + )} + <UnlockSheet offer={offer} onBuy={buy} onClose={() => setOffer(null)} /> + <toasts.View /> + </SafeAreaView> + </Looks.Provider> + ); +} + +// Bonuses and achievements show one at a time, sliding in at the top; the +// same kind waiting in the queue is stacked into one (notes.js). During a +// game or practice round they're held instead (hold(true)) and listed on its +// results screen (held), except urgent ones (show(title, text, { urgent })). +function useToasts() { + const t = useTheme(); + // (positioned absolutely, so it has to step below the notch and status bar itself) + const insets = useSafeAreaInsets(); + const queue = useRef([]); // stacked notes (notes.js) + const active = useRef(false); // (a ref: show() is called from other screens' renders) + const [current, setCurrent] = useState(null); + const holding = useRef(false); + const [held, setHeld] = useState([]); + const y = useRef(new Animated.Value(-200)).current; + + const next = () => { + const note = queue.current.shift(); + const item = note ? Notes.words(note) : null; + active.current = Boolean(item); + setCurrent(item ? { title: item[0], text: item[1] } : null); + if (!item) return; + y.setValue(-(insets.top + 160)); + Animated.sequence([ + Animated.spring(y, { toValue: 0, useNativeDriver: true }), + Animated.delay(1500), + Animated.timing(y, { toValue: -(insets.top + 160), duration: 250, useNativeDriver: true }), + ]).start(next); + }; + + const show = (title, text, { urgent = false } = {}) => { + if (holding.current && !urgent) return setHeld((list) => Notes.stack(list, title, text)); + queue.current = Notes.stack(queue.current, title, text); + if (!active.current) next(); + }; + + // A game or round starts (true: hold what it earns) or ends (false) + const hold = (on) => { + holding.current = on; + if (on) setHeld([]); + }; + + const View_ = () => + current ? ( + <Animated.View testID="toast" pointerEvents="none" style={[styles.toast, { top: insets.top + 8, backgroundColor: t.goldBg, borderColor: t.gold, transform: [{ translateY: y }] }]}> + <Text style={[styles.toastTitle, { color: t.ink }]}>{current.title}</Text> + <Text style={[styles.toastText, { color: t.goldInk }]}>{current.text}</Text> + </Animated.View> + ) : null; + + return { show, hold, held, View: View_ }; +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + center: { flex: 1, alignItems: 'center', justifyContent: 'center' }, + toast: { position: 'absolute', left: 16, right: 16, padding: 12, borderRadius: 14, borderWidth: 2 }, + toastTitle: { fontSize: 17, fontWeight: '800' }, + toastText: { fontSize: 15, fontWeight: '600' }, +}); diff --git a/apps/mobile/src/Board.js b/apps/mobile/src/Board.js new file mode 100644 index 0000000..eb89224 --- /dev/null +++ b/apps/mobile/src/Board.js @@ -0,0 +1,280 @@ +// The board: squares, the rectangles so far in their players' colours, and +// whatever is happening on it now (a rectangle being drawn, the CPU's growing +// one, skip-counting). Drawing is a drag from corner to corner. +import { useEffect, useMemo, useRef, useState } from 'react'; +import { PanResponder, Platform, View } from 'react-native'; +import Svg, { G, Line, Rect, Text as SvgText } from 'react-native-svg'; +import { boardLayout, cellAt, dragRect } from './boardMath'; +import { useTheme } from './theme'; +import { BoardDecor, PatternDefs, hasPattern, patternFill, useBoardSkin, useLooks, usePlayerColors } from './looks'; +import { useReducedMotion } from './effects'; + +// (the web build: the system's sans-serif, and no text selection while dragging) +const FONT = Platform.OS === 'web' ? 'system-ui, sans-serif' : undefined; +const NO_SELECT = Platform.OS === 'web' ? { userSelect: 'none' } : null; + +// A colour made darker (amount < 0) or lighter, like the web board's outlines +function shade(hex, amount) { + const n = parseInt(hex.slice(1), 16); + const ch = (v) => Math.round(Math.max(0, Math.min(255, amount < 0 ? v * (1 + amount) : v + (255 - v) * amount))); + return `rgb(${ch(n >> 16)}, ${ch((n >> 8) & 255)}, ${ch(n & 255)})`; +} + +// The lines between the squares inside a rectangle, drawn over its colour so +// every square can be counted +// every square can be counted (quieter than the rectangle's outline, so its shape still shows) +function SquareLines({ x, y, w, h, cell, width = 1, opacity = 0.45 }) { + const lines = []; + for (let i = 1; i < w; i++) lines.push(<Line key={`c${i}`} x1={(x + i) * cell} y1={y * cell} x2={(x + i) * cell} y2={(y + h) * cell} stroke="#fff" strokeOpacity={opacity} strokeWidth={width} />); + for (let i = 1; i < h; i++) lines.push(<Line key={`r${i}`} x1={x * cell} y1={(y + i) * cell} x2={(x + w) * cell} y2={(y + i) * cell} stroke="#fff" strokeOpacity={opacity} strokeWidth={width} />); + return lines; +} + +// A rectangle's outline: dark and thick, just inside its edge, so two +// rectangles side by side show a clear line between them +const OUTLINE = 3.5; +function Outline({ x, y, w, h, cell, color }) { + return <Rect x={x * cell + OUTLINE / 2} y={y * cell + OUTLINE / 2} width={w * cell - OUTLINE} height={h * cell - OUTLINE} fill="none" stroke={shade(color, -0.6)} strokeWidth={OUTLINE} />; +} + +// drag: { c0, r0, c1, r1, verdict } while drawing (verdict: match.judge's); preview: the CPU's rectangle so +// far (hide: the placed one it grows into); counting: { rect, byRows, shown }; +// onDraw(rect) when a drag ends; tapped: a rectangle placed for you, shown a +// moment (with valid). +export default function Board({ match, width, drag, preview, hide, counting, canDraw, onDrag, onDraw, tapped = null }) { + const t = useTheme(); + const PLAYER_COLORS = usePlayerColors(); // (the Wardrobe's looks) + const skin = useBoardSkin(); + const pattern = useLooks().pattern; // (on your own rectangles) + const { size, board } = match; + const { cell, side } = boardLayout(width, size); + const start = useRef(null); + const live = useRef({ canDraw, onDrag, onDraw, cell, size }); + live.current = { canDraw, onDrag, onDraw, cell, size }; + + const responder = useMemo( + () => + PanResponder.create({ + onStartShouldSetPanResponder: () => live.current.canDraw, + onMoveShouldSetPanResponder: () => live.current.canDraw, + onPanResponderTerminationRequest: () => false, // (don't let a scroll steal the drag) + onPanResponderGrant: (e) => { + const { cell: px, size: n } = live.current; + const { c, r } = cellAt(e.nativeEvent.locationX, e.nativeEvent.locationY, px, n); + start.current = { c0: c, r0: r, x0: e.nativeEvent.locationX, y0: e.nativeEvent.locationY }; + live.current.onDrag({ c0: c, r0: r, c1: c, r1: r }); + }, + onPanResponderMove: (_, g) => { + const s = start.current; + if (!s) return; + const { cell: px, size: n } = live.current; + const { c, r } = cellAt(s.x0 + g.dx, s.y0 + g.dy, px, n); + live.current.onDrag({ c0: s.c0, r0: s.r0, c1: c, r1: r }); + }, + onPanResponderRelease: (_, g) => { + const s = start.current; + start.current = null; + if (!s) return; + const { cell: px, size: n } = live.current; + const { c, r } = cellAt(s.x0 + g.dx, s.y0 + g.dy, px, n); + live.current.onDraw(dragRect({ c0: s.c0, r0: s.r0, c1: c, r1: r })); + }, + onPanResponderTerminate: () => { + start.current = null; + live.current.onDrag(null); + }, + }), + [] + ); + + const fx = usePlaceEffect(match, useLooks().place); + + const box = (r, key, props) => <Rect key={key} x={r.x * cell} y={r.y * cell} width={r.w * cell} height={r.h * cell} {...props} />; + const drawing = drag ? dragRect(drag) : null; + + return ( + <View style={[{ width: side, height: side, alignSelf: 'center' }, NO_SELECT]} {...responder.panHandlers} accessibilityLabel={`Board, ${size} by ${size}`}> + <Svg width={side} height={side} pointerEvents="none"> + <PatternDefs kind={pattern} /> + <Rect x={0} y={0} width={side} height={side} fill={skin.bg} /> + <BoardDecor kind={skin.decor} side={side} cell={cell} /> + {Array.from({ length: size + 1 }, (_, i) => ( + <G key={i}> + <Line x1={i * cell} y1={0} x2={i * cell} y2={side} stroke={skin.grid} strokeWidth={1} /> + <Line x1={0} y1={i * cell} x2={side} y2={i * cell} stroke={skin.grid} strokeWidth={1} /> + </G> + ))} + {board.rects.map((r, i) => { + if (r === hide) return null; + const color = PLAYER_COLORS[r.player % PLAYER_COLORS.length]; + const label = r.w * cell >= 34 && r.h * cell >= 22; + return ( + <G key={`r${i}`}> + {box(r, 'fill', { fill: color, fillOpacity: r.solved ? 0.85 : 0.5 })} + {hasPattern(pattern) && match.players[r.player] && !match.players[r.player].cpu ? box(r, 'pattern', { fill: patternFill(pattern) }) : null} + <SquareLines {...r} cell={cell} /> + <Outline {...r} cell={cell} color={color} /> + {label && r.solved ? ( + <SvgText x={(r.x + r.w / 2) * cell} y={(r.y + r.h / 2) * cell + 6} fill="#fff" fontFamily={FONT} fontSize={Math.min(18, cell * 0.6)} fontWeight="bold" textAnchor="middle"> + {r.area} + </SvgText> + ) : null} + </G> + ); + })} + {fx ? <PlaceEffect {...fx} cell={cell} color={PLAYER_COLORS[fx.rect.player % PLAYER_COLORS.length]} gold={t.gold} /> : null} + {counting ? <CountingOverlay counting={counting} cell={cell} t={t} /> : null} + {preview ? box(preview, 'cpu', { fill: PLAYER_COLORS[match.current % PLAYER_COLORS.length], fillOpacity: 0.35, stroke: skin.edge, strokeWidth: 2, strokeDasharray: '6 4' }) : null} + {tapped ? <Drawing rect={tapped} verdict={{ ok: tapped.valid, fits: tapped.valid, rightSize: true }} color={PLAYER_COLORS[match.current % PLAYER_COLORS.length]} cell={cell} side={side} t={t} skin={skin} /> : null} + {drawing ? <Drawing rect={drawing} verdict={drag.verdict} color={PLAYER_COLORS[match.current % PLAYER_COLORS.length]} cell={cell} side={side} t={t} skin={skin} /> : null} + <Rect x={1} y={1} width={side - 2} height={side - 2} fill="none" stroke={skin.edge} strokeWidth={2} /> + </Svg> + </View> + ); +} + +// The Wardrobe's placing effect: for a moment after one of your rectangles +// lands, { kind, rect, p } with p going from 0 to 1 +const PLACE_FX_MS = 650; +function usePlaceEffect(match, kind) { + const reduced = useReducedMotion(); + const [fx, setFx] = useState(null); + const count = useRef(match.board.rects.length); + const rects = match.board.rects; + useEffect(() => { + const before = count.current; + count.current = rects.length; + const rect = rects[rects.length - 1]; + const owner = rect && match.players[rect.player]; + if (rects.length !== before + 1 || kind === 'none' || reduced || !owner || owner.cpu) return undefined; + const start = Date.now(); + let frame = null; + const step = () => { + const p = Math.min(1, (Date.now() - start) / PLACE_FX_MS); + setFx(p < 1 ? { kind, rect, p } : null); + if (p < 1) frame = requestAnimationFrame(step); + }; + step(); + return () => { + cancelAnimationFrame(frame); + setFx(null); + }; + }, [rects.length]); + return fx; +} + +function PlaceEffect({ kind, rect, p, cell, color, gold }) { + const x = rect.x * cell; + const y = rect.y * cell; + const w = rect.w * cell; + const h = rect.h * cell; + if (kind === 'pop') { + // a flash that shrinks onto the rectangle + const grow = (1 - p) * cell * 0.4; + return <Rect x={x - grow} y={y - grow} width={w + grow * 2} height={h + grow * 2} fill="#fff" fillOpacity={(1 - p) * 0.6} stroke={color} strokeOpacity={1 - p} strokeWidth={3} />; + } + if (kind === 'ripple') { + // two rings spreading out from its edge + return [0, 0.25].map((lag) => { + const q = Math.max(0, Math.min(1, (p - lag) / (1 - lag))); + const out = q * cell * 1.6; + return <Rect key={lag} x={x - out} y={y - out} width={w + out * 2} height={h + out * 2} rx={out / 2} fill="none" stroke={color} strokeOpacity={(1 - q) * 0.9} strokeWidth={3} />; + }); + } + // glow: a golden edge that brightens and fades + return <Rect x={x} y={y} width={w} height={h} fill={gold} fillOpacity={Math.sin(p * Math.PI) * 0.35} stroke={gold} strokeOpacity={Math.sin(p * Math.PI)} strokeWidth={6} />; +} + +// The rectangle being drawn, labelled with its size, as on the web: the +// player's colour when it's right and fits, grey when the size is wrong, red +// when it bumps into another. The label sits above it (the finger is on it), +// or inside its top when there's no room above. +function Drawing({ rect, verdict, color, cell, side, t, skin }) { + const tint = !verdict.fits ? t.bad : verdict.ok ? color : skin.wrong; + const x = rect.x * cell; + const y = rect.y * cell; + const w = rect.w * cell; + const h = rect.h * cell; + const label = `${rect.w} × ${rect.h}${verdict.ok ? ' ✓' : ''}`; + const size = Math.max(14, Math.min(cell * 0.6, 24)); + const tw = label.length * size * 0.62 + 14; + const th = size + 10; + const tx = Math.max(2, Math.min(x + w / 2 - tw / 2, side - tw - 2)); + const ty = y - th - 4 >= 0 ? y - th - 4 : y + 4; + return ( + <G> + <Rect x={x} y={y} width={w} height={h} fill={tint} fillOpacity={verdict.ok ? 0.55 : 0.3} /> + <Rect x={x + 1.5} y={y + 1.5} width={w - 3} height={h - 3} fill="none" stroke={tint} strokeWidth={3} /> + <Rect x={tx} y={ty} width={tw} height={th} rx={6} fill={t.ink} /> + <SvgText x={tx + tw / 2} y={ty + th / 2 + size * 0.35} fill={t.surface} fontFamily={FONT} fontSize={size} fontWeight="900" textAnchor="middle"> + {label} + </SvgText> + </G> + ); +} + +// Skip-counting: the rows (or columns) counted so far light up, each with its running total +// everySquare: every counted square shows its number (1, 2, 3, then 4, 5, 6 …), +// the last of each row in bold: that's the running total. Without it only +// the running totals show (the board's squares are too small for more). +function CountingOverlay({ counting, cell, t, everySquare = false }) { + const { rect, byRows, shown, group } = counting; + const size = everySquare ? Math.min(22, cell * 0.42) : Math.min(16, cell * 0.5); + const strips = []; + for (let i = 0; i < shown; i++) { + const strip = byRows ? { x: rect.x, y: rect.y + i, w: rect.w, h: 1 } : { x: rect.x + i, y: rect.y, w: 1, h: rect.h }; + // the squares along the strip, in counting order (left to right, or top to bottom) + const squares = Array.from({ length: group }, (_, j) => (byRows ? { x: strip.x + j, y: strip.y } : { x: strip.x, y: strip.y + j })); + const numbered = everySquare ? squares : squares.slice(-1); + strips.push( + <G key={i}> + <Rect x={strip.x * cell} y={strip.y * cell} width={strip.w * cell} height={strip.h * cell} fill={t.gold} fillOpacity={0.9} /> + <SquareLines {...strip} cell={cell} width={1.5} opacity={0.6} /> + {numbered.map((sq) => { + const n = i * group + (byRows ? sq.x - strip.x : sq.y - strip.y) + 1; + const total = n === (i + 1) * group; + return ( + <SvgText + key={n} + x={(sq.x + 0.5) * cell} + y={(sq.y + 0.5) * cell + size * 0.35} + fill={t.ink} + fillOpacity={total ? 1 : 0.7} + fontFamily={FONT} + fontSize={total && everySquare ? size * 1.15 : size} + fontWeight={total ? '900' : '600'} + textAnchor="middle" + > + {n} + </SvgText> + ); + })} + </G> + ); + } + return strips; +} + +// The rectangle just drawn, on its own, for answering: a × b squares in the +// player's colour, as big as fits (up to 56 px a square), with "Help me +// count" lighting up its rows. counting: { byRows, group, shown }. +export function RectPicture({ rect, color, counting, maxWidth, maxHeight }) { + const t = useTheme(); + const pattern = useLooks().pattern; + const cell = Math.max(8, Math.floor(Math.min(maxWidth / rect.w, maxHeight / rect.h, 56))); + const width = cell * rect.w; + const height = cell * rect.h; + const at = { ...rect, x: 0, y: 0 }; + return ( + <View style={[{ width, height, alignSelf: 'center' }, NO_SELECT]} accessibilityLabel={`Your rectangle: ${rect.w} squares across and ${rect.h} down`}> + <Svg width={width} height={height} pointerEvents="none"> + <PatternDefs kind={pattern} /> + <Rect x={0} y={0} width={width} height={height} fill={color} fillOpacity={0.85} /> + {hasPattern(pattern) ? <Rect x={0} y={0} width={width} height={height} fill={patternFill(pattern)} /> : null} + {counting ? <CountingOverlay counting={{ ...counting, rect: at }} cell={cell} t={t} everySquare /> : null} + <SquareLines x={0} y={0} w={rect.w} h={rect.h} cell={cell} width={1.5} opacity={0.55} /> + <Outline x={0} y={0} w={rect.w} h={rect.h} cell={cell} color={color} /> + </Svg> + </View> + ); +} diff --git a/apps/mobile/src/ClassScreen.js b/apps/mobile/src/ClassScreen.js new file mode 100644 index 0000000..3610eae --- /dev/null +++ b/apps/mobile/src/ClassScreen.js @@ -0,0 +1,224 @@ +// A live class, as a student (the teacher hosts it from the web game): join +// with the code on the board, wait for the teacher, then play each game the +// teacher starts: the whole class on the same rolls, or pairs and +// tournaments head to head. Results stay up until the next game starts. +// What the screens say is shared with the web game (@blockout/progress/classroom). +import { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; +import * as Live from '@blockout/progress/classroom'; +import * as Wardrobe from '@blockout/progress/wardrobe'; +import * as Classroom from './classroom'; +import Game from './Game'; +import { addToHistory } from './history'; +import { useLooks } from './looks'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +// The game for this view, if there's one to play: { key, kind, me } +function gameFor(view) { + if (view.you.benched) return null; // joined after it started: the next one + if (view.type === 'class') return view.state === 'lobby' ? null : { key: `class:${view.game}`, kind: 'class', me: 0 }; + const m = view.match; + if (!m || view.state === 'lobby') return null; + return { key: `pair:${view.game}:${m.id}`, kind: 'pair', me: m.players.findIndex((p) => p.id === view.you.id), over: m.over }; +} + +// user: the signed-in account (a student plays under their first name); +// resume: a session from last time; code: from a link; onExit() back to the menu +export default function ClassScreen({ user, resume = null, code: linkCode = '', progress, settings, onSave, toast, onEarned, earned, onHold, onExit }) { + const t = useTheme(); + const looks = useLooks(); + const [session, setSession] = useState(resume); + const [view, setView] = useState(null); + const [playing, setPlaying] = useState(null); // the game on screen: { key, kind, me, setup, cfg } + const finished = useRef(new Set()); // (games whose results are up) + const stop = useRef(null); + const seen = useRef(false); + const rewarded = useRef(new Set()); // (tournaments already rewarded) + + // Live updates while in the class + useEffect(() => { + if (!session) return undefined; + seen.current = false; + Classroom.client.saveSession(session); + stop.current = Classroom.client.listenStudent(session, { + view: (v) => { + seen.current = true; + setView(v); + }, + gone: (reason) => { + stop.current = null; + Classroom.client.saveSession(null); + const said = Live.goneToast(reason, seen.current); + if (said) toast(...said); + onExit(); + }, + }); + return () => stop.current && stop.current(); + }, [session]); + + // Each view: which game to show, and a tournament's rewards once it's over + useEffect(() => { + if (!view) return; + const done = !session.teacher && Live.tournamentRewards(progress, view, session.name, rewarded.current); + if (done) { + onEarned(done.earned); + onSave(); + addToHistory(done.entry); + if (done.champion) toast('🏆 Tournament champion!', 'You won the whole thing!'); + } + const next = gameFor(view); + if (next && (!playing || playing.key !== next.key)) { + if (next.over) return setPlaying((p) => (p && finished.current.has(p.key) ? p : null)); // (finished before you got here) + return setPlaying({ ...next, ...setupFor(view, next, settings, looks) }); + } + // no game now: a finished one's results stay up until the next starts + if (!next && playing && !finished.current.has(playing.key)) setPlaying(null); + }, [view]); + + async function leave() { + if (stop.current) stop.current(); + stop.current = null; + if (session) Classroom.client.leave(session).catch(() => {}); + Classroom.client.saveSession(null); + onExit(); + } + + if (!session) return <JoinForm user={user} linkCode={linkCode} looks={looks} onJoined={setSession} onClose={onExit} />; + + if (playing && view) { + return ( + <Game + key={playing.key} + setup={playing.setup} + settings={playing.cfg} + progress={progress} + onSave={onSave} + toast={toast} + onEarned={onEarned} + earned={earned} + onHold={onHold} + onExit={leave} + live={{ kind: playing.kind, view, session, client: Classroom.client, me: playing.me, onOver: () => finished.current.add(playing.key) }} + /> + ); + } + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title={`Class ${session.code}`} onClose={leave} closeLabel="Leave the class" /> + <View style={styles.wait}> + <Text style={styles.waitIcon}>🏫</Text> + <Text testID="class-name" style={[styles.waitName, { color: t.ink }]}> + {session.name} + </Text> + {view ? null : <ActivityIndicator color={t.accent} />} + <Text testID="class-wait" style={[styles.waitText, { color: t.muted }]}> + {view ? Live.waitingText(view, session) : 'Joining…'} + </Text> + <Button testID="leave-class" kind="secondary" title="Leave class" onPress={leave} style={styles.wide} /> + </View> + </View> + ); +} + +// The game a view starts: the teacher's settings for everyone (no shop locks), +// your own way of placing unless the teacher picked one +function setupFor(view, next, settings, looks) { + const s = view.settings; + const timer = Number(s.answerTime) || 0; + const mine = (p) => ({ ...p, ...Wardrobe.playerLook(looks, p, 'single') }); + const players = + next.kind === 'class' + ? [mine({ name: view.you.name })] + : view.match.players.map((p, i) => (i === next.me ? mine({ name: p.name }) : { name: p.isTeacher ? 'Teacher' : p.name, avatar: p.avatar, title: p.title })); // (the server plays a CPU partner's turns) + return { + setup: { mode: next.kind, difficulty: s.difficulty, size: s.size, players, timer, timerMs: timer * 1000 }, + cfg: { ...settings, ...Live.classCfg(s, settings.placeMode), autoRoll: 'manual' }, + }; +} + +// The class code (and a guest's name: a signed-in student plays under their first name) +function JoinForm({ user, linkCode, looks, onJoined, onClose }) { + const t = useTheme(); + const student = user && user.role === 'student' ? user : null; + const [code, setCode] = useState(linkCode); + const [name, setName] = useState(student ? student.firstName || student.name : ''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!student) Classroom.lastName().then((n) => n && setName((now) => now || n)); + // from a link: is that class still on? + if (linkCode) + Classroom.client.roomInfo(linkCode).catch((err) => { + if (err.code !== 'room') return; + setError('This class has finished. Ask your teacher for a new link.'); + setCode(''); + }); + }, []); + + async function join() { + const c = code.trim().toUpperCase(); + const n = name.trim(); + if (!c || !n || busy) return; + setError(null); + setBusy(true); + try { + const r = await Classroom.client.join(c, n, { avatar: looks.avatar, title: looks.title }); + if (!student) Classroom.saveName(n); + onJoined({ code: c, id: r.id, key: r.key, name: r.name || n, teacher: r.teacher }); + } catch (err) { + setError(err.message); + setBusy(false); + } + } + + const input = [styles.input, { color: t.ink, backgroundColor: t.surface, borderColor: t.line }]; + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Join a class" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page} keyboardShouldPersistTaps="handled"> + <Text style={[styles.help, { color: t.muted }]}>Type the code your teacher shows on the board.</Text> + <Text style={[styles.label, { color: t.ink }]}>Class code</Text> + <TextInput testID="class-code" value={code} onChangeText={(v) => setCode(v.toUpperCase())} placeholder="ABCD" placeholderTextColor={t.muted} maxLength={4} autoCapitalize="characters" autoCorrect={false} style={[...input, styles.code]} /> + <Text style={[styles.label, { color: t.ink }]}>Your name</Text> + <TextInput + testID="class-name-input" + value={name} + onChangeText={setName} + editable={!student} + placeholder="First name" + placeholderTextColor={t.muted} + maxLength={20} + autoCorrect={false} + returnKeyType="go" + onSubmitEditing={join} + style={[...input, student ? { opacity: 0.7 } : null]} + /> + {student ? <Text style={[styles.help, { color: t.muted }]}>You play under your account’s name.</Text> : null} + {error ? ( + <Text testID="class-join-error" style={[styles.error, { color: t.bad }]}> + {error} + </Text> + ) : null} + <Button testID="class-join" title={busy ? 'Joining…' : 'Join'} onPress={join} disabled={busy || code.trim().length < 4 || !name.trim()} /> + </ScrollView> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 10, maxWidth: 700, width: '100%', alignSelf: 'center' }, + help: { fontSize: 15, lineHeight: 21 }, + label: { fontSize: 15, fontWeight: '800', marginTop: 6 }, + input: { height: 52, minWidth: 0, borderWidth: 2, borderRadius: 12, paddingHorizontal: 12, fontSize: 20, fontWeight: '700' }, + code: { fontSize: 30, letterSpacing: 8, textAlign: 'center', height: 64 }, + error: { fontSize: 15, fontWeight: '600' }, + wait: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 14, padding: 24 }, + waitIcon: { fontSize: 56 }, + waitName: { fontSize: 26, fontWeight: '900' }, + waitText: { fontSize: 18, textAlign: 'center', lineHeight: 26 }, + wide: { alignSelf: 'stretch' }, +}); diff --git a/apps/mobile/src/Collection.js b/apps/mobile/src/Collection.js new file mode 100644 index 0000000..8809acc --- /dev/null +++ b/apps/mobile/src/Collection.js @@ -0,0 +1,165 @@ +// Achievements and Stickers, drawn from @blockout/progress/views (the same +// lists and words as the web game). +import { useEffect } from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Shop from '@blockout/progress/shop'; +import * as Views from '@blockout/progress/views'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +// The fact speed tiers' colours: bronze, silver, gold +const SPEED = { 10: '#c98a4b', 5: '#9aa3ad', 1: '#e0a800' }; + +export function AchievementsScreen({ progress, onClose }) { + const t = useTheme(); + const list = Views.achievementGroups(progress); + const fs = Views.factSpeed(progress); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Achievements" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page}> + <Text testID="achievements-summary" style={[styles.summary, { color: t.muted }]}> + {list.earned} of {list.total} earned + </Text> + {list.groups.map((g) => ( + <View key={g.name} style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}> + {g.name} · {g.earned} of {g.total} + </Text> + {g.note ? <Text style={[styles.note, { color: t.muted }]}>{g.note}</Text> : null} + {g.rows.map((r) => ( + <View key={r.id} testID={`achievement-${r.id}`} style={[styles.row, { backgroundColor: r.earned ? t.goldBg : t.surface, borderColor: r.earned ? t.gold : t.line }]}> + <Text style={[styles.icon, { opacity: r.earned ? 1 : 0.45 }]}>{r.icon || '🔒'}</Text> + <View style={styles.rowText}> + <Text style={[styles.name, { color: t.ink }]}>{r.name}</Text> + <Text style={[styles.desc, { color: t.muted }]}>{r.desc}</Text> + </View> + <Text style={[styles.right, { color: r.earned ? t.goldInk : t.muted }]}>{r.right}</Text> + </View> + ))} + </View> + ))} + <View style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}> + Fact speed · {fs.earned} of {fs.total} + </Text> + <Text style={[styles.note, { color: t.muted }]}>{fs.note}</Text> + <View style={styles.legend}> + {fs.tiers.map((tier) => ( + <View key={tier.secs} style={styles.legendItem}> + <View style={[styles.legendCell, { backgroundColor: SPEED[tier.secs] }]}> + <Text style={styles.legendNum}>{tier.secs}</Text> + </View> + <Text style={[styles.desc, { color: t.ink }]}> + Under {tier.secs} s: {tier.count} + </Text> + </View> + ))} + </View> + <FactGrid fs={fs} /> + </View> + </ScrollView> + </View> + ); +} + +// size × size cells with the times table along the edges +function FactGrid({ fs }) { + const t = useTheme(); + const head = (text, key) => ( + <View key={key} style={styles.cell}> + <Text style={[styles.head, { color: t.muted }]}>{text}</Text> + </View> + ); + return ( + <View style={styles.grid} accessibilityLabel={`Fact speed, up to ${fs.size} × ${fs.size}`}> + <View style={styles.gridRow}> + {head('×', 'corner')} + {fs.cells[0].map((c) => head(String(c.b), `h${c.b}`))} + </View> + {fs.cells.map((row, r) => ( + <View key={r} style={styles.gridRow}> + {head(String(r + 1), `r${r}`)} + {row.map((c) => ( + <View key={c.b} style={[styles.cell, styles.fact, { backgroundColor: c.best ? SPEED[c.best] : t.soft }]}> + {c.best ? <Text style={styles.factNum}>{c.best}</Text> : null} + </View> + ))} + </View> + ))} + </View> + ); +} + +// onSeen(ids): they've been looked at (clears the menu badge); onSwap(): spares for points +export function StickersScreen({ progress, onClose, onSeen, onSwap }) { + const t = useTheme(); + const album = Views.stickerAlbum(progress); + useEffect(() => { + onSeen(Object.keys(progress.stickers || {})); + }, []); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Stickers" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page}> + <Text testID="stickers-summary" style={[styles.summary, { color: t.muted }]}> + {album.got} of {album.total} collected + </Text> + <Text style={[styles.note, { color: t.muted }]}>Every achievement drops a sticker. Swap the spares for points!</Text> + {album.sections.map((section) => ( + <View key={section.rarity} style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}>{section.name}</Text> + <View style={styles.album}> + {section.slots.map((st) => ( + <View key={st.id} accessibilityLabel={st.count ? `${st.id}, ${section.name}${st.count > 1 ? `, ${st.count}` : ''}` : 'Not found yet'} style={[styles.slot, { backgroundColor: st.count ? t.surface : t.soft, borderColor: st.count ? RARITY[section.rarity] : t.line }]}> + <Text style={[styles.sticker, { opacity: st.count ? 1 : 0.35, color: t.muted }]}>{st.emoji || '?'}</Text> + {st.count > 1 ? ( + <View style={[styles.count, { backgroundColor: t.ink }]}> + <Text style={[styles.countText, { color: t.bg }]}>×{st.count}</Text> + </View> + ) : null} + </View> + ))} + </View> + </View> + ))} + </ScrollView> + <View style={styles.bottom}> + <Button testID="swap-spares" title={album.spareValue ? `Swap spares for 🪙 ${Shop.formatPoints(album.spareValue)}` : 'No spares to swap'} onPress={onSwap} disabled={!album.spareValue} /> + </View> + </View> + ); +} + +const RARITY = { common: '#9aa3ad', rare: '#2e86de', epic: '#8e5bd6' }; + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 18, paddingBottom: 40 }, + summary: { fontSize: 16, fontWeight: '700' }, + group: { gap: 8 }, + groupTitle: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, + note: { fontSize: 14, lineHeight: 20 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 12, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth * 2 }, + icon: { fontSize: 26, width: 34, textAlign: 'center' }, + rowText: { flex: 1, gap: 2 }, + name: { fontSize: 16, fontWeight: '700' }, + desc: { fontSize: 14, lineHeight: 19 }, + right: { fontSize: 14, fontWeight: '700' }, + legend: { flexDirection: 'row', flexWrap: 'wrap', gap: 14 }, + legendItem: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + legendCell: { width: 24, height: 24, borderRadius: 5, alignItems: 'center', justifyContent: 'center' }, + legendNum: { color: '#fff', fontSize: 12, fontWeight: '800' }, + grid: { gap: 3 }, + gridRow: { flexDirection: 'row', gap: 3 }, + cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' }, + head: { fontSize: 13, fontWeight: '700' }, + fact: { borderRadius: 4 }, + factNum: { color: '#fff', fontSize: 11, fontWeight: '800' }, + album: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + slot: { width: 64, height: 64, borderRadius: 14, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + sticker: { fontSize: 32 }, + count: { position: 'absolute', right: -6, top: -6, minWidth: 26, height: 20, borderRadius: 10, paddingHorizontal: 4, alignItems: 'center', justifyContent: 'center' }, + countText: { fontSize: 12, fontWeight: '800' }, + bottom: { padding: 16, paddingTop: 8 }, +}); diff --git a/apps/mobile/src/Game.js b/apps/mobile/src/Game.js new file mode 100644 index 0000000..aa3ccd4 --- /dev/null +++ b/apps/mobile/src/Game.js @@ -0,0 +1,1062 @@ +// A game on the phone, played turn by turn with @blockout/engine/match. Each +// turn moves through three screens, sized to the phone so nothing scrolls: +// +// roll big dice and a Roll button; then "Draw it ›" (or on by itself) +// board the board, as big as it fits: drawing the rectangle, the CPU's +// turn and its explanation, and the results +// answer the question and the keypad +// help "Help me count": the rectangle on its own, its rows lighting up +// one by one with the running total, then back to the answer +// +// The screen animates between the match's steps; a game left half-way just +// stops (every wait checks it's still the same game). +// +// A live class's game (live) plays the same turns, but the server rolls (the +// teacher's roll for the whole class, or yours on your turn in pairs), hears +// each answer, and ends the game. Pairs: your partner's moves show as they +// make them. The words, the board's catching up and the rewards are shared +// with the web game (@blockout/progress/classroom). +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Match from '@blockout/engine/match'; +import * as Core from '@blockout/engine'; +import * as Progress from '@blockout/progress'; +import * as Settings from '@blockout/progress/settings'; +import * as Stats from '@blockout/progress/stats'; +import Board, { RectPicture } from './Board'; +import { dragRect, drawnMessage } from './boardMath'; +import * as Rewards from './rewards'; +import * as Live from '@blockout/progress/classroom'; +import * as Daily from '@blockout/progress/daily'; +import * as Journey from '@blockout/progress/journey'; +import { addToHistory, recordGame } from './history'; +import { useTheme } from './theme'; +import { usePlayerColors } from './looks'; +import { Celebration, Rolling, Streak } from './effects'; +import { Button, Die, Earned, Keypad } from './ui'; + +// For automated checks of the web build: BLOCKOUT_FAST (set before the page +// loads) makes every wait 20 times shorter, and BlockoutMobile.match is the game +const testing = Platform.OS === 'web' && typeof window !== 'undefined'; +const pace = testing && window.BLOCKOUT_FAST ? 0.05 : 1; +const AUTO_ROLL_MS = 700 * pace; // before an auto roll, so "Your turn" can be read first +const AUTO_PLACE_MS = 900 * pace; // how long an auto-placed rectangle shows before the question +const INSTANT_CPU_MS = 600 * pace; // an instant CPU turn: just long enough to see it +const COUNT_STEP_MS = 850 * pace; // between skip-counting steps when helping +const PASS_MS = 1600 * pace; +const SCORED_MS = 1400 * pace; +const DRAW_IT_MS = 2500 * pace; // after a roll, on to the board by itself +const TRY_AGAIN = ['Not quite — try again!', 'Almost! Have another go.', 'Hmm, not that one. Try again!']; +const BONUS_ICONS = { first: '✨', speedy: '⚡', streak: '🔥', timer: '⏱️' }; +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); +// "an 8", "an 11", "an 18", but "a 7" +const article = (n) => (/^(8|11|18)$/.test(String(n)) || /^8\d$/.test(String(n)) ? 'an' : 'a'); + +// setup: { mode, difficulty, size, players }; progress: the save (changed in +// place); onSave() after every change to it; toast(title, text) for bonuses; +// onEarned(achievements) for their banners and stickers; onExit() back to the +// menu; onAgain() a new game. +// settings: the Settings (read once: they don't change mid-game, as on the web). +// live: a live class's game: { kind: 'class' | 'pair', view (the server's, +// updated as it changes), session, client, me (pairs: your player), onOver() +// when its results are up }; onExit then leaves the class. +// earned: what it's earned so far (held back from popping up while playing: +// onHold(true) while it's on screen), listed on its results screen +// onTurnOn(switches): settings a Journey level up turns on ([[key, value], …]) +export default function Game({ setup, settings = Settings.DEFAULT_SETTINGS, progress, onSave, onTurnOn = () => {}, toast, onEarned, earned = [], onHold = () => {}, onExit, onAgain, live = null }) { + const t = useTheme(); + const PLAYER_COLORS = usePlayerColors(); // (the Wardrobe's colours) + const [, setTick] = useState(0); + const redraw = () => setTick((n) => n + 1); + const alive = useRef(true); + const m = useRef(null); + const cfg = useRef(settings).current; + const CPU_STEP_MS = (Settings.CPU_STEP_MS[cfg.cpuSpeed] || 1000) * pace; + const placeMode = cfg.placeMode; // 'draw' or 'auto' + if (!m.current) m.current = Match.createMatch({ ...setup, answerBonus: Progress.answerBonus }); + const match = m.current; + if (testing) window.BlockoutMobile = { match, Match, live }; + // a live game: the round (or pairs turn) it's at, whether this one's answer + // went to the server, and what of the server's match is on the board + const lc = useRef(null); + if (!lc.current) { + lc.current = { round: 0, reported: true, doneTurn: 0, synced: 0, passSeen: 0, rollShown: null, note: '' }; + match.teacherPlayer = Boolean(live && live.session.teacher); + if (live && live.kind === 'class' && Live.rejoinClass(match, live.view.you)) toast(`🏫 Back in class ${live.view.code}`, `You have ${live.view.you.score} points.`, { urgent: true }); + } + const gen = useRef(0); // a new roll from the teacher stops whatever the last one was still doing + + const [area, setArea] = useState(null); // { width, height } below the header + const [view, setView] = useState('roll'); // 'roll' | 'board' | 'answer' | 'help' + const [msg, setMsg] = useState(''); + const [oops, setOops] = useState(false); + const [dice, setDice] = useState([null, null]); + const [rolling, setRolling] = useState(false); // (the dice are spinning, in the Wardrobe's roll look) + const [rolled, setRolled] = useState(false); // the roll is in: "Draw it ›" + const [drag, setDrag] = useState(null); + const [preview, setPreview] = useState(null); + const [hide, setHide] = useState(null); + const [counting, setCounting] = useState(null); + const [steps, setSteps] = useState([]); + const [entry, setEntry] = useState(''); + const [feedback, setFeedback] = useState(null); // { text, kind: 'good' | 'try' | 'help' } + const [busy, setBusy] = useState(false); + const [over, setOver] = useState(null); // { results } + const [helpDone, setHelpDone] = useState(false); // counted: "I know it! ›" + const [goOn, setGoOn] = useState(null); // after a CPU turn: the "My turn ›" button's label + const [tapped, setTapped] = useState(null); // auto placing: the spot shown for a moment + const goOnPressed = useRef(null); + const helpStarted = useRef(0); + const answerStart = useRef(0); + const pausedMs = useRef(0); + const turnId = useRef(0); // (so a late "on by itself" doesn't skip a turn) + + useEffect(() => { + onHold(true); // (bonuses and achievements wait for the results) + return () => { + alive.current = false; + stopClock(); + onHold(false); + }; + }, []); + + // ---- the answer timer (Expert's, or Home multiplayer's from Settings). It + // pauses while counting; when it runs out the rectangle comes off. + const clock = useRef(null); // { total, remaining, last, paused, interval } + const [timeLeft, setTimeLeft] = useState(null); + const [answering, setAnswering] = useState(null); // the rectangle on the answer screen + function startClock() { + stopClock(); + if (!setup.timerMs) return; + const c = { total: setup.timerMs, remaining: setup.timerMs, last: Date.now(), paused: false }; + c.interval = setInterval(() => { + const now = Date.now(); + if (!c.paused) c.remaining -= now - c.last; + c.last = now; + setTimeLeft(Math.max(0, c.remaining)); + if (c.remaining <= 0) { + stopClock(); + answerTimeout(); + } + }, 100); + clock.current = c; + setTimeLeft(c.total); + } + function stopClock() { + if (clock.current) clearInterval(clock.current.interval); + clock.current = null; + } + function pauseClock(paused) { + if (!clock.current) return; + clock.current.paused = paused; + clock.current.last = Date.now(); + } + + // Out of time: the answer shows, the rectangle comes off, no points + async function answerTimeout() { + if (!alive.current || match.phase !== 'answer') return; + const res = Match.timeout(match); + report({ kind: 'timeout' }); + Rewards.onTimeout(progress, match, res); + onSave(); + setBusy(true); + setView('answer'); + setEntry(String(res.rect.area)); + setFeedback({ kind: 'try', text: `⏰ Time's up! ${res.rect.a} × ${res.rect.b} = ${res.rect.area}. No points this turn.` }); + redraw(); + await wait(3000 * pace); + setBusy(false); + setTimeLeft(null); + return nextTurn(); + } + // Resolves after ms, or never once the game is left + const wait = useCallback((ms) => { + const g = gen.current; + return new Promise((resolve) => setTimeout(() => alive.current && g === gen.current && resolve(), ms)); + }, []); + + const who = (p) => (match.mode === 'single' && !p.cpu ? 'You' : p.name); + const mine = () => !Match.currentPlayer(match).cpu; + const award = (earned) => onEarned(earned); + + // ---- turns + + async function rollAnimation() { + const face = (list) => list[Math.floor(Math.random() * list.length)]; + setRolling(true); + for (let i = 0; i < 10; i++) { + setDice([face(match.faces.a), face(match.faces.b)]); + await wait(60 * Math.max(pace, 0.5)); + } + setRolling(false); + } + + function startTurn() { + turnId.current++; + helpPlan.current = null; + setView('roll'); + setDice([null, null]); + setRolled(false); + setSteps([]); + setFeedback(null); + setEntry(''); + setOops(false); + if (live) return liveTurn(); + if (match.phase === 'cpu') return runCpu(); + const p = Match.currentPlayer(match); + setTapped(null); + if (cfg.autoRoll === 'auto') { + // Auto roll: the dice roll by themselves, after a moment to read whose turn it is + setMsg(match.mode === 'multi' ? `${p.name}: rolling the dice…` : 'Rolling the dice…'); + redraw(); + const turn = turnId.current; + wait(AUTO_ROLL_MS).then(() => turn === turnId.current && humanRoll()); + return; + } + setMsg(match.mode === 'multi' ? `${p.name}, roll the dice!` : 'Roll the dice!'); + redraw(); + } + + function nextTurn() { + if (live) return startTurn(); // (the server ends these games) + const results = Match.endTurn(match); + if (results) return finish(results); + startTurn(); + } + + async function humanRoll() { + if (match.phase !== 'roll' || busy) return; + if (live) return pairRoll(); // (the server rolls for a pairs game) + setBusy(true); + setMsg(''); + await rollAnimation(); + const [a, b] = Match.roll(match); + setBusy(false); + return rolledDice(a, b); + } + + async function rolledDice(a, b) { + setDice([a, b]); + if (match.phase === 'stuck') { + report({ kind: 'pass' }); // (whole class: the server checks it; pairs: it passes for you) + setMsg(`No room for a ${a} × ${b} anywhere. ${match.mode === 'multi' ? `${Match.currentPlayer(match).name} passes` : 'You pass'}.`); + redraw(); + await wait(PASS_MS); + Match.pass(match); + return nextTurn(); + } + setMsg(`You rolled ${a} and ${b}!`); + setRolled(true); + const turn = turnId.current; + wait(DRAW_IT_MS).then(() => turn === turnId.current && match.phase === 'place' && toBoard()); + } + + // From the roll to placing it: drawing it, or auto + async function toBoard() { + if (match.phase !== 'place') return; + const [a, b] = match.dice; + setRolled(false); + setView('board'); + if (placeMode === 'auto') { + // placed snugly for you, shown a moment, then the math + const spot = Match.autoSpot(match); + setMsg(`Placing your ${a} by ${b} rectangle…`); + setTapped({ ...spot, valid: true }); + await wait(AUTO_PLACE_MS); + setTapped(null); + return onDraw(spot); + } + setMsg(`Draw a ${a} by ${b} rectangle: press on a square and drag.`); + } + + function onDrag(d) { + if (!d || match.phase !== 'place') return setDrag(null); + setDrag({ ...d, verdict: Match.judge(match, dragRect(d)) }); + showPartner('place', dragRect(d)); + } + + function onDraw(rect) { + setDrag(null); + if (match.phase !== 'place') return; + const res = Match.place(match, rect); + if (!res.ok) { + setMsg(drawnMessage(rect, match.dice, res.verdict)); + setOops(true); + return; + } + setOops(false); + setEntry(''); + setFeedback(null); + answerStart.current = Date.now(); + pausedMs.current = 0; + setAnswering(res.rect); + setView('answer'); + showPartner('answer', res.rect); + startClock(); + } + + // ---- answering + + function onKey(key) { + if (match.phase !== 'answer' || busy) return; + if (key === 'check') return check(entry); + let next = entry; + if (key === 'back') next = entry.slice(0, -1); + else if (entry.length < 3) next = (entry === '0' ? '' : entry) + key; + setEntry(next); + if (Match.answerDecided(next, match.pending.rect.area)) check(next); + } + + async function check(value) { + if (value === '' || match.phase !== 'answer') return; + const ms = Date.now() - answerStart.current - pausedMs.current; + // the timer bonus goes by how much time was left + const c = clock.current; + const timer = c && setup.timer ? { secs: setup.timer, left: c.remaining / c.total } : null; + const res = Match.answer(match, value, { ms, timer }); + if (res.correct) { + stopClock(); + setBusy(true); + const squares = `+${res.rect.area} ${res.rect.area === 1 ? 'square' : 'squares'}`; + setFeedback({ text: res.firstTry ? `Correct! ${squares} 🎉` : `You got it! ${squares}`, kind: 'good' }); + report({ kind: 'placed', rect: { x: res.rect.x, y: res.rect.y, w: res.rect.w, h: res.rect.h }, firstTry: res.firstTry, ms: Math.round(ms) }); + for (const part of res.bonus.parts) toast(`${BONUS_ICONS[part.label.split(' ')[0]] || '⭐'} ${capitalize(part.label)}`, `+${part.points} bonus points`); + award(Rewards.onAnswer(progress, match, res, ms)); + onSave(); + redraw(); + await wait(SCORED_MS); + setBusy(false); + setCounting(null); + setTimeLeft(null); + return nextTurn(); + } + setEntry(''); + setFeedback({ text: TRY_AGAIN[(res.attempts - 1) % TRY_AGAIN.length], kind: 'try' }); + if (res.helpNow) { + setBusy(true); + await wait(900 * Math.max(pace, 0.2)); // ("Not quite" first) + setBusy(false); + helpCount("Let's count them together!"); + } + } + + // The first time a turn: the help counts (fact stats, achievements). After + // that, "Count again" replays the same counting (again: the plan kept here). + const helpPlan = useRef(null); + async function helpCount(intro = null, again = false) { + if (match.phase !== 'answer') return; + const rect = match.pending.rect; + let h = helpPlan.current; + if (!again) { + h = Match.help(match); + if (!h) return; + helpPlan.current = h; + award(Rewards.onHelp(progress, match, rect)); + onSave(); + } + if (!h) return; + helpStarted.current = Date.now(); + pauseClock(true); + setHelpDone(false); + setCounting(null); + setView('help'); + if (h.split) { + const s = h.split; + setFeedback({ kind: 'help', text: `Split ${s.teen} into 10 and ${s.ones}: 10 × ${s.other} = ${s.tens}, and ${s.ones} × ${s.other} = ${s.rest}. Now add them up!` }); + setHelpDone(true); + return; + } + const plan = h.count; + const units = plan.steps === 1 ? plan.unit : `${plan.unit}s`; + setFeedback({ kind: 'help', text: intro || `${plan.steps} ${units} of ${plan.group}. Let's count${again ? ' again' : ''}!` }); + await wait(COUNT_STEP_MS); + for (let i = 1; i <= plan.steps; i++) { + setFeedback({ kind: 'help', text: `${plan.steps} ${units} of ${plan.group}: ${plan.totals.slice(0, i).join(', ')}` }); + setCounting({ byRows: plan.byRows, group: plan.group, shown: i }); + await wait(COUNT_STEP_MS); + } + setFeedback({ kind: 'help', text: `${plan.totals.join(', ')}. So what is ${rect.a} × ${rect.b}?` }); + setHelpDone(true); + } + + // From counting back to the answer (the time spent counting doesn't count against them) + function backToAnswer() { + pausedMs.current += Date.now() - helpStarted.current; + pauseClock(false); + setFeedback(null); + setEntry(''); + setView('answer'); + } + + // ---- the CPU's turn: roll, then on the board: draw it growing, explain the counting + + // After a CPU turn: on by itself, or (Settings › After a CPU turn) a button + // to press, unless the game is over anyway + async function afterCpu(delay) { + if (cfg.cpuEnd !== 'button' || Match.isOver(match)) return wait(delay); + const next = match.players[(match.current + 1) % match.players.length]; + setGoOn(match.mode === 'single' ? 'My turn ›' : next.cpu ? `${next.name} ›` : `${next.name}'s turn ›`); + await new Promise((resolve) => (goOnPressed.current = resolve)); + setGoOn(null); + } + + // Instant CPU turns: roll, place and score straight away, no explanation + async function runInstantCpu() { + const p = Match.currentPlayer(match); + const { dice: [a, b], rect } = Match.cpuTurn(match); + setDice([a, b]); + setView('board'); + setMsg(rect ? `${p.name} rolled ${a} and ${b}: ${a} × ${b} = ${rect.area}.` : `${p.name} rolled ${a} and ${b}: no room, so it passes.`); + redraw(); + await wait(INSTANT_CPU_MS); + return nextTurn(); + } + + async function runCpu() { + if (cfg.cpuSteps === 'instant') return runInstantCpu(); + const p = Match.currentPlayer(match); + setMsg(`${p.name} is rolling…`); + redraw(); + await wait(CPU_STEP_MS); + await rollAnimation(); + const firstOnBoard = match.board.rects.length === 0; + const { dice: [a, b], rect } = Match.cpuTurn(match); + setDice([a, b]); + if (!rect) { + setMsg(`No room for a ${a} × ${b} anywhere. ${p.name} passes.`); + redraw(); + await afterCpu(PASS_MS); + return nextTurn(); + } + setMsg(`${p.hello ? `${p.hello} ` : ''}${p.name} rolled ${a} and ${b}.`); // (with the CPU character's greeting) + setHide(rect); + await wait(CPU_STEP_MS * 1.2); + setMsg(''); + setView('board'); + const lines = [`I rolled ${article(a)} ${a} and ${article(b)} ${b}. I'll draw it ${firstOnBoard ? 'in a corner' : 'snug against the others'}: ${rect.w} across and ${rect.h} down.`]; + setSteps([...lines]); + for (let w = 1; w <= rect.w; w++) { + setPreview({ ...rect, w, h: 1 }); + await wait(CPU_STEP_MS / 2); + } + for (let h = 2; h <= rect.h; h++) { + setPreview({ ...rect, h }); + await wait(CPU_STEP_MS); + } + setPreview(null); + setHide(null); + const split = Match.legendSplit(rect, match.difficulty); + if (split) { + const s = split.split; + lines.push(`Split ${s.teen} into 10 and ${s.ones}: ${s.tens} + ${s.rest} = ${rect.area}.`); + setSteps([...lines]); + await wait(CPU_STEP_MS * 2); + } else if (a === 1 || b === 1) { + lines.push(`It's just 1 ${a === 1 ? 'row' : 'column'} of ${a === 1 ? b : a}, so that's ${rect.area}.`); + setSteps([...lines]); + await wait(CPU_STEP_MS); + } else { + const plan = Match.countPlan(rect); + const intro = `That's ${plan.steps} ${plan.unit}s of ${plan.group}. Let's count by ${plan.group}s:`; + for (let i = 1; i <= plan.steps; i++) { + setSteps([...lines, `${intro} ${plan.totals.slice(0, i).join(', ')}`]); + setCounting({ rect, byRows: plan.byRows, group: plan.group, shown: i }); + await wait(CPU_STEP_MS); + } + lines.push(`${intro} ${plan.totals.join(', ')}`); + setCounting(null); + } + lines.push(`${a} × ${b} = ${rect.area}`); + setSteps([...lines]); + redraw(); + await afterCpu(CPU_STEP_MS * 1.5); + return nextTurn(); + } + + // ✕ in the middle of a game asks first: the game's points only count once it's finished + function leave() { + if (over) return onExit(); + const title = live ? 'Leave the class?' : 'Leave this game?'; + const text = live ? 'You can join again with the class code.' : "The points from this game won't count. (Your answers and achievements are kept.)"; + if (Platform.OS === 'web') { + if (window.confirm(`${title}\n\n${text}`)) onExit(); + return; + } + Alert.alert(title, text, [ + { text: 'Keep playing', style: 'cancel' }, + { text: 'Leave', style: 'destructive', onPress: onExit }, + ]); + } + + // ---- a live class's game + + const opponent = () => match.players[1 - live.me]; + + // A turn in a live game: wait for the teacher's roll (whole class), or in + // pairs roll on your turn and watch your partner's + function liveTurn() { + const c = lc.current; + if (live.kind === 'class') { + match.phase = 'wait'; + setMsg(c.round ? 'Waiting for the next roll…' : 'Get ready! Waiting for the first roll…'); + return redraw(); + } + const m = live.view.match; + if (m && !m.over && m.current === live.me && m.turn === c.round && m.turn !== c.doneTurn) { + c.reported = false; + match.phase = 'roll'; + const auto = live.view.settings.autoRoll; + setMsg(`${c.note} ${auto ? 'Your turn! Rolling…' : 'Your turn: roll the dice!'}`.trim()); + c.note = ''; + redraw(); + const turn = turnId.current; + if (auto) wait(AUTO_ROLL_MS).then(() => turn === turnId.current && humanRoll()); + return; + } + match.phase = 'wait'; + setView('board'); // (watch your partner's move) + setMsg(m && m.current !== live.me ? `${opponent().name}'s turn…` : 'Waiting…'); + redraw(); + } + + // Tell the server how this roll went (once per roll) + function report(result) { + const c = lc.current; + if (!live || c.reported) return; + c.reported = true; + c.doneTurn = c.round; + live.client.sendResult(live.session, { round: c.round, ...result }).catch((err) => { + if (err.code !== 'stale' && err.code !== 'done') toast('⚠️ Couldn’t send your answer', err.message, { urgent: true }); + }); + } + + // Pairs: your roll, from the server (it passes for you when nothing fits) + async function pairRoll() { + const c = lc.current; + setBusy(true); + setMsg(''); + match.phase = 'rolling'; + let res; + try { + res = await live.client.roll(live.session); + } catch (err) { + match.phase = 'roll'; + setBusy(false); + if (err.code !== 'turn' && err.code !== 'stale') toast('⚠️ Couldn’t roll', err.message, { urgent: true }); + return redraw(); + } + await rollAnimation(); + setBusy(false); + const [a, b] = res.roll; + setDice([a, b]); + if (res.passed) { + c.reported = true; + c.doneTurn = c.round; + match.phase = 'wait'; + return setMsg(`No room for a ${a} × ${b} anywhere. You pass.`); + } + match.phase = 'roll'; + Match.roll(match, [a, b]); + return rolledDice(a, b); + } + + // Pairs: what you're doing on your turn (the rectangle you're dragging or + // aiming, then the one you're answering) for your partner to watch: when it + // changes, a few times a second at most. Only a preview: misses don't matter. + const partnerFeed = useRef({ sent: '', timer: null }); + function showPartner(phase, rect) { + if (!live || live.kind !== 'pair' || match.current !== live.me) return; + const f = partnerFeed.current; + const key = JSON.stringify({ phase, rect: rect ? { x: rect.x, y: rect.y, w: rect.w, h: rect.h } : null }); + if (key === f.sent) return; + f.sent = key; + if (f.timer) return; // (the latest goes out when the timer fires) + const send = () => { + f.timer = null; + if (alive.current) live.client.live(live.session, JSON.parse(f.sent)).catch(() => {}); + }; + send(); + f.timer = setTimeout(send, 120); + } + + // Whole class: a new roll from the teacher. If you were still working on the + // last one, it's gone. + async function classView(view) { + const c = lc.current; + if (view.state === 'ended') return over ? null : finishClass(view); + if (view.state !== 'playing' || view.round <= c.round) { + if (!c.round && match.phase === 'roll') startTurn(); // (no roll yet: get ready) + return; + } + if (!['wait', 'over'].includes(match.phase)) { + gen.current++; // (stop whatever the last roll was still doing) + stopClock(); + if (!c.reported) { + Live.skipUnfinished(match); + toast('⏭️ Next roll!', 'Your teacher moved on before you finished that one.', { urgent: true }); + } + } + c.round = view.round; + startTurn(); + setBusy(false); + setTimeLeft(null); + setCounting(null); + const [a, b] = view.roll; + if (view.you.done) { + // no room for this roll (the server checks), or you'd answered it already + c.reported = true; + setDice([a, b]); + if (view.you.result === 'pass') setMsg(`No room for a ${a} × ${b} anywhere. You pass this time.`); + return; + } + c.reported = false; + await rollAnimation(); + match.phase = 'roll'; + Match.roll(match, [a, b]); + rolledDice(a, b); + } + + // Pairs: bring the board and the turn up to date + async function pairView(view) { + if (over) return; + const c = lc.current; + const m = view.match; + if (!m) return; + const note = Live.syncPair(match, m, live.me, c); + if (m.over) return finishPair(view); + const myTurn = m.current === live.me; + const newTurn = m.turn !== c.round; + if (newTurn) c.round = m.turn; + if (newTurn && myTurn && ['wait', 'roll'].includes(match.phase)) { + c.note = note; + return startTurn(); + } + // your partner's move as they make it: dragging, then answering + const partner = !myTurn && m.live && m.live.by !== view.you.id ? m.live : null; + setPreview(partner && partner.rect ? partner.rect : null); + if (!myTurn && match.phase === 'wait') { + if (m.roll) { + setMsg(Live.partnerLine(opponent().name, m.roll, partner)); + if (c.rollShown !== m.turn) { + c.rollShown = m.turn; + await rollAnimation(); + setDice(m.roll); + } + } else if (newTurn || note) { + setDice([null, null]); + setMsg(`${note} ${opponent().name}'s turn…`.trim()); + } + } + redraw(); + } + + useEffect(() => { + if (!live) return; + if (live.kind === 'class') classView(live.view); + else pairView(live.view); + }, [live && live.view]); + + function finishLive() { + if (live.onOver) live.onOver(); + gen.current++; + stopClock(); + match.phase = 'over'; + match.pending = null; + setPreview(null); + setTimeLeft(null); + setView('board'); + } + + function finishClass(view) { + finishLive(); + const p = match.players[0]; + Object.assign(p, { score: view.you.score, squares: view.you.squares, bonus: view.you.bonus }); + if (!match.teacherPlayer) { + const { earned, entry } = Live.classRewards(progress, view, p, { startedAt: match.startedAt, statsName: Rewards.STATS_NAME }); + award(earned); + onSave(); + addToHistory(entry); + } + const result = Live.classResult(view); + setOver({ live: result, celebrate: result.celebrate }); + } + + function finishPair(view) { + finishLive(); + if (match.teacherPlayer) { + // (the teacher's games don't count) + } else if (view.type === 'tournament') { + Live.tournamentGamePoints(progress, view, live.me); // (the tournament's own rewards come at the end) + onSave(); + } else { + const { earned, entry } = Live.pairRewards(progress, view, live.me, match.players[live.me], { startedAt: match.startedAt, statsName: Rewards.STATS_NAME }); + award(earned); + onSave(); + addToHistory(entry); + } + const result = Live.pairResult(view, live.me, Core.emptyCount(match.board)); + setOver({ live: result, celebrate: result.won }); + } + + // ---- the end + + function finish(results) { + const rewards = Rewards.onGameOver(progress, match, results, setup.journeyLevel); + if (Rewards.countsForStats(match)) recordGame(setup.journeyLevel ? { ...match, level: setup.journeyLevel } : match, results); // (two players on one phone: just for fun) + if (rewards.boardBonus) toast(`🏁 Finished ${article(match.size)} ${match.size}×${match.size} game!`, `+${rewards.boardBonus} bonus points`); + award(rewards.earned); + // the speed-ups a new Journey board brings: on (Settings turns them back) + const switches = (rewards.journey && rewards.journey.switches) || []; + if (switches.length) { + onTurnOn(switches); + toast(...Journey.switchToast(switches)); + } + const daily = match.mode === 'single' && Daily.complete(progress, { kind: 'game', size: match.size, difficulty: match.difficulty, won: results.humanWon, level: setup.journeyLevel }); // (today's practice?) + if (daily) { + toast(...daily.toast); + award(daily.earned); + } + onSave(); + setOver({ results, journey: rewards.journey }); + setView('board'); + redraw(); + } + + useEffect(() => { + if (!live || live.kind !== 'class') startTurn(); // (once, for the match this screen was made with; a class's waits for its view) + }, []); + + // ---- the screens + + const side = (reserve) => (area ? Math.max(120, Math.min(area.width - 32, area.height - reserve, 620)) : 0); + const current = match.current; + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <Header match={match} who={who} over={over} onExit={leave} showProgress={cfg.showProgress === true || cfg.showProgress === 'true'} /> + <View style={styles.fill} onLayout={(e) => setArea(e.nativeEvent.layout)}> + {!area ? null : view === 'roll' ? ( + <View style={styles.rollScreen}> + <View style={styles.bigDice}> + <Rolling rolling={rolling}> + <Die value={dice[0]} size={Math.min(130, area.width * 0.32)} /> + </Rolling> + <Text style={[styles.bigTimes, { color: t.muted }]}>×</Text> + <Rolling rolling={rolling}> + <Die value={dice[1]} size={Math.min(130, area.width * 0.32)} /> + </Rolling> + </View> + <Text testID="turn-msg" style={[styles.rollMsg, { color: t.ink }]}> + {msg || ' '} + </Text> + {match.phase === 'roll' && mine() && cfg.autoRoll !== 'auto' ? <Button testID="roll" title="Roll the dice" onPress={humanRoll} disabled={busy} style={styles.wide} /> : null} + {rolled ? <Button testID="draw-it" title="Draw it ›" onPress={toBoard} style={styles.wide} /> : null} + </View> + ) : view === 'board' ? ( + <BoardScreen over={Boolean(over)}> + <Board + match={match} + width={side((over ? 300 : steps.length ? 214 : 150) + (goOn ? 64 : 0))} + drag={drag} + preview={preview} + hide={hide} + counting={counting} + canDraw={match.phase === 'place' && mine() && placeMode === 'draw'} + onDrag={onDrag} + onDraw={onDraw} + tapped={tapped} + /> + {over ? null : ( + <View style={styles.diceRow} accessibilityLabel={dice[0] ? `Rolled ${dice[0]} and ${dice[1]}` : undefined}> + <Die value={dice[0]} size={52} /> + <Text style={[styles.times, { color: t.muted }]}>×</Text> + <Die value={dice[1]} size={52} /> + </View> + )} + {goOn ? <Button testID="go-on" title={goOn} onPress={() => goOnPressed.current && goOnPressed.current()} style={styles.wide} /> : null} + {over && over.live ? ( + <LiveResults over={over} earned={earned} tournament={live.view.tournament ? Live.tournamentLine(live.view.tournament) : null} teacherPlayer={match.teacherPlayer} onLeave={onExit} /> + ) : over ? ( + <Results over={over} match={match} who={who} earned={earned} onAgain={onAgain} onExit={onExit} /> + ) : steps.length ? ( + <View style={[styles.steps, { backgroundColor: t.surface, borderColor: t.line }]}> + {steps.slice(-3).map((s, i, shown) => ( + <Text key={i} style={[styles.step, { color: t.ink }, i === shown.length - 1 && /^\d+ × \d+ = \d+$/.test(s) ? styles.stepBig : null]}> + {s} + </Text> + ))} + </View> + ) : ( + <Text testID="turn-msg" style={[styles.msg, { color: oops ? t.bad : t.ink }]}> + {msg} + </Text> + )} + </BoardScreen> + ) : view === 'help' ? ( + <HelpScreen area={area} rect={match.pending.rect} color={PLAYER_COLORS[current % PLAYER_COLORS.length]} counting={counting} text={feedback ? feedback.text : ''} done={helpDone} onBack={backToAnswer} /> + ) : ( + <AnswerScreen + rect={answering} + timeLeft={timeLeft} + timeTotal={setup.timerMs} + entry={entry} + feedback={feedback} + busy={busy} + streak={Match.currentPlayer(match).streak} + help={!match.pending || match.expert ? null : match.pending.helped ? 'again' : 'first'} + onKey={onKey} + onHelp={() => helpCount(null, Boolean(match.pending && match.pending.helped))} + /> + )} + </View> + {over && (over.live ? over.celebrate : over.results.winners.length === 1 && !over.results.winners[0].cpu) ? <Celebration /> : null} + </View> + ); +} + +// The board screen; at the end it scrolls (the results, with what was earned, +// can be more than a small phone shows under the board) +function BoardScreen({ over, children }) { + if (!over) return <View style={styles.boardScreen}>{children}</View>; + return ( + <ScrollView style={styles.fill} contentContainerStyle={[styles.boardScreen, styles.boardScroll]}> + {children} + </ScrollView> + ); +} + +// "Help me count": the rectangle as big as it fits, its rows lighting up +function HelpScreen({ area, rect, color, counting, text, done, onBack }) { + const t = useTheme(); + const below = 120 + 72; // the words and the button + return ( + <View style={styles.helpScreen}> + <RectPicture rect={rect} color={color} counting={counting} maxWidth={area.width - 32} maxHeight={Math.max(80, area.height - below - 32)} /> + <Text testID="help-text" style={[styles.helpText, { color: t.ink }]}> + {text} + </Text> + <Button testID="help-done" title="I know it! ›" onPress={onBack} disabled={!done} style={styles.wide} /> + </View> + ); +} + +// Scores, and ✕ back to the menu; with Settings › Board progress, how full the board is +function Header({ match, who, over, onExit, showProgress }) { + const t = useTheme(); + const PLAYER_COLORS = usePlayerColors(); // (the Wardrobe's colours) + const total = match.size * match.size; + const filled = total - Core.emptyCount(match.board); + return ( + <View> + <View style={styles.header}> + <Pressable onPress={onExit} accessibilityRole="button" accessibilityLabel="Main menu" hitSlop={10} style={[styles.close, { borderColor: t.line, backgroundColor: t.surface }]}> + <Text style={[styles.closeText, { color: t.ink }]}>✕</Text> + </Pressable> + {match.players.map((p, i) => ( + <View key={i} style={[styles.score, { borderColor: i === match.current && !over ? PLAYER_COLORS[i] : t.line, backgroundColor: t.surface }]}> + <View style={[styles.swatch, { backgroundColor: PLAYER_COLORS[i] }]} /> + <View style={styles.scoreWho}> + <Text style={[styles.scoreName, { color: t.ink }]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8} accessibilityLabel={who(p)}> + {p.avatar ? `${p.avatar} ` : ''} + {match.players.length > 2 ? who(p).replace(/^Player /, 'P') : who(p)} + </Text> + {p.title ? ( + <Text testID="player-title" style={[styles.scoreTitle, { color: t.muted }]} numberOfLines={1}> + {p.title} + </Text> + ) : null} + </View> + <Text style={[styles.scorePts, { color: t.ink }]}>{p.score}</Text> + </View> + ))} + </View> + {showProgress ? ( + <View style={styles.progress} accessibilityLabel={`Board: ${filled} of ${total} filled`}> + <Text testID="board-progress" style={[styles.progressText, { color: t.muted }]}> + Board: {filled} of {total} filled + </Text> + <View style={[styles.progressBar, { backgroundColor: t.soft }]}> + <View style={{ width: `${(filled / total) * 100}%`, height: '100%', backgroundColor: PLAYER_COLORS[match.current % PLAYER_COLORS.length], borderRadius: 4 }} /> + </View> + </View> + ) : null} + </View> + ); +} + +// The question and the keypad +// help: 'first' ("Help me count"), 'again' ("Count again") or null (Expert) +// timeLeft / timeTotal (ms): the answer timer, when there is one +function AnswerScreen({ rect, entry, feedback, busy, help, onKey, onHelp, streak = 0, timeLeft = null, timeTotal = 0 }) { + const t = useTheme(); + if (!rect) return null; + const low = timeLeft !== null && timeLeft <= 5000; + return ( + <View style={styles.answerScreen}> + {timeLeft !== null && timeTotal ? ( + <View style={styles.timer} accessibilityLabel={`${Math.ceil(timeLeft / 1000)} seconds left`}> + <Text testID="timer" style={[styles.timerText, { color: low ? t.bad : t.ink }]}> + ⏱️ {Math.ceil(timeLeft / 1000)} s + </Text> + <View style={[styles.timerBar, { backgroundColor: t.soft }]}> + <View style={{ width: `${(timeLeft / timeTotal) * 100}%`, height: '100%', backgroundColor: low ? t.bad : t.good, borderRadius: 5 }} /> + </View> + </View> + ) : null} + <Streak n={streak} color={t.goldInk} /> + <View style={styles.questionRow}> + <Text style={[styles.question, { color: t.ink }]}> + {rect.a} × {rect.b} = + </Text> + <View testID="answer-box" style={[styles.answerBox, { borderColor: feedback && feedback.kind === 'good' ? t.good : t.edge, backgroundColor: t.surface }]}> + <Text style={[styles.answerText, { color: t.ink }]}>{entry || ' '}</Text> + </View> + </View> + <Text testID="feedback" numberOfLines={2} style={[styles.feedback, { color: !feedback ? t.muted : feedback.kind === 'good' ? t.goodInk : feedback.kind === 'try' ? t.bad : t.ink }]}> + {feedback ? feedback.text : 'How many squares did you just draw?'} + </Text> + <Keypad onKey={onKey} disabled={busy} /> + {help ? <Button testID="help" kind="secondary" title={help === 'again' ? 'Count again' : 'Help me count'} onPress={onHelp} disabled={busy} /> : null} + </View> + ); +} + +function Results({ over, match, who, earned, onAgain, onExit }) { + const t = useTheme(); + const PLAYER_COLORS = usePlayerColors(); // (the Wardrobe's colours) + const { results } = over; + const winner = results.winners[0]; + const title = results.tie ? "It's a tie!" : match.mode === 'single' ? (winner.cpu ? `${winner.name} wins!` : 'You win! 🎉') : `${winner.name} wins! 🎉`; + const top = results.ranked[0].score; + // a Journey game: moved up a level, or what it still takes + const journey = over.journey ? Journey.resultMessage(over.journey) : null; + const up = Boolean(over.journey && over.journey.advanced); + return ( + <View testID="results" style={[styles.results, { backgroundColor: t.paper, borderColor: t.line }]}> + <Text style={[styles.resultTitle, { color: t.ink }]}>{title}</Text> + <Text style={[styles.resultSub, { color: t.muted }]}>{results.empty === 0 ? 'The board is completely full!' : `Nobody could fit a rectangle. ${results.empty} squares left empty.`}</Text> + {results.ranked.map((p) => ( + <View key={p.index} style={styles.resultRow}> + <View style={styles.resultLine}> + <Text style={[styles.resultName, { color: t.ink }]}>{who(p)}</Text> + <Text style={[styles.resultName, { color: t.ink }]}>{p.score} pts</Text> + </View> + <View style={[styles.bar, { backgroundColor: t.soft }]}> + <View style={{ width: `${top ? (p.score / top) * 100 : 0}%`, height: '100%', backgroundColor: PLAYER_COLORS[p.index], borderRadius: 6 }} /> + </View> + </View> + ))} + <Earned notes={earned} /> + {journey ? ( + <View testID="journey-result" style={[styles.journeyResult, { borderColor: up ? t.good : t.line, backgroundColor: up ? t.goodBg : t.soft }]}> + <Text style={[styles.resultSub, { color: up ? t.goodInk : t.ink, fontWeight: '800' }]}>{journey.title}</Text> + <Text style={[styles.resultSub, { color: up ? t.goodInk : t.ink }]}>{journey.text}</Text> + </View> + ) : null} + {match.mode === 'multi' ? <Text style={[styles.resultSub, { color: t.muted }]}>Multiplayer games are just for fun: they don’t count towards Stats or achievements.</Text> : null} + <View style={styles.resultActions}> + <Button title={up && over.journey.to < Journey.DONE ? `Level ${over.journey.to} ›` : 'Play Again'} onPress={onAgain} style={styles.flex} testID="again" /> + <Button kind="secondary" title="Menu" onPress={onExit} style={styles.flex} /> + </View> + </View> + ); +} + +// A live class's results: the server's (Live.classResult / pairResult). The +// next game is the teacher's to start, so it's stay, or leave the class. +// tournament: where you stand now (it changes as the other games finish) +function LiveResults({ over, earned, tournament, teacherPlayer, onLeave }) { + const t = useTheme(); + const PLAYER_COLORS = usePlayerColors(); + const r = over.live; + const top = Math.max(1, ...r.rows.filter((x) => !x.gap).map((x) => x.score)); + return ( + <View testID="results" style={[styles.results, { backgroundColor: t.paper, borderColor: t.line }]}> + <Text testID="result-title" style={[styles.resultTitle, { color: t.ink }]}> + {r.title} + </Text> + <Text style={[styles.resultSub, { color: t.muted }]}>{r.sub}</Text> + {r.rows.map((p, i) => + p.gap ? ( + <Text key={i} style={[styles.resultSub, { color: t.muted }]}> + ⋯ + </Text> + ) : ( + <View key={i} style={styles.resultRow}> + <View style={styles.resultLine}> + <Text style={[styles.resultName, { color: t.ink, fontWeight: p.you ? '900' : '700' }]} numberOfLines={1}> + {p.rank ? `${Stats.ordinal(p.rank)} ` : ''} + {p.avatar ? `${p.avatar} ` : ''} + {p.name} + </Text> + <Text style={[styles.resultName, { color: t.ink }]}>{p.score} pts</Text> + </View> + <View style={[styles.bar, { backgroundColor: t.soft }]}> + <View style={{ width: `${(p.score / top) * 100}%`, height: '100%', backgroundColor: p.you ? PLAYER_COLORS[p.index || 0] : PLAYER_COLORS[p.index === undefined ? 1 : p.index] + '99', borderRadius: 6 }} /> + </View> + </View> + ) + )} + {tournament ? ( + <Text testID="result-tournament" style={[styles.resultSub, { color: t.ink }]}> + {tournament} + </Text> + ) : null} + <Earned notes={earned} /> + <Text style={[styles.resultSub, { color: t.muted }]}>{teacherPlayer ? 'Thanks for playing! Teacher games don’t count towards stats.' : 'Stay on this screen: your teacher can start another game.'}</Text> + <View style={styles.resultActions}> + <Button kind="secondary" title="Leave class" onPress={onLeave} style={styles.flex} testID="leave-class" /> + </View> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingVertical: 8 }, + close: { width: 40, height: 40, borderRadius: 20, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + closeText: { fontSize: 18, fontWeight: '800' }, + progress: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 16, paddingBottom: 6 }, + progressText: { fontSize: 13, fontWeight: '600' }, + progressBar: { flex: 1, height: 8, borderRadius: 4, overflow: 'hidden' }, + score: { flex: 1, flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 6, paddingHorizontal: 8, borderRadius: 12, borderWidth: 3 }, + swatch: { width: 12, height: 12, borderRadius: 6 }, + scoreWho: { flex: 1 }, + scoreName: { fontSize: 15, fontWeight: '600' }, + scoreTitle: { fontSize: 11, fontWeight: '700' }, + scorePts: { fontSize: 18, fontWeight: '800' }, + diceRow: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + times: { fontSize: 24, fontWeight: '800' }, + rollScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 20, padding: 16 }, + bigDice: { flexDirection: 'row', alignItems: 'center', gap: 16 }, + bigTimes: { fontSize: 40, fontWeight: '800' }, + rollMsg: { fontSize: 24, fontWeight: '700', textAlign: 'center', lineHeight: 32 }, + wide: { alignSelf: 'stretch' }, + boardScreen: { flex: 1, alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingTop: 4 }, + boardScroll: { flexGrow: 1, flex: 0, paddingBottom: 16 }, + msg: { fontSize: 18, textAlign: 'center', lineHeight: 24 }, + steps: { alignSelf: 'stretch', padding: 12, borderRadius: 12, borderWidth: 2, gap: 4 }, + step: { fontSize: 16, lineHeight: 22 }, + stepBig: { fontSize: 22, fontWeight: '800' }, + timer: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + timerText: { fontSize: 18, fontWeight: '800', minWidth: 70 }, + timerBar: { flex: 1, height: 10, borderRadius: 5, overflow: 'hidden' }, + answerScreen: { flex: 1, justifyContent: 'center', gap: 14, paddingHorizontal: 16, paddingBottom: 16 }, + questionRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12 }, + question: { fontSize: 44, fontWeight: '800' }, + answerBox: { minWidth: 130, borderWidth: 3, borderRadius: 14, paddingVertical: 6, alignItems: 'center' }, + answerText: { fontSize: 44, fontWeight: '800' }, + helpScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 20, paddingHorizontal: 16, paddingBottom: 16 }, + helpText: { fontSize: 22, fontWeight: '700', textAlign: 'center', lineHeight: 30, minHeight: 60 }, + feedback: { fontSize: 17, textAlign: 'center', fontWeight: '600', lineHeight: 23, minHeight: 46 }, + results: { alignSelf: 'stretch', padding: 14, borderRadius: 16, borderWidth: 2, gap: 8 }, + resultTitle: { fontSize: 26, fontWeight: '800', textAlign: 'center' }, + resultSub: { fontSize: 15, textAlign: 'center' }, + journeyResult: { gap: 2, padding: 10, borderWidth: 2, borderRadius: 10 }, + resultRow: { gap: 4 }, + resultLine: { flexDirection: 'row', justifyContent: 'space-between' }, + resultName: { fontSize: 17, fontWeight: '700' }, + bar: { height: 10, borderRadius: 5, overflow: 'hidden' }, + resultActions: { flexDirection: 'row', gap: 12, marginTop: 8 }, +}); diff --git a/apps/mobile/src/Home.js b/apps/mobile/src/Home.js new file mode 100644 index 0000000..3223484 --- /dev/null +++ b/apps/mobile/src/Home.js @@ -0,0 +1,393 @@ +// The menu, on one screen: points and who's signed in, what to play +// (mode, then the Journey's level, or difficulty and board in Free play, each +// one row of choices) and Play at the bottom. +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Progress from '@blockout/progress'; +import * as Shop from '@blockout/progress/shop'; +import * as Setup from '@blockout/progress/setup'; +import * as Practice from '@blockout/progress/practice'; +import * as Journey from '@blockout/progress/journey'; +import { BOARDS, DIFFICULTIES, PLAYER_COUNTS, boardOpen, difficultyOpen, itemFor, modeOpen, playersOpen } from './rewards'; +import { useTheme } from './theme'; +import { ROLE } from './AccountScreen'; +import { Button } from './ui'; + +const MODES = [ + { id: 'single', label: 'You vs CPU' }, + { id: 'multi', label: 'Friends' }, + { id: 'practice', label: 'Practice' }, +]; +const LEVELS = [ + { id: 'learn', label: 'Learn' }, + { id: 'expert', label: 'Expert ×2' }, +]; + +// onLocked(itemId): a locked choice was tapped (offer it); onShop(): open the Shop +export default function Home({ progress, user, choice, onChoose, onPlay, onClass, liveClasses = [], onSignIn, onAccount, onHomework, onClasses, onKids, onAdmin, homeworkCount = 0, signingIn, accountError, onLocked, onShop, onWardrobe, onAchievements, onStats, onStickers, onSettings }) { + const t = useTheme(); + const newUnlocks = Shop.affordableCount(progress); + const stickerNews = Shop.newStickers(progress) + Shop.spareStickers(progress); + const wardrobeNews = Shop.newWardrobe(progress) + Shop.affordableLooks(progress); + // single player's Journey: the level picks the board and dice (and so the + // difficulty its Expert and timers go by) + const journeyLevel = choice.mode === 'single' && Journey.onJourney(progress, choice.play) ? Journey.journeyLevel(progress) : null; + const playSwitch = choice.mode === 'single' && Journey.freePlayOpen(progress) && !Journey.isDone(progress); + // Practice's Journey: the level picks the times table + const practiceLevel = choice.mode === 'practice' && Journey.onPracticeJourney(progress, choice.practicePlay) ? Journey.practiceLevel(progress) : null; + const practiceSwitch = choice.mode === 'practice' && Journey.autoPracticeOpen(progress) && !Journey.practiceDone(progress); + // (Practice on Auto goes by its biggest reached table's difficulty) + const d = journeyLevel ? Journey.levelSetup(journeyLevel).difficulty : practiceLevel ? Journey.practiceTier(practiceLevel) : choice.mode === 'practice' ? Practice.autoDifficulty(progress) : choice.difficulty; + const level = Setup.gameLevel(progress, d, choice); // (what's unlocked on this difficulty) + const practicing = choice.mode === 'practice'; + const pr = Practice.practiceChoice(progress, d, choice.practice || {}); + const warning = practicing || journeyLevel ? null : Setup.boardWarning(d, choice.size); + const setPractice = (part) => onChoose({ practice: { ...pr, ...part } }); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScrollView contentContainerStyle={styles.page} bounces={false}> + <View style={styles.top}> + <Text style={[styles.title, { color: t.ink }]}>Blockout</Text> + <View style={styles.topRight}> + <Text testID="wallet" style={[styles.wallet, { color: t.goldInk, backgroundColor: t.goldBg }]}> + 🪙 {Shop.formatPoints(progress.wallet)} + </Text> + <Pressable testID="shop" accessibilityRole="button" accessibilityLabel={`Shop${newUnlocks ? `, ${newUnlocks} to unlock` : ''}`} onPress={onShop} style={({ pressed }) => [styles.shopBtn, { backgroundColor: t.soft, borderColor: t.line, transform: [{ scale: pressed ? 0.96 : 1 }] }]}> + <Text style={[styles.smallText, { color: t.ink }]}>🛒 Shop</Text> + {newUnlocks ? ( + <View style={[styles.badge, { backgroundColor: t.bad }]}> + <Text style={styles.badgeText}>{newUnlocks}</Text> + </View> + ) : null} + </Pressable> + <Pressable testID="settings" accessibilityRole="button" accessibilityLabel="Settings" onPress={onSettings} hitSlop={6} style={({ pressed }) => [styles.gear, { backgroundColor: t.soft, borderColor: t.line, transform: [{ scale: pressed ? 0.94 : 1 }] }]}> + <Text style={styles.gearText}>⚙️</Text> + </Pressable> + </View> + </View> + + <View style={[styles.account, { backgroundColor: t.paper, borderColor: t.line }]}> + {user ? ( + <> + <Text testID="signed-in" style={[styles.accountText, { color: t.ink }]} numberOfLines={1}> + {(ROLE[user.role] || ROLE.student).emoji} <Text style={styles.bold}>{user.name || user.firstName || 'You'}</Text> + </Text> + {user.role === 'student' ? <SmallButton testID="homework" title="📚 Homework" badge={homeworkCount} onPress={onHomework} /> : null} + {user.role === 'teacher' ? <SmallButton testID="my-classes" title="🏫 My classes" onPress={onClasses} /> : null} + {user.role === 'parent' ? <SmallButton testID="my-kids" title="👪 My kids" onPress={onKids} /> : null} + {user.isAdmin ? <SmallButton testID="admin" title="🛠" onPress={onAdmin} /> : null} + <SmallButton testID="account" title="Account" onPress={onAccount} /> + </> + ) : ( + <> + <Text style={[styles.accountText, { color: t.muted }]} numberOfLines={1} accessibilityLabel="Playing as a guest. Sign in to keep your points on every device."> + 👤 Guest + </Text> + <SmallButton testID="homework" title="📚 Homework" badge={homeworkCount} onPress={onHomework} /> + <SmallButton testID="sign-in" title={signingIn ? 'Signing in…' : 'Sign in'} onPress={onSignIn} disabled={signingIn} /> + </> + )} + </View> + {accountError ? <Text style={[styles.error, { color: t.bad }]}>{accountError}</Text> : null} + + <View style={styles.more}> + <MenuButton testID="wardrobe" label="👕 Looks" badge={wardrobeNews} onPress={onWardrobe} /> + <MenuButton testID="stats" label="📊 Stats" onPress={onStats} /> + <MenuButton testID="achievements" label="🏆 Awards" onPress={onAchievements} /> + <MenuButton testID="stickers" label="🎁 Stickers" badge={stickerNews} onPress={onStickers} /> + </View> + + <Section title="Play"> + <Segments items={MODES.map((x) => ({ id: x.id, label: x.label, open: modeOpen(progress, x.id) }))} value={choice.mode} onPick={(mode) => onChoose({ mode })} onLocked={(mode) => onLocked(itemFor.mode(mode))} /> + {playSwitch || practiceSwitch ? ( + <Segments + compact + prefix="play-" + items={[ + { id: 'journey', label: '🧗 Journey', open: true }, + { id: 'free', label: practicing ? '✨ Auto' : '🎲 Free play', open: true }, + ]} + value={journeyLevel || practiceLevel ? 'journey' : 'free'} + onPick={(play) => onChoose(practicing ? { practicePlay: play } : { play })} + /> + ) : null} + {choice.mode === 'single' && (journeyLevel || Journey.isDone(progress)) ? <JourneyCard progress={progress} /> : null} + {practicing && (practiceLevel || Journey.practiceDone(progress)) ? <JourneyCard progress={progress} practice /> : null} + {/* (on the Journey the round sets Learn or Expert, the timer and the length) */} + {practicing && practiceLevel ? null : practicing ? ( + <> + <Segments + compact + items={LEVELS.map((l) => ({ id: l.id, label: l.label, open: isOpen(progress, Practice.levelItem(d, l.id)) }))} + value={pr.level} + onPick={(level) => setPractice({ level })} + onLocked={(lv) => onLocked(Practice.levelItem(d, lv))} + /> + {pr.level === 'expert' ? ( + <Segments + compact + items={Practice.PRACTICE_TIMERS.map((secs) => ({ id: secs, label: secs ? `⏱️ ${secs} s` : 'No timer', open: isOpen(progress, Practice.timerItem(d, secs)) }))} + prefix="t" + value={pr.timer} + onPick={(timer) => setPractice({ timer })} + onLocked={(secs) => onLocked(Practice.timerItem(d, secs))} + /> + ) : null} + <Segments + compact + items={Practice.PRACTICE_COUNTS.map((n) => ({ id: n, label: `${n}`, sub: 'questions', open: isOpen(progress, Practice.countItem(d, n)) }))} + prefix="q" + value={pr.count} + onPick={(count) => setPractice({ count })} + onLocked={(n) => onLocked(Practice.countItem(d, n))} + /> + </> + ) : choice.mode === 'single' ? ( + <Segments + compact + items={LEVELS.map((l) => ({ id: l.id, label: l.label, open: isOpen(progress, Setup.levelItem(d, l.id)) }))} + value={level.level} + onPick={(lv) => onChoose({ level: lv })} + onLocked={(lv) => onLocked(Setup.levelItem(d, lv))} + /> + ) : ( + <Segments + compact + items={PLAYER_COUNTS.map((n) => ({ id: n, label: `${n} players`, open: playersOpen(progress, n) }))} + value={choice.players || 2} + onPick={(players) => onChoose({ players })} + onLocked={(n) => onLocked(itemFor.players(n))} + /> + )} + {choice.mode === 'single' && level.level === 'expert' ? ( + <Segments + compact + items={Setup.GAME_TIMERS.map((secs) => ({ id: secs, label: secs ? `⏱️ ${secs} s` : 'No timer', open: !secs || isOpen(progress, Setup.timerItem(d, secs)) }))} + value={level.timer} + onPick={(timer) => onChoose({ timer })} + onLocked={(secs) => onLocked(Setup.timerItem(d, secs))} + /> + ) : null} + </Section> + + {/* (practice has no difficulty: the Journey's table or Auto picks the facts) */} + {journeyLevel || practicing ? null : ( + <Section title="Difficulty"> + {/* (only what the Journey has reached) */} + <Segments + columns={3} + compact={practicing} + items={DIFFICULTIES.filter((x) => difficultyOpen(progress, x.id)).map((x) => ({ id: x.id, label: x.name, sub: practicing ? null : x.dice.replace(' dice', ''), open: true }))} + value={choice.difficulty} + onPick={(difficulty) => onChoose({ difficulty })} + /> + </Section> + )} + + {practicing ? ( + practiceLevel ? null : ( + // Auto: what it will ask (the facts you need, from every table you've reached) + <Text testID="practice-preview" style={[styles.preview, { color: t.muted }]}> + {Practice.autoPreview(progress)} + </Text> + ) + ) : journeyLevel ? null : ( + <Section title="Board"> + <Segments compact items={BOARDS.filter((n) => boardOpen(progress, n)).map((n) => ({ id: n, label: `${n}`, open: true }))} value={choice.size} onPick={(size) => onChoose({ size })} /> + {warning ? <Text testID="board-warning" style={[styles.warning, { color: t.bad }]}>{warning}</Text> : null} + </Section> + )} + </ScrollView> + <View style={styles.bottom}> + {/* a tournament your teacher scheduled, open now */} + {liveClasses.map((l) => ( + <View key={l.code} testID="live-banner" style={[styles.live, { backgroundColor: t.goldBg, borderColor: t.gold }]}> + <Text style={[styles.liveText, { color: t.ink }]} numberOfLines={2}> + 🏆 {l.title} is open! + </Text> + <SmallButton testID="live-join" title="Join" onPress={() => onClass(l.code)} /> + </View> + ))} + <View style={styles.playRow}> + <Button testID="class" kind="secondary" title="🏫 Class" onPress={() => onClass('')} style={styles.classBtn} /> + <Button testID="play" title={practiceLevel ? `▶ Practice ×${Journey.practiceSetup(practiceLevel).table}` : practicing ? 'Practice' : journeyLevel ? `▶ Play Level ${journeyLevel}` : 'Play'} onPress={onPlay} disabled={Boolean(warning)} style={styles.flex} /> + </View> + </View> + </View> + ); +} + +// The level you're on, what it takes to move up, and the path so far +// practice: Practice's Journey (a times table a level) rather than the game's +function JourneyCard({ progress, practice = false }) { + const t = useTheme(); + const level = practice ? Journey.practiceLevel(progress) : Journey.journeyLevel(progress); + const done = practice ? Journey.practiceDone(progress) : Journey.isDone(progress); + const steps = practice ? Journey.practiceSteps(progress) : Journey.steps(progress); + const line = practice + ? done ? 'You finished every times table. Auto keeps you sharp!' : Journey.practiceLine(level) + : done ? 'You finished every level. Pick any board and dice in Free play!' : Journey.levelLine(level); + return ( + <View testID="journey-card" style={[styles.journey, { backgroundColor: t.accentSoft, borderColor: t.accent }]}> + <Text testID="journey-level" style={[styles.journeyLevel, { color: t.ink }]}> + {practice ? Journey.practiceName(level) : Journey.levelName(level)} + </Text> + <Text style={[styles.journeyLine, { color: t.ink }]}>{line}</Text> + {done ? null : <Text style={[styles.preview, { color: t.muted, textAlign: 'center' }]}>🏁 {practice ? Journey.practiceGoalLine() : Journey.goalLine(level)} to move up.</Text>} + <View style={styles.steps} accessibilityLabel={`Level ${level} of ${steps.length}`}> + {steps.map((s) => ( + <View key={s.level} style={[styles.step, { borderColor: s.done ? t.good : s.current ? t.accent : t.line, backgroundColor: s.done ? t.good : s.current ? t.accentButton : t.surface }]}> + <Text style={[styles.stepText, { color: s.done || s.current ? t.accentInk : t.muted }]}>{s.label || s.level}</Text> + </View> + ))} + </View> + </View> + ); +} + +function Section({ title, children }) { + const t = useTheme(); + return ( + <View style={styles.section}> + <Text style={[styles.sectionTitle, { color: t.muted }]}>{title}</Text> + {children} + </View> + ); +} + +const isOpen = (progress, itemId) => !itemId || Progress.isUnlocked(progress, itemId); + +// One row of equal choices (or rows of `columns`); a locked one shows 🔒, and +// tapping it offers it (onLocked). compact: shorter, for the second rows. +// (testIDs are choice-<prefix><id>, so two rows can share an id like 10) +function Segments({ items, value, onPick, onLocked, columns = 0, compact = false, prefix = '' }) { + const t = useTheme(); + return ( + <View style={[styles.segments, columns ? styles.wrap : null]} accessibilityRole="radiogroup"> + {items.map((it) => { + const on = it.id === value; + return ( + <Pressable + key={it.id} + testID={`choice-${prefix}${it.id}`} + accessibilityRole="radio" + accessibilityLabel={`${it.label}${it.sub ? ` ${it.sub}` : ''}${it.open ? '' : ', locked'}`} + accessibilityState={{ selected: on }} + onPress={() => (it.open ? onPick(it.id) : onLocked(it.id))} + style={({ pressed }) => [ + styles.segment, + compact ? styles.compact : null, + columns ? { flexBasis: `${100 / columns - 3}%`, flexGrow: 1 } : null, + { backgroundColor: on ? t.accentSoft : t.surface, borderColor: on ? t.accent : t.line, opacity: it.open ? 1 : 0.5, transform: [{ scale: pressed ? 0.97 : 1 }] }, + ]} + > + <Text style={[styles.segmentText, { color: t.ink }]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.7}> + {it.open ? '' : '🔒 '} + {it.label} + </Text> + {it.sub ? ( + <Text style={[styles.segmentSub, { color: t.muted }]} numberOfLines={1}> + {it.sub} + </Text> + ) : null} + </Pressable> + ); + })} + </View> + ); +} + +// One of the menu's row of extras, with a count badge when there's something new +function MenuButton({ label, badge = 0, onPress, testID }) { + const t = useTheme(); + return ( + <Pressable + testID={testID} + accessibilityRole="button" + accessibilityLabel={`${label.replace(/^\S+ /, '')}${badge ? `, ${badge} new` : ''}`} + onPress={onPress} + style={({ pressed }) => [styles.menuBtn, { backgroundColor: t.surface, borderColor: t.line, transform: [{ scale: pressed ? 0.97 : 1 }] }]} + > + {/* the icon over its word, so four fit side by side on a small phone */} + <Text style={styles.menuIcon}>{label.split(' ')[0]}</Text> + <Text style={[styles.menuText, { color: t.ink }]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}> + {label.replace(/^\S+ /, '')} + </Text> + {badge ? ( + <View style={[styles.badge, styles.cornerBadge, { backgroundColor: t.bad }]}> + <Text style={styles.badgeText}>{badge}</Text> + </View> + ) : null} + </Pressable> + ); +} + +function SmallButton({ title, onPress, disabled, testID, badge = 0 }) { + const t = useTheme(); + return ( + <Pressable + testID={testID} + accessibilityRole="button" + disabled={disabled} + onPress={onPress} + style={({ pressed }) => [styles.small, { backgroundColor: t.soft, borderColor: t.line, opacity: disabled ? 0.5 : 1, transform: [{ scale: pressed ? 0.97 : 1 }] }]} + > + <Text style={[styles.smallText, { color: t.ink }]}>{title}</Text> + {badge ? ( + <View style={[styles.badge, styles.cornerBadge, { backgroundColor: t.bad }]}> + <Text style={styles.badgeText}>{badge}</Text> + </View> + ) : null} + </Pressable> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1 }, + playRow: { flexDirection: 'row', gap: 10 }, + classBtn: { minWidth: 110 }, + live: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 10, borderRadius: 12, borderWidth: 2 }, + liveText: { flex: 1, fontSize: 15, fontWeight: '800' }, + page: { flexGrow: 1, padding: 16, paddingTop: 10, paddingBottom: 8, gap: 10 }, + top: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, + title: { fontSize: 28, fontWeight: '900' }, + gear: { width: 38, height: 38, borderRadius: 19, borderWidth: StyleSheet.hairlineWidth * 2, alignItems: 'center', justifyContent: 'center' }, + gearText: { fontSize: 18 }, + topRight: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + wallet: { fontSize: 17, fontWeight: '800', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 999, overflow: 'hidden' }, + shopBtn: { flexDirection: 'row', alignItems: 'center', gap: 6, paddingVertical: 7, paddingHorizontal: 12, borderRadius: 10, borderWidth: StyleSheet.hairlineWidth * 2 }, + badge: { minWidth: 20, height: 20, borderRadius: 10, paddingHorizontal: 5, alignItems: 'center', justifyContent: 'center' }, + badgeText: { color: '#fff', fontSize: 12, fontWeight: '800' }, + // (on the corner, like an app's badge, so the label keeps its room) + cornerBadge: { position: 'absolute', top: -7, right: -5 }, + account: { flexDirection: 'row', alignItems: 'center', gap: 8, paddingHorizontal: 12, paddingVertical: 8, borderRadius: 14, borderWidth: StyleSheet.hairlineWidth * 2 }, + accountText: { flex: 1, fontSize: 15 }, + bold: { fontWeight: '700' }, + error: { fontSize: 14, textAlign: 'center' }, + section: { gap: 6 }, + sectionTitle: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, + segments: { flexDirection: 'row', gap: 8 }, + wrap: { flexWrap: 'wrap' }, + compact: { minHeight: 36, paddingVertical: 2 }, + warning: { fontSize: 14, lineHeight: 19, fontWeight: '600' }, + chips: { flexDirection: 'row', gap: 6 }, + chip: { minWidth: 46, height: 36, paddingHorizontal: 8, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + chipText: { fontSize: 15, fontWeight: '800' }, + preview: { fontSize: 13, lineHeight: 18 }, + segment: { flex: 1, minHeight: 48, paddingVertical: 6, paddingHorizontal: 4, borderRadius: 12, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + segmentText: { fontSize: 16, fontWeight: '700' }, + segmentSub: { fontSize: 12, marginTop: 2 }, + journey: { alignItems: 'center', gap: 4, padding: 12, borderRadius: 12, borderWidth: 2 }, + journeyLevel: { fontSize: 24, fontWeight: '900' }, + journeyLine: { fontSize: 16, fontWeight: '700' }, + steps: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'center', gap: 4, marginTop: 4 }, + step: { minWidth: 22, height: 22, paddingHorizontal: 3, borderRadius: 11, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + stepText: { fontSize: 10, fontWeight: '800' }, + bottom: { padding: 16, paddingTop: 8 }, + more: { flexDirection: 'row', gap: 8 }, + menuBtn: { flex: 1, alignItems: 'center', justifyContent: 'center', height: 52, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth * 2 }, + menuIcon: { fontSize: 18, lineHeight: 22 }, + menuText: { fontSize: 13, fontWeight: '700' }, + small: { paddingVertical: 8, paddingHorizontal: 14, borderRadius: 10, borderWidth: StyleSheet.hairlineWidth * 2 }, + smallText: { fontSize: 15, fontWeight: '700' }, +}); diff --git a/apps/mobile/src/HomeworkScreen.js b/apps/mobile/src/HomeworkScreen.js new file mode 100644 index 0000000..a5c4532 --- /dev/null +++ b/apps/mobile/src/HomeworkScreen.js @@ -0,0 +1,115 @@ +// Homework, as on the web game: today's practice first (everyone, guests +// too: @blockout/progress/daily: three cards to pick from, and new ones for +// points), then a student's homework +// (@blockout/progress/homework), late first, each with how far along it is +// and who set it. Play › sets the menu up for it and starts. +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Homework from '@blockout/progress/homework'; +import * as Daily from '@blockout/progress/daily'; +import * as Account from './account'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +// student: signed in as one (else only today's practice); onPlay(goal); +// onDaily(i): today's practice, its card i; onReroll(): new cards (true when +// dealt); onCount(n): homework still to do (the menu's badge) +export default function HomeworkScreen({ progress, student, onPlay, onDaily, onReroll, onCount, onClose }) { + const t = useTheme(); + const [items, setItems] = useState(null); + const [error, setError] = useState(null); + const [, setDealt] = useState(0); // (new cards: draw them) + const daily = Daily.card(progress); + useEffect(() => { + if (!student) return; + Account.api('GET', '/api/me/homework') + .then(({ homework }) => { + setItems(homework); + onCount(Homework.openCount(homework)); + }) + .catch((e) => setError(e.message)); + }, []); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Homework" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page}> + <Text style={[styles.heading, { color: t.muted }]}>⭐ Today’s practice</Text> + <View testID="daily" style={styles.top}> + <Text style={[styles.label, { color: t.ink }]}> + {daily.title} {daily.reward ? <Text style={{ color: t.goldInk }}>{daily.reward}</Text> : null} + </Text> + {daily.streak ? <Text style={[styles.state, { color: t.goldInk }]}>{daily.streak}</Text> : null} + {daily.done ? ( + <Text testID="daily-text" style={[styles.task, { color: t.muted }]}> + {daily.doneText} + </Text> + ) : null} + </View> + {/* three cards to pick from, like a level-up's: tapping one plays it */} + {daily.cards.map((card, i) => ( + <Pressable + key={i} + testID="daily-card" + accessibilityRole="button" + accessibilityLabel={`Play: ${card.text}. ${card.why}`} + onPress={() => onDaily(i)} + style={({ pressed }) => [styles.row, styles.daily, { backgroundColor: t.goldBg, borderColor: t.gold, transform: [{ scale: pressed ? 0.97 : 1 }] }]} + > + <Text style={styles.icon}>{card.icon}</Text> + <View style={[styles.top, styles.flex]}> + <Text style={[styles.task, { color: t.ink }]}>{card.text}</Text> + <Text style={[styles.state, { color: t.muted }]}>{card.why}</Text> + </View> + <Text style={[styles.go, { color: t.goldInk }]}>Play ›</Text> + </Pressable> + ))} + {daily.done ? null : <Button testID="daily-reroll" kind="secondary" title={daily.reroll.label} disabled={!daily.reroll.can} onPress={() => onReroll() && setDealt((n) => n + 1)} />} + <Text style={[styles.heading, { color: t.muted }]}>From your teacher</Text> + {!student ? <Text style={[styles.help, { color: t.muted }]}>Sign in with your school account to see homework from your teacher or a grown-up.</Text> : null} + {!student ? null : error ? <Text style={[styles.help, { color: t.muted }]}>{error}</Text> : !items ? <ActivityIndicator color={t.accent} /> : null} + {items ? ( + <Text testID="homework-intro" style={[styles.help, { color: t.muted }]}> + {Homework.homeworkIntro(items)} + </Text> + ) : null} + {(items ? Homework.homeworkRows(items) : []).map((row, i) => ( + <View key={row.h.id || i} testID="homework-row" style={[styles.row, { backgroundColor: t.surface, borderColor: row.h.status === 'late' ? t.bad : t.line, opacity: row.playable ? 1 : 0.7 }]}> + <View style={styles.top}> + <Text style={[styles.label, { color: t.ink }]}>{row.label}</Text> + <Text style={[styles.state, { color: row.h.status === 'late' ? t.bad : row.h.status === 'done' ? t.goodInk : t.muted }]}>{row.state}</Text> + </View> + <View style={[styles.bar, { backgroundColor: t.soft }]}> + <View style={{ width: `${(row.have / row.need) * 100}%`, height: '100%', backgroundColor: t.good, borderRadius: 5 }} /> + </View> + <View style={styles.meta}> + <Text style={[styles.metaText, { color: t.muted }]}> + {row.have} of {row.need} · {row.from} + </Text> + {row.playable ? <Button testID="homework-play" title="Play ›" onPress={() => onPlay(row.h.goal)} style={styles.play} /> : null} + </View> + </View> + ))} + </ScrollView> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 12, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + help: { fontSize: 15, lineHeight: 21 }, + heading: { fontSize: 14, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5, marginTop: 6 }, + daily: { flexDirection: 'row', alignItems: 'center', gap: 12 }, + icon: { fontSize: 30 }, + go: { fontSize: 15, fontWeight: '900' }, + flex: { flex: 1 }, + task: { fontSize: 16, fontWeight: '700' }, + row: { borderWidth: 2, borderRadius: 14, padding: 12, gap: 8 }, + top: { gap: 2 }, + label: { fontSize: 17, fontWeight: '800' }, + state: { fontSize: 14, fontWeight: '700' }, + bar: { height: 10, borderRadius: 5, overflow: 'hidden' }, + meta: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 8 }, + metaText: { flex: 1, fontSize: 14 }, + play: { height: 44, minWidth: 92 }, +}); diff --git a/apps/mobile/src/HostScreen.js b/apps/mobile/src/HostScreen.js new file mode 100644 index 0000000..d9c32b9 --- /dev/null +++ b/apps/mobile/src/HostScreen.js @@ -0,0 +1,430 @@ +// Hosting a live class from the phone (a teacher), as on the web game's +// projector screen: pick the kind of game, then the lobby (the code and its +// QR code, settings, pairs, who's joined), the game (the teacher's rolls, or +// the pairs and the bracket) and the end (podium and the class report). The +// choices and words are shared (@blockout/progress/hosting). On a phone the +// odd one out in pairs plays the CPU or the teacher on another device. +import { useEffect, useRef, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Switch, Text, View } from 'react-native'; +import Svg, { Rect } from 'react-native-svg'; +import Constants from 'expo-constants'; +import * as Hosting from '@blockout/progress/hosting'; +import * as Classroom from './classroom'; +import { Qr } from './AccountScreen'; +import { Chips, Field, Help, Row, Section, SmallButton, ask } from './forms'; +import { useTheme } from './theme'; +import { Button, Die, ScreenHeader } from './ui'; + +const SERVER = Constants.expoConfig.extra.server; +const joinLink = (code) => `${SERVER}/#class=${code}`; +const ODD_ON_PHONE = Hosting.ODD.filter(([v]) => v !== 'screen'); // (playing on this phone while hosting: not here) + +// host: { code, teacherKey } to pick up (a planned tournament's lobby, or after +// the app was closed); null to set one up. onExit(): back to the menu. +export default function HostScreen({ host: start = null, toast, onExit }) { + const t = useTheme(); + const [host, setHost] = useState(start); + const [view, setView] = useState(null); + const stop = useRef(null); + + useEffect(() => { + if (!host) return undefined; + Classroom.client.saveHost(host); + stop.current = Classroom.client.listenTeacher(host, { + view: setView, + gone: (reason) => { + Classroom.client.saveHost(null); + if (reason !== 'closed') toast('⚠️ Lost the class', 'The classroom server may have restarted.'); + onExit(); + }, + }); + return () => stop.current && stop.current(); + }, [host]); + + const act = (action, extra) => Classroom.client.teacher(host, action, extra).catch((e) => toast('⚠️ Couldn’t do that', e.message)); + + async function close() { + if (!(await ask('Close the class?', 'Everyone goes back to their menu.', 'Close'))) return; + if (stop.current) stop.current(); + await Classroom.client.teacher(host, 'close').catch(() => {}); + Classroom.client.saveHost(null); + onExit(); + } + + if (!host) return <Setup onCreated={setHost} onClose={onExit} />; + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title={`Class ${host.code}`} onClose={close} closeLabel="Close the class" /> + <ScrollView contentContainerStyle={styles.page}> + {!view ? <ActivityIndicator color={t.accent} /> : view.state === 'lobby' ? <Lobby view={view} act={act} /> : view.state === 'playing' ? <Playing view={view} act={act} /> : <Ended view={view} act={act} />} + {view ? <Roster view={view} act={act} /> : null} + <Button testID="host-close" kind="secondary" title="Close the class" onPress={close} /> + </ScrollView> + </View> + ); +} + +// The kind of game, its board and difficulty (the rest is in the lobby) +function Setup({ onCreated, onClose }) { + const t = useTheme(); + const [v, setV] = useState(Hosting.SETUP.start); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const set = (part) => setV((now) => ({ ...now, ...part, size: Hosting.setupSize(part.type || now.type, part.size || now.size) })); + async function create() { + setError(null); + setBusy(true); + try { + const room = await Classroom.client.createRoom({ type: v.type, size: v.size, difficulty: v.difficulty, rounds: v.rounds }); + onCreated({ code: room.code, teacherKey: room.teacherKey }); + } catch (e) { + setError(e.message); + setBusy(false); + } + } + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Host a live game" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page}> + <Field label="Game"> + <Chips testID="host-type" options={Hosting.HOST_TYPES.map(([value, label]) => [value, label])} value={v.type} onChange={(type) => set({ type })} /> + <Help>{Hosting.HOST_TYPE_HELP[v.type]}</Help> + </Field> + <Field label="Board size"> + <Chips testID="host-size" options={Hosting.SETUP.sizes.map((n) => [n, `${n}×${n}`])} value={v.size} onChange={(size) => set({ size })} /> + </Field> + <Field label="Difficulty"> + <Chips testID="host-difficulty" options={Hosting.SETUP.difficulties.map(([value, label, detail]) => [value, `${label} · ${detail}`])} value={v.difficulty} onChange={(difficulty) => set({ difficulty })} /> + </Field> + {v.type === 'class' ? ( + <Field label="Rolls in a game"> + <Chips testID="host-rounds" options={Hosting.SETUP.rounds.map((n) => [n, String(n)])} value={v.rounds} onChange={(rounds) => set({ rounds })} /> + </Field> + ) : null} + {error ? <Text style={{ color: t.bad, fontWeight: '600' }}>{error}</Text> : null} + <Button testID="host-create" title={busy ? 'Opening the class…' : 'Open the class'} onPress={create} disabled={busy} /> + </ScrollView> + </View> + ); +} + +function Lobby({ view, act }) { + const t = useTheme(); + const link = joinLink(view.code); + const start = Hosting.startButton(view); + const [pick, setPick] = useState(null); // arranging pairs: the first name tapped + return ( + <> + <View style={[styles.join, { backgroundColor: t.paper, borderColor: t.line }]}> + <Qr text={link} size={130} /> + <View style={styles.flex}> + <Text style={[styles.small, { color: t.muted }]}>Join with the code</Text> + <Text testID="host-code" style={[styles.code, { color: t.ink }]} selectable> + {view.code} + </Text> + <Text style={[styles.small, { color: t.muted }]} numberOfLines={2}> + {link.replace(/^https?:\/\//, '')} + </Text> + <SmallButton testID="host-share" title="Share…" onPress={() => Share.share({ message: Hosting.joinMessage(link, view.code) }).catch(() => {})} /> + </View> + </View> + <Button testID="host-start" title={start.text} onPress={() => act('start')} disabled={start.disabled} /> + {view.type === 'pairs' ? ( + <Section title="Pairs"> + <Chips testID="host-matching" options={Hosting.MATCHING} value={view.pairOptions.matching} onChange={(matching) => act('pairOptions', { options: { matching } })} /> + <Text style={[styles.label, { color: t.ink }]}>The odd one out plays</Text> + <Chips testID="host-odd" options={ODD_ON_PHONE.map(([v, label, detail]) => [v, `${label} ${detail}`])} value={view.pairOptions.odd} onChange={(odd) => act('pairOptions', { options: { odd } })} /> + <Help>{Hosting.pairsHelp(view)}</Help> + {view.pairOptions.odd === 'device' ? ( + <View style={styles.device}> + <Qr text={`${link}&play=${view.playKey}`} size={96} /> + <Help>Scan this on your other device to play the odd one out there. {view.teacher && view.teacher.connected ? '✓ It has joined.' : 'Not joined yet.'}</Help> + </View> + ) : null} + {view.pairOptions.matching !== 'shuffle' + ? view.pairs.map(([a, b], i) => ( + <View key={i} style={styles.pairRow}> + {[a, b].map((p, j) => + p ? ( + <Pressable + key={p.id} + disabled={view.pairOptions.matching !== 'arrange'} + onPress={() => { + if (!pick) setPick(p.id); + else if (pick === p.id) setPick(null); + else { + act('swap', { a: pick, b: p.id }); + setPick(null); + } + }} + style={[styles.pairName, { backgroundColor: pick === p.id ? t.accentSoft : t.surface, borderColor: pick === p.id ? t.accent : t.line }]} + > + <Text style={[styles.strong, { color: t.ink }]}>{p.name}</Text> + </Pressable> + ) : ( + <Text key={`odd${j}`} style={[styles.pairName, styles.strong, { color: t.muted, borderColor: t.line }]}> + {view.pairOptions.odd === 'cpu' ? 'CPU' : 'You'} + </Text> + ) + )} + </View> + )) + : null} + </Section> + ) : null} + <Section title="Settings"> + <SmallButton testID="host-quick" title="⚡ Make it quick" onPress={() => act('settings', { settings: Hosting.QUICK_SETTINGS })} disabled={Hosting.isQuick(view)} /> + {Hosting.HOST_SETTINGS.filter((d) => Hosting.settingShown(d, view)).map((d) => ( + <Field key={d.key} label={d.label}> + <Chips testID={`host-setting-${d.key}`} options={d.options.map(([value, label, detail]) => [value, detail ? `${label} · ${detail}` : label])} value={view.settings[d.key]} onChange={(value) => act('settings', { settings: { [d.key]: value } })} /> + </Field> + ))} + </Section> + </> + ); +} + +function Playing({ view, act }) { + const t = useTheme(); + const next = Hosting.nextButton(view); + const done = view.here ? view.done / view.here : 0; + return ( + <> + <Text testID="host-round" style={[styles.round, { color: t.ink }]}> + {Hosting.roundLabel(view)} + </Text> + {view.type === 'class' && view.roll ? ( + <> + <View style={styles.dice}> + <Die value={view.roll[0]} size={84} /> + <Text style={[styles.times, { color: t.muted }]}>×</Text> + <Die value={view.roll[1]} size={84} /> + </View> + <Text style={[styles.rollText, { color: t.ink }]}> + Draw a {view.roll[0]} by {view.roll[1]} rectangle + </Text> + <View style={[styles.bar, { backgroundColor: t.soft }]}> + <View style={{ width: `${done * 100}%`, height: '100%', backgroundColor: t.good, borderRadius: 6 }} /> + </View> + <Text testID="host-done" style={[styles.small, { color: t.muted, textAlign: 'center' }]}> + {view.done} of {view.here} done + </Text> + </> + ) : null} + {next ? <Button testID="host-next" title={next.text} onPress={() => act('next')} disabled={next.disabled} /> : null} + {next ? ( + <View style={styles.auto}> + <Switch testID="host-auto" value={Boolean(view.autoNext)} onValueChange={(on) => act('autoNext', { on })} /> + <Text style={[styles.small, { color: t.ink, flex: 1 }]}>{Hosting.autoLabel(view)}</Text> + </View> + ) : null} + {view.type !== 'class' ? <Matches view={view} /> : <Boards view={view} />} + {view.type === 'tournament' && view.tournament ? <Tournament t={view.tournament} /> : null} + <Button + testID="host-end" + kind="secondary" + title="End the game now" + onPress={async () => (await ask('End the game now?', 'Scores so far are final.', 'End it')) && act('end')} + /> + </> + ); +} + +function Ended({ view, act }) { + const t = useTheme(); + const r = view.report; + return ( + <> + <Text style={[styles.round, { color: t.ink }]}>{view.type === 'tournament' ? '🏆 The tournament is over' : 'Game over'}</Text> + {Hosting.podium(view).map((p, i) => ( + <Row key={i} testID="host-podium"> + <Text style={[styles.podium, { color: t.ink }]}> + {p.medal} {p.avatar ? `${p.avatar} ` : ''} + {p.name} · {p.score} + </Text> + </Row> + ))} + {view.type === 'pairs' ? <Matches view={view} /> : null} + {view.type === 'tournament' && view.tournament ? <Tournament t={view.tournament} /> : null} + {r ? ( + <Section title="How the class did"> + <View style={styles.tiles}> + {Hosting.reportTiles(r).map(([value, label]) => ( + <View key={label} style={[styles.tile, { backgroundColor: t.surface, borderColor: t.line }]}> + <Text style={[styles.tileValue, { color: t.ink }]}>{value}</Text> + <Text style={[styles.small, { color: t.muted, textAlign: 'center' }]}>{label}</Text> + </View> + ))} + </View> + {!r.hardest.length ? <Help>Nothing missed. What a class! 🌟</Help> : null} + {r.hardest.map((f) => ( + <Row key={f.fact}> + <Text style={[styles.strong, { color: t.ink }]}> + {f.fact} <Text style={{ color: t.bad }}>· missed {f.missed} of {f.asked}</Text> + </Text> + </Row> + ))} + </Section> + ) : null} + <Button testID="host-again" title="Play again" onPress={() => act('lobby')} /> + </> + ); +} + +// Pairs and tournaments: a card per game +function Matches({ view }) { + const t = useTheme(); + return (view.matches || []).map((m, i) => { + const card = Hosting.matchCard(m); + return ( + <View key={i} testID="host-match" style={[styles.match, { backgroundColor: t.surface, borderColor: m.over ? t.line : t.accent }]}> + {card.rows.map((p) => ( + <View key={p.id} style={styles.matchRow}> + <Text style={styles.mark}>{p.mark}</Text> + <Text style={[styles.strong, { color: t.ink, flex: 1, fontWeight: p.winner ? '900' : '700' }]} numberOfLines={1}> + {p.avatar ? `${p.avatar} ` : ''} + {p.name} + </Text> + <Text style={[styles.strong, { color: t.ink }]}>{p.score}</Text> + </View> + ))} + <Text style={[styles.small, { color: t.muted }]}>{card.status}</Text> + </View> + ); + }); +} + +// A tournament: the bracket's rounds (knockouts), else the standings +function Tournament({ t: tour }) { + const t = useTheme(); + return ( + <Section title="Tournament"> + {tour.format === 'koth' + ? tour.hills.map((h) => ( + <Row key={h.id}> + <Text style={[styles.strong, { color: t.ink }]}> + {tour.hills.length > 1 ? `Hill ${h.id}: ` : ''}👑 {h.king || '…'} + {h.streak > 1 ? ` · 🔥 ${h.streak} in a row` : ''} + </Text> + <Text style={[styles.small, { color: t.muted }]}> + {h.queue.length ? `Next: ${h.queue.join(', ')} · ` : ''} + {h.played} of {h.limit} games + </Text> + </Row> + )) + : null} + {tour.format === 'knockout' || tour.format === 'double' ? ( + <ScrollView horizontal contentContainerStyle={styles.bracket}> + {tour.rounds.map((r, i) => ( + <View key={i} style={styles.bracketRound}> + <Text style={[styles.small, { color: t.muted }]}>{r.label}</Text> + {r.matches.map((m, j) => ( + <View key={j} style={[styles.bracketMatch, { borderColor: t.line, backgroundColor: t.surface }]}> + {[m.a, m.b].map((name, k) => ( + <Text key={k} style={{ color: name === m.winner ? t.ink : t.muted, fontWeight: name === m.winner ? '900' : '600' }}> + {name} + </Text> + ))} + </View> + ))} + {r.byes.map((name) => ( + <Text key={name} style={[styles.small, { color: t.muted }]}> + {name}: bye + </Text> + ))} + </View> + ))} + </ScrollView> + ) : null} + </Section> + ); +} + +// Whole class: everyone's board, as they place their rectangles +function Boards({ view }) { + const t = useTheme(); + const size = view.settings.size; + const px = 76; + const cell = px / size; + return ( + <View style={styles.boards}> + {view.players.map((p) => ( + <View key={p.id} style={[styles.boardCard, { borderColor: p.done ? t.good : t.line, opacity: p.connected ? 1 : 0.5 }]}> + <Svg width={px} height={px}> + <Rect x={0} y={0} width={px} height={px} fill={t.surface} /> + {((view.boards && view.boards[p.id]) || []).map(([x, y, w, h], i) => ( + <Rect key={i} x={x * cell} y={y * cell} width={w * cell} height={h * cell} fill="#2e86de" fillOpacity={0.85} stroke={t.paper} strokeWidth={1} /> + ))} + </Svg> + <Text style={[styles.small, { color: t.ink }]} numberOfLines={1}> + {p.done ? '✓ ' : ''} + {p.name} · {p.score} + </Text> + </View> + ))} + </View> + ); +} + +function Roster({ view, act }) { + const t = useTheme(); + const r = Hosting.roster(view); + return ( + <Section title={r.title}> + {!r.rows.length ? <Help>Nobody yet. Names show up here as students join.</Help> : null} + {r.rows.map((p, i) => ( + <View key={p.id || i} testID="host-roster" style={[styles.rosterRow, { borderColor: t.line, opacity: p.offline ? 0.5 : 1 }]}> + {p.rank ? <Text style={[styles.rank, { color: t.muted }]}>{p.rank}</Text> : null} + <Text style={[styles.strong, { color: t.ink, flex: 1 }]} numberOfLines={1}> + {p.avatar ? `${p.avatar} ` : ''} + {p.name} + {p.title ? <Text style={[styles.small, { color: t.muted }]}> {p.title}</Text> : null} + </Text> + {p.waiting ? <Text style={[styles.small, { color: t.muted }]}>{p.waiting}</Text> : null} + {p.removable ? ( + <SmallButton title="×" testID="host-remove" onPress={async () => (await ask(`Remove ${p.name} from the class?`, 'They can join again with the code.', 'Remove')) && act('remove', { id: p.id })} /> + ) : p.score !== undefined && !p.waiting ? ( + <Text style={[styles.strong, { color: t.ink }]}> + {p.score} {p.status} + </Text> + ) : null} + </View> + ))} + </Section> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1, gap: 6 }, + page: { padding: 16, paddingTop: 4, gap: 16, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + join: { flexDirection: 'row', gap: 12, alignItems: 'center', borderWidth: 2, borderRadius: 14, padding: 12 }, + code: { fontSize: 44, fontWeight: '900', letterSpacing: 6 }, + small: { fontSize: 13, lineHeight: 18 }, + strong: { fontSize: 16, fontWeight: '700' }, + label: { fontSize: 15, fontWeight: '800' }, + device: { flexDirection: 'row', gap: 12, alignItems: 'center' }, + pairRow: { flexDirection: 'row', gap: 8, alignItems: 'center' }, + pairName: { flex: 1, borderWidth: 2, borderRadius: 10, padding: 10, textAlign: 'center' }, + round: { fontSize: 22, fontWeight: '900', textAlign: 'center' }, + dice: { flexDirection: 'row', gap: 16, alignItems: 'center', justifyContent: 'center' }, + times: { fontSize: 30, fontWeight: '800' }, + rollText: { fontSize: 20, fontWeight: '800', textAlign: 'center' }, + bar: { height: 12, borderRadius: 6, overflow: 'hidden' }, + auto: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + match: { borderWidth: 2, borderRadius: 12, padding: 10, gap: 4 }, + matchRow: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + mark: { width: 22, fontSize: 16 }, + bracket: { gap: 12, paddingVertical: 4 }, + bracketRound: { gap: 8, minWidth: 130 }, + bracketMatch: { borderWidth: 2, borderRadius: 10, padding: 8, gap: 2 }, + boards: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 }, + boardCard: { width: 92, alignItems: 'center', gap: 4, borderWidth: 2, borderRadius: 10, padding: 6 }, + rosterRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth }, + rank: { width: 36, fontSize: 14, fontWeight: '800' }, + podium: { fontSize: 18, fontWeight: '800' }, + tiles: { flexDirection: 'row', gap: 8 }, + tile: { flex: 1, padding: 10, borderRadius: 12, borderWidth: 2, alignItems: 'center', gap: 2 }, + tileValue: { fontSize: 22, fontWeight: '900' }, +}); diff --git a/apps/mobile/src/MyClassesScreen.js b/apps/mobile/src/MyClassesScreen.js new file mode 100644 index 0000000..91c6610 --- /dev/null +++ b/apps/mobile/src/MyClassesScreen.js @@ -0,0 +1,399 @@ +// A teacher's classes, as on the web game: a tab for each (and a new one), +// then the class's join code (with its link's QR code), its students (their +// Times Table, homework and grown-ups), the class's homework, planned +// tournaments, and codes for other teachers. Hosting a live game is its own +// screen (onHost). The words and rules are shared (@blockout/progress/grownups). +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, Share, StyleSheet, Text, View } from 'react-native'; +import Constants from 'expo-constants'; +import * as Grownups from '@blockout/progress/grownups'; +import * as Homework from '@blockout/progress/homework'; +import * as Account from './account'; +import { CodeCard, Qr } from './AccountScreen'; +import StudentPage, { HomeworkForm } from './StudentPage'; +import { Chips, ErrorLine, Field, Help, MasteryBar, Row, Section, SmallButton, TextBox, ask } from './forms'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +const SERVER = Constants.expoConfig.extra.server; + +// onHost(): set up a live game; onOpenLobby({ code, teacherKey }): a planned tournament's lobby +export default function MyClassesScreen({ user, toast, onHost, onOpenLobby, onClose }) { + const t = useTheme(); + const [classes, setClasses] = useState(null); + const [current, setCurrent] = useState(null); + const [error, setError] = useState(null); + const [page, setPage] = useState(null); // { student } or { grownUps: student } + const [name, setName] = useState(''); + const [addError, setAddError] = useState(null); + + const load = async (pick = null) => { + try { + const list = (await Account.api('GET', '/api/classes')).classes; + setClasses(list); + setCurrent((now) => pick || (list.some((c) => c.id === now) ? now : list.length ? list[0].id : null)); + } catch (e) { + setError(e.message); + } + }; + useEffect(() => { + load(); + }, []); + + if (page && page.student) return <StudentPage id={page.student.id} me={user} toast={toast} onBack={() => setPage(null)} onDeleted={() => setPage(null)} />; + if (page && page.grownUps) return <GrownUps student={page.grownUps} onBack={() => setPage(null)} />; + + async function addClass() { + setAddError(null); + try { + const c = await Account.api('POST', '/api/classes', { name }); + setName(''); + load(c.id); + } catch (e) { + setAddError(e.message); + } + } + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="🏫 My classes" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page} keyboardShouldPersistTaps="handled"> + <Button testID="host-open" title="Host a live game" onPress={onHost} /> + {error ? <Help>{error}</Help> : !classes ? <ActivityIndicator color={t.accent} /> : null} + {classes && classes.length ? ( + <Chips testID="class-tab" options={classes.map((c) => [c.id, `${c.name} (${c.students})`])} value={current} onChange={setCurrent} /> + ) : null} + {classes ? ( + <View style={styles.form}> + <TextBox testID="class-new-name" value={name} onChangeText={setName} placeholder={classes.length ? 'New class name' : 'Class name, like “Room 12”'} maxLength={40} style={styles.flex} onSubmitEditing={addClass} /> + <Button testID="class-new" kind="secondary" title="+ New class" onPress={addClass} disabled={!name.trim()} style={styles.button} /> + </View> + ) : null} + <ErrorLine error={addError} /> + {classes && !classes.length ? <Help>Make a class, then give your students its code. Any student with a school account can join with it.</Help> : null} + {current ? <ClassDetail key={current} id={current} toast={toast} onStudent={(s) => setPage({ student: s })} onGrownUps={(s) => setPage({ grownUps: s })} onChanged={() => load()} onOpenLobby={onOpenLobby} /> : null} + {classes ? <TeacherInvites /> : null} + </ScrollView> + </View> + ); +} + +function ClassDetail({ id, toast, onStudent, onGrownUps, onChanged, onOpenLobby }) { + const t = useTheme(); + const [d, setD] = useState(null); + const [error, setError] = useState(null); + const load = () => + Account.api('GET', `/api/classes/${id}`) + .then(setD) + .catch((e) => setError(e.message)); + useEffect(() => { + load(); + }, [id]); + if (error) return <Help>{error}</Help>; + if (!d) return <ActivityIndicator color={t.accent} />; + const c = d.class; + const link = `${SERVER}/#roster=${c.code}`; + + async function takeOut(s) { + if (!(await ask(`Take ${s.name} out of ${c.name}?`, 'They can join again with the class code.', 'Take out'))) return; + await Account.api('DELETE', `/api/classes/${c.id}/students/${s.id}`).catch((e) => toast('⚠️ Couldn’t do that', e.message)); + load(); + onChanged(); + } + async function removeHomework(a) { + if (!(await ask(`Remove “${a.label}”?`, 'It comes off for the whole class.', 'Remove'))) return; + await Account.api('DELETE', `/api/homework/${a.id}`).catch((e) => toast('⚠️ Couldn’t remove it', e.message)); + load(); + } + + return ( + <> + <View style={[styles.join, { backgroundColor: t.paper, borderColor: t.line }]}> + <Qr text={link} size={110} /> + <View style={styles.flex}> + <Text style={[styles.small, { color: t.muted }]}>Students join with</Text> + <Text testID="class-code" style={[styles.code, { color: t.ink }]} selectable> + {c.code} + </Text> + <SmallButton testID="class-share" title="Share join link…" onPress={() => Share.share({ message: `Join ${c.name} on Blockout: ${link} (class code ${c.code})` }).catch(() => {})} /> + </View> + </View> + <Help>Students sign in with their school account{c.school ? ` (${c.school})` : ''}, then type the code or open the link.</Help> + + <Section title={`Students · ${d.students.length}`}> + {!d.students.length ? <Help>Nobody has joined yet.</Help> : null} + {d.students.map((s) => ( + <View key={s.id} testID="roster-row" style={[styles.student, { backgroundColor: t.surface, borderColor: t.line }]}> + <Pressable accessibilityRole="button" onPress={() => onStudent(s)} style={styles.studentTop}> + <Text style={[styles.name, { color: t.accent }]}>{s.name} ›</Text> + <Text style={[styles.small, { color: t.muted }]}>played {Grownups.ago(s.lastPlayed)}</Text> + </Pressable> + <MasteryBar summary={s.summary} /> + <Text style={[styles.small, { color: t.muted }]}> + {s.summary.mastered} of {s.summary.total} mastered · needs practice: {Grownups.needsPracticeShort(s.summary.needsPractice)} · homework: {Grownups.homeworkCount(s.homework)} + </Text> + <View style={styles.buttons}> + <SmallButton testID="roster-grown-ups" title="Grown-ups" onPress={() => onGrownUps(s)} /> + <SmallButton testID="roster-remove" title="Take out" onPress={() => takeOut(s)} /> + </View> + </View> + ))} + </Section> + + <Section title="Class homework"> + {!d.assignments.length ? <Help>No homework set.</Help> : null} + {d.assignments.map((a) => ( + <Row key={a.id} testID="class-homework" right={<SmallButton title="Remove" onPress={() => removeHomework(a)} />}> + <Text style={[styles.strong, { color: t.ink }]}>{a.label}</Text> + <Text style={[styles.small, { color: t.muted }]}>{Grownups.classHomeworkLine(a, Homework.niceDay)}</Text> + </Row> + ))} + <HomeworkForm + options={d.options} + who={d.students.length ? `every student in ${c.name}` : 'a new student'} + toast={toast} + onSubmit={async ({ goal, due }) => { + await Account.api('POST', '/api/homework', { classId: c.id, goal, due }); + load(); + }} + /> + </Section> + + <Schedules classId={c.id} toast={toast} onOpenLobby={onOpenLobby} /> + </> + ); +} + +// Planned tournaments: their lobby opens by itself at the time +function Schedules({ classId, toast, onOpenLobby }) { + const t = useTheme(); + const [list, setList] = useState(null); + const load = () => + Account.api('GET', '/api/schedules') + .then((r) => setList(r.schedules)) + .catch(() => setList([])); + useEffect(() => { + load(); + }, [classId]); + if (!list) return null; + const mine = Grownups.schedulesFor(list, classId); + + async function remove(s) { + if (!(await ask(`Remove “${s.title}”?`, 'The tournament won’t open.', 'Remove'))) return; + await Account.api('DELETE', `/api/schedules/${s.id}`).catch((e) => toast('⚠️ Couldn’t remove it', e.message)); + load(); + } + async function open(s) { + try { + const h = await Account.api('GET', `/api/schedules/${s.id}/host`); + onOpenLobby({ code: h.code, teacherKey: h.teacherKey }); + } catch (e) { + toast('⚠️ Couldn’t open it', e.message); + } + } + + return ( + <Section title="Tournaments"> + {!mine.length ? <Help>Plan a tournament and its lobby opens by itself at that time.</Help> : null} + {mine.map((s) => ( + <Row + key={s.id} + testID="schedule-row" + tone={s.open ? 'live' : null} + right={ + <View style={styles.rowButtons}> + {s.open ? <SmallButton kind="primary" title="Open the lobby" onPress={() => open(s)} /> : null} + <SmallButton title="Remove" onPress={() => remove(s)} /> + </View> + } + > + <Text style={[styles.strong, { color: t.ink }]}>{s.title}</Text> + <Text style={[styles.small, { color: t.muted }]}>{Grownups.scheduleLine(s)}</Text> + </Row> + ))} + <ScheduleForm classId={classId} toast={toast} onPlanned={load} /> + </Section> + ); +} + +// When: one of the next seven days, at a time in the school day +const HOURS = [8, 9, 10, 11, 12, 13, 14, 15, 16]; +const hourLabel = (h) => new Date(2000, 0, 1, h).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' }); + +function ScheduleForm({ classId, toast, onPlanned }) { + const t = useTheme(); + const days = Array.from({ length: 7 }, (_, i) => { + const d = new Date(Date.now() + i * 86400000); + return [Grownups.dayString(d), i === 0 ? 'Today' : i === 1 ? 'Tomorrow' : Grownups.shortDay(d.getTime())]; + }); + const [v, setV] = useState({ title: '', day: days[1][0], hour: 10, format: 'knockout', size: 8, difficulty: 'easy', scope: 'class' }); + const [error, setError] = useState(null); + const set = (part) => setV((now) => ({ ...now, ...part })); + async function plan() { + setError(null); + const [y, m, d] = v.day.split('-').map(Number); + const startsAt = new Date(y, m - 1, d, v.hour).getTime(); + try { + await Account.api('POST', '/api/schedules', { classId, scope: v.scope, title: v.title, startsAt, settings: { format: v.format, size: Number(v.size), difficulty: v.difficulty } }); + toast('🏆 Tournament planned', Grownups.niceTime(startsAt)); + set({ title: '' }); + onPlanned(); + } catch (e) { + setError(e.message); + } + } + return ( + <View style={[styles.box, { backgroundColor: t.paper, borderColor: t.line }]}> + <Text style={[styles.strong, { color: t.ink }]}>Plan a tournament</Text> + <Field label="Name"> + <TextBox testID="schedule-title" value={v.title} onChangeText={(title) => set({ title })} placeholder="e.g. Friday Cup" maxLength={60} /> + </Field> + <Field label="Day"> + <Chips testID="schedule-day" options={days} value={v.day} onChange={(day) => set({ day })} /> + </Field> + <Field label="Time"> + <Chips testID="schedule-hour" options={HOURS.map((h) => [h, hourLabel(h)])} value={v.hour} onChange={(hour) => set({ hour })} /> + </Field> + <Field label="Format"> + <Chips testID="schedule-format" options={Object.entries(Grownups.FORMAT_NAMES)} value={v.format} onChange={(format) => set({ format })} /> + </Field> + <Field label="Board"> + <Chips options={Grownups.SCHEDULE_SIZES.map((n) => [n, `${n}×${n}`])} value={v.size} onChange={(size) => set({ size })} /> + </Field> + <Field label="Difficulty"> + <Chips options={Grownups.SCHEDULE_DIFFICULTIES.map((x) => [x, x[0].toUpperCase() + x.slice(1)])} value={v.difficulty} onChange={(difficulty) => set({ difficulty })} /> + </Field> + <Field label="Who"> + <Chips + options={[ + ['class', 'This class'], + ['school', 'Whole school'], + ]} + value={v.scope} + onChange={(scope) => set({ scope })} + /> + </Field> + <ErrorLine error={error} /> + <Button testID="schedule-plan" title="Plan it" onPress={plan} disabled={!v.title.trim()} /> + </View> + ); +} + +// A student's grown-ups, for their teacher: who's following (take off a wrong +// one), and a code for a new one +function GrownUps({ student, onBack }) { + const t = useTheme(); + const [parents, setParents] = useState(null); + const [invite, setInvite] = useState(null); + const [error, setError] = useState(null); + const load = () => + Account.api('GET', `/api/students/${student.id}/parents`) + .then((r) => setParents(r.parents)) + .catch((e) => setError(e.message)); + useEffect(() => { + load(); + }, []); + async function remove(p) { + if (!(await ask(`Stop ${p.name} following ${student.name}?`, 'They won’t see how they’re doing any more.', 'Stop'))) return; + await Account.api('DELETE', `/api/students/${student.id}/parents/${p.id}`).catch((e) => setError(e.message)); + load(); + } + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title={`${student.name}’s grown-ups`} onClose={onBack} closeLabel="Back" /> + <ScrollView contentContainerStyle={styles.page}> + {!parents ? <ActivityIndicator color={t.accent} /> : !parents.length ? <Help>No grown-up is following yet.</Help> : null} + {(parents || []).map((p) => ( + <Row key={p.id} right={<SmallButton title="Remove" onPress={() => remove(p)} />}> + <Text style={[styles.strong, { color: t.ink }]}>👪 {p.name}</Text> + </Row> + ))} + <Help>A code lets one grown-up follow {student.name}: their Times Table, homework, and setting homework of their own.</Help> + {invite ? ( + <CodeCard invite={invite} who={`For ${student.name}’s grown-up.`} /> + ) : ( + <Button + testID="student-parent-code" + kind="secondary" + title="Make a grown-up code" + onPress={() => + Account.api('POST', `/api/students/${student.id}/parent-invites`) + .then(setInvite) + .catch((e) => setError(e.message)) + } + /> + )} + <ErrorLine error={error} /> + </ScrollView> + </View> + ); +} + +// Bringing in another teacher: a one-time code, and the codes still out there +function TeacherInvites() { + const t = useTheme(); + const [invites, setInvites] = useState([]); + const [made, setMade] = useState(null); + const [error, setError] = useState(null); + const load = () => + Account.api('GET', '/api/me/teacher-invites') + .then((r) => setInvites(r.invites)) + .catch((e) => setError(e.message)); + useEffect(() => { + load(); + }, []); + async function make() { + setError(null); + try { + setMade(await Account.api('POST', '/api/me/teacher-invites', {})); + load(); + } catch (e) { + setError(e.message); + } + } + return ( + <Section title="Invite a teacher" help="A colleague signs in, then types the code (or opens its link) to get their own classes."> + {made ? <CodeCard invite={made} who="For one teacher." /> : <Button testID="teacher-code" kind="secondary" title="Make a teacher code" onPress={make} />} + {invites.map((i) => ( + <Row + key={i.id} + testID="teacher-invite" + right={ + i.usedAt ? null : ( + <SmallButton + title="Remove" + onPress={() => + Account.api('DELETE', `/api/me/invites/${i.id}`) + .then(load) + .catch((e) => setError(e.message)) + } + /> + ) + } + > + <Text style={[styles.strong, { color: t.ink }]}>🍎 Code from {Grownups.shortDay(i.createdAt)}</Text> + <Text style={[styles.small, { color: t.muted }]}>{Grownups.inviteState(i)}</Text> + </Row> + ))} + <ErrorLine error={error} /> + </Section> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1, gap: 6 }, + page: { padding: 16, paddingTop: 4, gap: 16, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + form: { flexDirection: 'row', gap: 8 }, + button: { minWidth: 120 }, + join: { flexDirection: 'row', gap: 12, alignItems: 'center', borderWidth: 2, borderRadius: 14, padding: 12 }, + code: { fontSize: 34, fontWeight: '900', letterSpacing: 4 }, + small: { fontSize: 13, lineHeight: 18 }, + strong: { fontSize: 16, fontWeight: '800' }, + name: { fontSize: 18, fontWeight: '900' }, + student: { borderWidth: 2, borderRadius: 12, padding: 12, gap: 8 }, + studentTop: { flexDirection: 'row', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, + buttons: { flexDirection: 'row', gap: 8 }, + rowButtons: { gap: 6 }, + box: { borderWidth: 2, borderRadius: 14, padding: 12, gap: 12 }, +}); diff --git a/apps/mobile/src/MyKidsScreen.js b/apps/mobile/src/MyKidsScreen.js new file mode 100644 index 0000000..9c3c2cd --- /dev/null +++ b/apps/mobile/src/MyKidsScreen.js @@ -0,0 +1,97 @@ +// A grown-up's children, as on the web game: a card for each (Times Table, +// homework, when they last played) that opens their page, and linking another +// child with the code they (or their teacher) made. +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Grownups from '@blockout/progress/grownups'; +import * as Account from './account'; +import StudentPage from './StudentPage'; +import { ErrorLine, Help, MasteryBar, Section, TextBox } from './forms'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +export default function MyKidsScreen({ user, toast, onClose }) { + const t = useTheme(); + const [kids, setKids] = useState(null); + const [error, setError] = useState(null); + const [open, setOpen] = useState(null); // a child's page + const [code, setCode] = useState(''); + const [linkError, setLinkError] = useState(null); + const load = () => + Account.api('GET', '/api/children') + .then((r) => setKids(r.children)) + .catch((e) => setError(e.message)); + useEffect(() => { + if (!open) load(); + }, [open]); + + if (open) + return ( + <StudentPage + id={open} + me={user} + toast={toast} + onBack={() => setOpen(null)} + onDeleted={() => { + toast('👋 Account deleted', 'Everything in it is gone.'); + setOpen(null); + }} + /> + ); + + async function link() { + setLinkError(null); + try { + const r = await Account.api('POST', '/api/children', { code: code.replace(/\s+/g, '').toUpperCase() }); + toast('👪 Linked', r.child.name); + setCode(''); + load(); + } catch (e) { + setLinkError(e.message); + } + } + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="👪 My kids" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page} keyboardShouldPersistTaps="handled"> + {error ? <Help>{error}</Help> : !kids ? <ActivityIndicator color={t.accent} /> : null} + {kids && !kids.length ? <Help>Link your child’s account with a code: your child makes one on their Blockout account page (tap Account on the menu), or their teacher can.</Help> : null} + {(kids || []).map((c) => ( + <Pressable + key={c.id} + testID="kid-card" + accessibilityRole="button" + onPress={() => setOpen(c.id)} + style={({ pressed }) => [styles.card, { backgroundColor: t.surface, borderColor: t.line, transform: [{ scale: pressed ? 0.98 : 1 }] }]} + > + <Text style={[styles.name, { color: t.ink }]}>🎒 {c.name}</Text> + <MasteryBar summary={c.summary} /> + <Text style={[styles.small, { color: t.muted }]}> + {c.summary.mastered} of {c.summary.total} facts mastered · played {Grownups.ago(c.lastPlayed)} + </Text> + <Text style={[styles.small, { color: t.muted }]}>{Grownups.kidHomework(c.homework)}</Text> + </Pressable> + ))} + <Section title="Link a child"> + <View style={styles.form}> + <TextBox testID="kid-code" value={code} onChangeText={setCode} placeholder="Child’s code" maxLength={9} autoCapitalize="characters" style={styles.flex} onSubmitEditing={link} /> + <Button testID="kid-link" title="Link" onPress={link} disabled={!code.trim()} style={styles.button} /> + </View> + <ErrorLine error={linkError} testID="kid-error" /> + </Section> + </ScrollView> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 14, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + card: { borderWidth: 2, borderRadius: 14, padding: 14, gap: 8 }, + name: { fontSize: 19, fontWeight: '900' }, + small: { fontSize: 14 }, + form: { flexDirection: 'row', gap: 8 }, + button: { minWidth: 92 }, +}); diff --git a/apps/mobile/src/PracticeScreen.js b/apps/mobile/src/PracticeScreen.js new file mode 100644 index 0000000..ab31672 --- /dev/null +++ b/apps/mobile/src/PracticeScreen.js @@ -0,0 +1,398 @@ +// Practice on the phone: a round of questions from your weakest facts, played +// with @blockout/progress/practice (the web game's rules and words). The +// question and keypad on one screen; "Help me count" on its own, as in the game. +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Alert, Platform, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Practice from '@blockout/progress/practice'; +import * as Daily from '@blockout/progress/daily'; +import * as Settings from '@blockout/progress/settings'; +import * as Setup from '@blockout/progress/setup'; +import * as Stats from '@blockout/progress/stats'; +import * as Views from '@blockout/progress/views'; +import * as Journey from '@blockout/progress/journey'; +import { RectPicture } from './Board'; +import { addToHistory } from './history'; +import { useTheme } from './theme'; +import { usePlayerColors } from './looks'; +import { Streak } from './effects'; +import { Button, Earned, Keypad } from './ui'; + +const testing = Platform.OS === 'web' && typeof window !== 'undefined'; +const pace = testing && window.BLOCKOUT_FAST ? 0.05 : 1; +const COUNT_STEP_MS = 850 * pace; +const RIGHT_MS = 1100 * pace; +const TIMEOUT_MS = 2200 * pace; +const TRY_AGAIN = ['Not quite — try again!', 'Almost! Have another go.', 'Hmm, not that one. Try again!']; +const BONUS_ICONS = { first: '✨', speedy: '⚡', streak: '🔥', timer: '⏱️' }; +const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1); +const STATUS = { mastered: '#2a9d5c', learning: '#f2c94c', practice: '#d64533', unseen: null }; + +// options: { difficulty, count, tables, level, timer }; the save is changed in +// place (onSave after each change); toast and onEarned as in the game. +// earned / onHold: as Game's (pop-ups wait for the results) +export default function PracticeScreen({ options, progress, settings = Settings.DEFAULT_SETTINGS, onSave, toast, onEarned, earned = [], onHold = () => {}, onExit, onAgain }) { + const t = useTheme(); + const PLAYER_COLORS = usePlayerColors(); // (the Wardrobe's colours) + const r = useRef(null); + if (!r.current) r.current = Practice.createRound(progress, options); + const round = r.current; + if (testing) window.BlockoutPractice = { round }; + const alive = useRef(true); + const [, setTick] = useState(0); + const redraw = () => setTick((n) => n + 1); + const [area, setArea] = useState(null); + const [view, setView] = useState('question'); // 'question' | 'help' | 'done' + const [entry, setEntry] = useState(''); + const [feedback, setFeedback] = useState(null); // { text, kind } + const [busy, setBusy] = useState(false); + const [counting, setCounting] = useState(null); + const [helpText, setHelpText] = useState(''); + const [helpDone, setHelpDone] = useState(false); + const [end, setEnd] = useState(null); + const [timeLeft, setTimeLeft] = useState(null); + const started = useRef(Date.now()); + const paused = useRef(0); + const helpStarted = useRef(0); + const clock = useRef(null); + const timerMs = round.timer ? Setup.timerMs(progress, round.timer) : 0; // (a cheat can stretch it) + + const wait = useCallback((ms) => new Promise((resolve) => setTimeout(() => alive.current && resolve(), ms)), []); + useEffect(() => { + onHold(true); // (bonuses and achievements wait for the results) + startQuestion(); + return () => { + alive.current = false; + stopClock(); + onHold(false); + }; + }, []); + + // ---- the timer (Expert's): pauses while counting; out of time, it's missed and comes back + function startClock() { + stopClock(); + if (!timerMs) return; + const c = { total: timerMs, remaining: timerMs, last: Date.now(), paused: false }; + c.interval = setInterval(() => { + const now = Date.now(); + if (!c.paused) c.remaining -= now - c.last; + c.last = now; + setTimeLeft(Math.max(0, c.remaining)); + if (c.remaining <= 0) { + stopClock(); + onTimeout(); + } + }, 100); + clock.current = c; + setTimeLeft(timerMs); + } + function stopClock() { + if (clock.current) clearInterval(clock.current.interval); + clock.current = null; + } + const pauseClock = (p) => { + if (!clock.current) return; + clock.current.paused = p; + clock.current.last = Date.now(); + }; + + function startQuestion() { + if (!round.current) return finish(); + setEntry(''); + setFeedback(null); + setView('question'); + started.current = Date.now(); + paused.current = 0; + startClock(); + redraw(); + } + + async function onTimeout() { + if (!alive.current || !round.current) return; + const out = Practice.timeout(progress, round); + if (!out) return; + onSave(); + setBusy(true); + setEntry(String(round.current.area)); + setFeedback({ kind: 'try', text: out.text }); + await wait(TIMEOUT_MS); + setBusy(false); + Practice.next(round); + startQuestion(); + } + + function onKey(key) { + if (busy || !round.current || round.current.done) return; + if (key === 'check') return check(entry); + let next = entry; + if (key === 'back') next = entry.slice(0, -1); + else if (entry.length < 3) next = (entry === '0' ? '' : entry) + key; + setEntry(next); + const q = round.current; + if (next !== '' && (Number(next) >= q.area || next.length >= String(q.area).length)) check(next); + } + + async function check(value) { + if (value === '') return; + const c = clock.current; + const timer = c && round.timer ? { secs: round.timer, left: c.remaining / c.total } : null; + const res = Practice.answer(progress, round, value, { ms: Date.now() - started.current - paused.current, timer }); + if (!res) return; + if (res.correct) { + stopClock(); + setBusy(true); + setFeedback({ kind: 'good', text: res.text }); + for (const part of res.bonus.parts) toast(`${BONUS_ICONS[part.label.split(' ')[0]] || '⭐'} ${capitalize(part.label)}`, `+${part.points} bonus points${round.expert ? ` (×${Practice.PRACTICE_EXPERT_BONUS} Expert)` : ''}`); + onEarned(res.earned); + onSave(); + redraw(); + await wait(RIGHT_MS); + setBusy(false); + setTimeLeft(null); + Practice.next(round); + return startQuestion(); + } + setEntry(''); + setFeedback({ kind: 'try', text: TRY_AGAIN[(res.attempts - 1) % TRY_AGAIN.length] }); + redraw(); + if (res.helpNow) { + setBusy(true); + await wait(900 * Math.max(pace, 0.2)); + setBusy(false); + helpCount("Let's count them together!"); + } + } + + // "Help me count": the fact as a rectangle, each square numbered row by row + async function helpCount(intro = null) { + const h = Practice.help(progress, round); + if (!h) return; + onEarned(h.earned); + onSave(); + helpStarted.current = Date.now(); + pauseClock(true); + setHelpDone(false); + setCounting(null); + setView('help'); + const unit = h.rows === 1 ? 'row' : 'rows'; + setHelpText(intro || `${h.rows} ${unit} of ${h.group}. Let's count!`); + await wait(COUNT_STEP_MS); + for (let i = 1; i <= h.rows; i++) { + setHelpText(`${h.rows} ${unit} of ${h.group}: ${h.totals.slice(0, i).join(', ')}`); + setCounting({ byRows: true, group: h.group, shown: i }); + await wait(COUNT_STEP_MS); + } + setHelpText(`${h.totals.join(', ')}. So what is ${round.current.a} × ${round.current.b}?`); + setHelpDone(true); + } + + function backFromHelp() { + paused.current += Date.now() - helpStarted.current; + pauseClock(false); + setFeedback(null); + setEntry(''); + setView('question'); + } + + function finish() { + stopClock(); + const out = Practice.finish(progress, round); + if (out.finishToast) toast(...out.finishToast); + onEarned(out.earned); + const daily = Daily.complete(progress, { kind: 'practice', tables: round.tables, count: round.count }); // (today's practice?) + if (daily) { + toast(...daily.toast); + onEarned(daily.earned); + } + onSave(); + addToHistory(out.record); + setEnd(out); + setView('done'); + } + + function leave() { + if (view === 'done') return onExit(); + const title = 'Stop practicing?'; + const text = 'The points for your answers are kept.'; + if (Platform.OS === 'web') { + if (window.confirm(`${title}\n\n${text}`)) onExit(); + return; + } + Alert.alert(title, text, [ + { text: 'Keep going', style: 'cancel' }, + { text: 'Stop', style: 'destructive', onPress: onExit }, + ]); + } + + const q = round.current; + const progressShare = round.queue.length ? (view === 'done' ? 1 : round.index / round.queue.length) : 0; + const low = timeLeft !== null && timeLeft <= 5000; + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <View style={styles.header}> + <Pressable onPress={leave} accessibilityRole="button" accessibilityLabel="Main menu" hitSlop={10} style={[styles.close, { borderColor: t.line, backgroundColor: t.surface }]}> + <Text style={[styles.closeText, { color: t.ink }]}>✕</Text> + </Pressable> + <View style={styles.headerMid}> + <Text testID="pr-counter" style={[styles.counter, { color: t.ink }]} numberOfLines={1}> + {view === 'done' ? 'Practice done!' : Practice.counter(round)} + </Text> + <View style={[styles.bar, { backgroundColor: t.soft }]}> + <View style={{ width: `${progressShare * 100}%`, height: '100%', backgroundColor: t.good, borderRadius: 4 }} /> + </View> + </View> + {view !== 'done' ? <Streak n={round.streak} color={t.goldInk} /> : null} + </View> + <View style={styles.fill} onLayout={(e) => setArea(e.nativeEvent.layout)}> + {!area ? null : view === 'done' && end ? ( + <Done end={end} progress={progress} round={round} settings={settings} earned={earned} onAgain={onAgain} onExit={onExit} /> + ) : view === 'help' && q ? ( + <View style={styles.helpScreen}> + <RectPicture rect={{ a: q.a, b: q.b, w: q.b, h: q.a }} color={PLAYER_COLORS[0]} counting={counting} maxWidth={area.width - 32} maxHeight={Math.max(80, area.height - 200)} /> + <Text testID="pr-help-text" style={[styles.helpText, { color: t.ink }]}> + {helpText} + </Text> + <Button testID="pr-help-done" title="I know it! ›" onPress={backFromHelp} disabled={!helpDone} style={styles.wide} /> + </View> + ) : q ? ( + <View style={styles.question}> + {timeLeft !== null && timerMs ? ( + <View style={styles.timer}> + <Text testID="pr-timer" style={[styles.timerText, { color: low ? t.bad : t.ink }]}> + ⏱️ {Math.ceil(timeLeft / 1000)} s + </Text> + <View style={[styles.timerBar, { backgroundColor: t.soft }]}> + <View style={{ width: `${(timeLeft / timerMs) * 100}%`, height: '100%', backgroundColor: low ? t.bad : t.good, borderRadius: 5 }} /> + </View> + </View> + ) : null} + <View style={styles.questionRow}> + <Text testID="pr-q" style={[styles.q, { color: t.ink }]}> + {q.a} × {q.b} = + </Text> + <View style={[styles.answerBox, { borderColor: feedback && feedback.kind === 'good' ? t.good : t.edge, backgroundColor: t.surface }]}> + <Text style={[styles.answerText, { color: t.ink }]}>{entry || ' '}</Text> + </View> + </View> + <Text testID="pr-feedback" numberOfLines={2} style={[styles.feedback, { color: !feedback ? t.muted : feedback.kind === 'good' ? t.goodInk : t.bad }]}> + {feedback ? feedback.text : q.retry ? 'One you missed earlier: have another go!' : ' '} + </Text> + <Keypad onKey={onKey} disabled={busy} /> + {!round.expert && !q.helped ? <Button testID="pr-help" kind="secondary" title="Help me count" onPress={() => helpCount()} disabled={busy} /> : null} + </View> + ) : null} + </View> + </View> + ); +} + +function Done({ end, progress, round, settings, earned, onAgain, onExit }) { + const t = useTheme(); + const facts = Stats.playerFacts(progress, round.name); + const grid = Stats.factGrid(facts, { unlocked: Views.tableSize(progress), showTime: Settings.showsTime(settings) }); + // a Practice Journey round: on to the next table, or what it still takes + const journey = end.journey ? Journey.practiceResultMessage(end.journey) : null; + const up = Boolean(end.journey && end.journey.advanced); + // moved up: on to the next round or table ("Expert ›", "×4 ›") + const next = up && !Journey.practiceDone(progress) ? Journey.practiceNextLabel(end.journey.to) : null; + return ( + <View style={styles.fill}> + <ScrollView contentContainerStyle={styles.done}> + <Text testID="pd-sub" style={[styles.sub, { color: t.ink }]}> + {end.sub} + </Text> + {journey ? ( + <View testID="pd-journey" style={[styles.journeyResult, { borderColor: up ? t.good : t.line, backgroundColor: up ? t.goodBg : t.soft }]}> + <Text style={[styles.sub, { color: up ? t.goodInk : t.ink, fontWeight: '800' }]}>{journey.title}</Text> + <Text style={[styles.sub, { color: up ? t.goodInk : t.ink }]}>{journey.text}</Text> + </View> + ) : null} + <Earned notes={earned} /> + <Text style={[styles.groupTitle, { color: t.muted }]}>What changed</Text> + {end.changes.length ? ( + end.changes.map((c) => ( + <View key={c.fact} testID="pd-change" style={[styles.change, { borderColor: t.line }]}> + <Text style={[styles.changeFact, { color: t.ink }]}>{c.fact}</Text> + <Text style={[styles.changeWhat, { color: c.up ? t.goodInk : t.bad }]}> + {Stats.FACT_STATUS[c.was].label} → {Stats.FACT_STATUS[c.now].label} + </Text> + </View> + )) + ) : ( + <Text style={[styles.small, { color: t.muted }]}>No changes this time. Keep practicing and your facts will move up!</Text> + )} + <Text style={[styles.groupTitle, { color: t.muted }]}>Times table</Text> + <View style={styles.grid}> + <View style={styles.gridRow}> + <Head text="×" t={t} /> + {grid.heads.map((h) => ( + <Head key={h.n} text={h.mastered ? `★${h.n}` : String(h.n)} t={t} gold={h.mastered} /> + ))} + </View> + {grid.cells.map((row, i) => ( + <View key={i} style={styles.gridRow}> + <Head text={grid.heads[i].mastered ? `★${i + 1}` : String(i + 1)} t={t} gold={grid.heads[i].mastered} /> + {row.map((c) => ( + <View key={c.b} accessibilityLabel={c.detail} style={[styles.cell, { backgroundColor: STATUS[c.status] || t.soft }]}> + <Text style={styles.cellMark}>{c.mark}</Text> + </View> + ))} + </View> + ))} + </View> + </ScrollView> + <View style={styles.actions}> + <Button testID="pd-again" title={next || 'Again'} onPress={onAgain} style={styles.flex} /> + <Button kind="secondary" title="Menu" onPress={onExit} style={styles.flex} /> + </View> + </View> + ); +} + +// A row or column number on the edge of the grid (★ when the whole line is mastered) +function Head({ text, t, gold = false }) { + return ( + <View style={styles.cell}> + <Text style={[styles.head, { color: gold ? t.goldInk : t.muted }]} numberOfLines={1} adjustsFontSizeToFit> + {text} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + journeyResult: { gap: 2, padding: 10, borderWidth: 2, borderRadius: 10 }, + fill: { flex: 1 }, + flex: { flex: 1 }, + wide: { alignSelf: 'stretch' }, + header: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingHorizontal: 12, paddingVertical: 8 }, + close: { width: 40, height: 40, borderRadius: 20, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + closeText: { fontSize: 18, fontWeight: '800' }, + headerMid: { flex: 1, gap: 6 }, + counter: { fontSize: 15, fontWeight: '700' }, + bar: { height: 8, borderRadius: 4, overflow: 'hidden' }, + question: { flex: 1, justifyContent: 'center', gap: 14, paddingHorizontal: 16, paddingBottom: 16 }, + timer: { flexDirection: 'row', alignItems: 'center', gap: 10 }, + timerText: { fontSize: 18, fontWeight: '800', minWidth: 70 }, + timerBar: { flex: 1, height: 10, borderRadius: 5, overflow: 'hidden' }, + questionRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 12 }, + q: { fontSize: 44, fontWeight: '800' }, + answerBox: { minWidth: 130, borderWidth: 3, borderRadius: 14, paddingVertical: 6, alignItems: 'center' }, + answerText: { fontSize: 44, fontWeight: '800' }, + feedback: { fontSize: 17, textAlign: 'center', fontWeight: '600', lineHeight: 23, minHeight: 46 }, + helpScreen: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 20, paddingHorizontal: 16, paddingBottom: 16 }, + helpText: { fontSize: 22, fontWeight: '700', textAlign: 'center', lineHeight: 30, minHeight: 60 }, + done: { padding: 16, gap: 10, paddingBottom: 24 }, + sub: { fontSize: 18, fontWeight: '800', textAlign: 'center' }, + groupTitle: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1, marginTop: 8 }, + change: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth }, + changeFact: { fontSize: 16, fontWeight: '800' }, + changeWhat: { fontSize: 15, fontWeight: '700' }, + small: { fontSize: 14, lineHeight: 20 }, + grid: { gap: 2 }, + gridRow: { flexDirection: 'row', gap: 2 }, + cell: { flex: 1, aspectRatio: 1, borderRadius: 3, alignItems: 'center', justifyContent: 'center' }, + cellMark: { color: '#fff', fontSize: 11, fontWeight: '900' }, + head: { fontSize: 12, fontWeight: '800' }, + actions: { flexDirection: 'row', gap: 12, padding: 16, paddingTop: 8 }, +}); diff --git a/apps/mobile/src/SettingsScreen.js b/apps/mobile/src/SettingsScreen.js new file mode 100644 index 0000000..836f3aa --- /dev/null +++ b/apps/mobile/src/SettingsScreen.js @@ -0,0 +1,65 @@ +// Settings: the web game's settings and words (@blockout/progress/settings), +// the ones the app's game honours so far. A locked choice shows 🔒; tapping it +// offers its unlock. +import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Settings from '@blockout/progress/settings'; +import { useTheme } from './theme'; +import { ScreenHeader } from './ui'; + +// (all of them; the order and words are the web game's) +const IN_THE_APP = ['theme', 'autoRoll', 'placeMode', 'cpuSpeed', 'cpuSteps', 'cpuEnd', 'answerTime', 'showProgress', 'timesMastered', 'timesLearning', 'timesPractice']; + +// onSet(key, value): pick a choice; onLocked(itemId): a locked one was tapped +export default function SettingsScreen({ progress, settings, onSet, onLocked, onClose }) { + const t = useTheme(); + const list = Settings.SETTINGS.filter((s) => IN_THE_APP.includes(s.key)); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Settings" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page}> + {list.map((s) => ( + <View key={s.key} style={styles.setting}> + <Text style={[styles.label, { color: t.ink }]}>{s.label}</Text> + <View style={styles.options} accessibilityRole="radiogroup" accessibilityLabel={s.label}> + {s.options.map((o) => { + const on = String(settings[s.key]) === String(o.value); + const open = Settings.allowed(progress, s.key, o.value); + return ( + <Pressable + key={String(o.value)} + testID={`setting-${s.key}-${o.value}`} + accessibilityRole="radio" + accessibilityLabel={`${o.label}${open ? '' : ', locked'}`} + accessibilityState={{ selected: on }} + onPress={() => (open ? onSet(s.key, o.value) : onLocked(Settings.unlockFor(s.key, o.value)))} + style={({ pressed }) => [ + styles.option, + { backgroundColor: on ? t.accentSoft : t.surface, borderColor: on ? t.accent : t.line, opacity: open ? 1 : 0.55, transform: [{ scale: pressed ? 0.97 : 1 }] }, + ]} + > + <Text style={[styles.optionText, { color: t.ink }]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.8}> + {open ? '' : '🔒 '} + {o.label} + </Text> + </Pressable> + ); + })} + </View> + {s.help ? <Text style={[styles.help, { color: t.muted }]}>{s.help}</Text> : null} + </View> + ))} + </ScrollView> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 22, paddingBottom: 40 }, + setting: { gap: 8 }, + label: { fontSize: 17, fontWeight: '800' }, + options: { flexDirection: 'row', gap: 8 }, + option: { flex: 1, minHeight: 44, paddingHorizontal: 6, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + optionText: { fontSize: 15, fontWeight: '700' }, + help: { fontSize: 14, lineHeight: 20 }, +}); diff --git a/apps/mobile/src/Shop.js b/apps/mobile/src/Shop.js new file mode 100644 index 0000000..a1ada9b --- /dev/null +++ b/apps/mobile/src/Shop.js @@ -0,0 +1,146 @@ +// The Shop and the unlock sheet, drawn from @blockout/progress/shop (the same +// rules and words as the web game's Shop). +import { useState } from 'react'; +import { Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Shop from '@blockout/progress/shop'; +import { useTheme } from './theme'; +import { Button } from './ui'; + +// The whole Shop: groups of rows, All | Available, points at the top. +// onAsk(itemId) opens the unlock sheet for a row. +export function ShopScreen({ progress, onAsk, onClose }) { + const t = useTheme(); + const [onlyAvailable, setOnlyAvailable] = useState(false); + const only = onlyAvailable ? Shop.availableIds(progress) : null; + const groups = Shop.shopGroups(progress, only); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <View style={styles.header}> + <Pressable onPress={onClose} accessibilityRole="button" accessibilityLabel="Back to the menu" hitSlop={10} style={[styles.close, { borderColor: t.line, backgroundColor: t.surface }]}> + <Text style={[styles.closeText, { color: t.ink }]}>✕</Text> + </Pressable> + <Text style={[styles.title, { color: t.ink }]}>Shop</Text> + <Text testID="shop-wallet" style={[styles.wallet, { color: t.goldInk, backgroundColor: t.goldBg }]}> + 🪙 {Shop.formatPoints(progress.wallet)} + </Text> + </View> + <View style={styles.toggle}> + {[ + [false, 'All'], + [true, 'Available'], + ].map(([value, label]) => ( + <Pressable + key={label} + testID={`shop-${label.toLowerCase()}`} + accessibilityRole="radio" + accessibilityState={{ selected: onlyAvailable === value }} + onPress={() => setOnlyAvailable(value)} + style={[styles.toggleOption, { backgroundColor: onlyAvailable === value ? t.accentSoft : t.surface, borderColor: onlyAvailable === value ? t.accent : t.line }]} + > + <Text style={[styles.toggleText, { color: t.ink }]}>{label}</Text> + </Pressable> + ))} + </View> + <ScrollView contentContainerStyle={styles.list}> + {onlyAvailable && !groups.length ? <Text style={[styles.empty, { color: t.muted }]}>Nothing to unlock right now. Keep playing to earn points!</Text> : null} + {groups.map((g) => ( + <View key={g.name} style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}>{g.name}</Text> + {g.rows.map((r) => ( + <ShopRow key={r.item.id} row={r} onAsk={onAsk} /> + ))} + </View> + ))} + </ScrollView> + </View> + ); +} + +function ShopRow({ row, onAsk }) { + const t = useTheme(); + const { item, owned, hidden, canBuy, why } = row; + return ( + <Pressable + testID={`shop-item-${item.id}`} + accessibilityRole="button" + accessibilityLabel={hidden ? 'Locked' : `${item.name}${owned ? ', unlocked' : `, ${item.price} points`}`} + disabled={owned} + onPress={() => onAsk(item.id)} + style={({ pressed }) => [styles.row, { backgroundColor: t.surface, borderColor: t.line, opacity: hidden ? 0.6 : 1, transform: [{ scale: pressed ? 0.98 : 1 }] }]} + > + <View style={styles.rowText}> + <Text style={[styles.rowName, { color: hidden ? t.muted : t.ink }]}>{hidden ? 'Locked' : item.name}</Text> + {!hidden && item.perk ? <Text style={[styles.rowPerk, { color: t.goodInk }]}>🎁 {item.perk}</Text> : null} + {why ? <Text style={[styles.rowWhy, { color: t.muted }]}>{why}</Text> : null} + </View> + {owned ? ( + <Text style={[styles.owned, { color: t.goodInk }]}>✓ Unlocked</Text> + ) : ( + <View style={[styles.price, { backgroundColor: canBuy ? t.accentButton : t.soft, borderColor: canBuy ? t.accentButton : t.line }]}> + <Text style={[styles.priceText, { color: canBuy ? t.accentInk : t.muted }]}>{hidden ? '🔒' : `🪙 ${Shop.formatPoints(item.price)}`}</Text> + </View> + )} + </Pressable> + ); +} + +// "Unlock X?" (or what comes first, or how to reveal it): Shop.unlockOffer's +// answer, with Unlock / Cancel, or just OK +export function UnlockSheet({ offer, onBuy, onClose }) { + const t = useTheme(); + if (!offer) return null; + const yes = offer.kind === 'offer'; + return ( + <Modal transparent animationType="fade" visible onRequestClose={onClose}> + <Pressable style={[styles.backdrop]} onPress={onClose} accessibilityLabel="Close"> + <Pressable style={[styles.sheet, { backgroundColor: t.paper, borderColor: t.line }]} onPress={() => {}}> + <Text testID="unlock-title" style={[styles.sheetTitle, { color: t.ink }]}> + {offer.title} + </Text> + {offer.kind !== 'earn' ? <Text style={[styles.sheetPrice, { color: t.goldInk }]}>{offer.price}</Text> : null} + {offer.message ? ( + <Text testID="unlock-message" style={[styles.sheetMessage, { color: t.ink }]}> + {offer.message} + </Text> + ) : null} + <View style={styles.sheetActions}> + {yes ? <Button testID="unlock-yes" title={`Unlock for 🪙 ${Shop.formatPoints(offer.item.price)}`} onPress={() => onBuy(offer.item.id)} style={styles.flex} /> : null} + <Button testID="unlock-no" kind={yes ? 'secondary' : 'primary'} title={yes ? 'Cancel' : 'OK'} onPress={onClose} style={yes ? styles.cancel : styles.flex} /> + </View> + </Pressable> + </Pressable> + </Modal> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1 }, + header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }, + close: { width: 40, height: 40, borderRadius: 20, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + closeText: { fontSize: 18, fontWeight: '800' }, + title: { flex: 1, fontSize: 26, fontWeight: '900' }, + wallet: { fontSize: 17, fontWeight: '800', paddingVertical: 6, paddingHorizontal: 12, borderRadius: 999, overflow: 'hidden' }, + toggle: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 8 }, + toggleOption: { flex: 1, height: 40, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + toggleText: { fontSize: 15, fontWeight: '700' }, + list: { padding: 16, paddingTop: 4, gap: 18, paddingBottom: 40 }, + empty: { fontSize: 16, textAlign: 'center', marginTop: 24 }, + group: { gap: 8 }, + groupTitle: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, + row: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 12, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth * 2 }, + rowText: { flex: 1, gap: 3 }, + rowName: { fontSize: 16, fontWeight: '700' }, + rowPerk: { fontSize: 14 }, + rowWhy: { fontSize: 13 }, + owned: { fontSize: 15, fontWeight: '700' }, + price: { minWidth: 72, paddingVertical: 8, paddingHorizontal: 10, borderRadius: 10, borderWidth: 1, alignItems: 'center' }, + priceText: { fontSize: 15, fontWeight: '800' }, + backdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' }, + sheet: { margin: 12, marginBottom: 24, padding: 20, borderRadius: 20, borderWidth: StyleSheet.hairlineWidth * 2, gap: 10 }, + sheetTitle: { fontSize: 22, fontWeight: '800' }, + sheetPrice: { fontSize: 16, fontWeight: '700' }, + sheetMessage: { fontSize: 16, lineHeight: 22 }, + sheetActions: { flexDirection: 'row', gap: 10, marginTop: 8 }, + cancel: { minWidth: 110 }, +}); diff --git a/apps/mobile/src/StatsScreen.js b/apps/mobile/src/StatsScreen.js new file mode 100644 index 0000000..6f8f299 --- /dev/null +++ b/apps/mobile/src/StatsScreen.js @@ -0,0 +1,198 @@ +// Stats: the same report as the web game's (@blockout/progress/stats), laid +// out for a phone: tiles, a card per game type, the Times Table and the facts +// to practice, and the recent games. +import { useEffect, useState } from 'react'; +import { ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Settings from '@blockout/progress/settings'; +import * as Stats from '@blockout/progress/stats'; +import * as Views from '@blockout/progress/views'; +import { loadHistory } from './history'; +import { useTheme } from './theme'; +import { ScreenHeader } from './ui'; + +// The fact statuses' colours (the web grid's) +// The Times Table's colours (also the grown-ups' mastery bars) +export const STATUS = { mastered: '#2a9d5c', learning: '#f2c94c', practice: '#d64533', unseen: null }; + +export default function StatsScreen({ progress, settings = Settings.DEFAULT_SETTINGS, onClose }) { + const t = useTheme(); + const [history, setHistory] = useState(null); + useEffect(() => { + loadHistory().then(setHistory); + }, []); + if (!history) return <View style={[styles.fill, { backgroundColor: t.bg }]} />; + const report = Stats.statsReport(history, progress); + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title="Stats" onClose={onClose} /> + <ScrollView contentContainerStyle={styles.page}> + {report.empty ? ( + <Text testID="stats-empty" style={[styles.empty, { color: t.muted }]}> + No games or practice yet. Play one and your stats will show up here! + </Text> + ) : null} + {report.tiles.length ? ( + <View style={styles.tiles}> + {report.tiles.map((tile) => ( + <View key={tile.label} style={[styles.tile, { backgroundColor: t.surface, borderColor: t.line }]}> + <Text testID="stats-tile" style={[styles.tileValue, { color: t.ink }]} numberOfLines={1} adjustsFontSizeToFit> + {tile.value} + </Text> + <Text style={[styles.tileLabel, { color: t.muted }]}>{tile.label}</Text> + </View> + ))} + </View> + ) : null} + {report.tables.map((table) => ( + <View key={table.heading} style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}>{table.heading}</Text> + {table.rows.map((row) => ( + <View key={`${row.title}${row.sub}`} style={[styles.card, { backgroundColor: t.surface, borderColor: t.line }]}> + <Text style={[styles.cardTitle, { color: t.ink }]}> + {row.title} <Text style={{ color: t.muted, fontWeight: '600' }}>· {row.sub}</Text> + </Text> + <View style={styles.values}> + {table.columns.map((col, i) => ( + <View key={col} style={styles.value}> + <Text style={[styles.valueNum, { color: t.ink }]}>{String(row.values[i])}</Text> + <Text style={[styles.valueLabel, { color: t.muted }]}>{col}</Text> + </View> + ))} + </View> + </View> + ))} + </View> + ))} + {report.players.length ? ( + <View style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}>Times Table</Text> + <View style={styles.legend}> + {Object.entries(Stats.FACT_STATUS).map(([status, { mark, label }]) => ( + <View key={status} style={styles.legendItem}> + <View style={[styles.legendCell, { backgroundColor: STATUS[status] || t.soft }]}> + <Text style={styles.cellMark}>{mark}</Text> + </View> + <Text style={[styles.small, { color: t.ink }]}>{label}</Text> + </View> + ))} + </View> + {report.players.map((p) => ( + <PlayerTable key={p.name} progress={progress} player={p} showTime={Settings.showsTime(settings)} /> + ))} + </View> + ) : null} + {report.recent.length ? ( + <View style={styles.group}> + <Text style={[styles.groupTitle, { color: t.muted }]}>Recent games</Text> + {report.recent.map((line, i) => ( + <View key={i} style={[styles.recent, { borderColor: t.line }]}> + <Text style={[styles.small, { color: t.muted }]}>{line.meta}</Text> + <Text style={[styles.recentText, { color: t.ink }]}> + {line.text} · <Text style={{ fontWeight: '800' }}>{line.winner}</Text> + </Text> + </View> + ))} + </View> + ) : null} + </ScrollView> + </View> + ); +} + +// One player's grid, then the facts they miss most +function PlayerTable({ progress, player, showTime }) { + const t = useTheme(); + const facts = Stats.playerFacts(progress, player.name); + const practice = Stats.factsToPractice(facts); + return ( + <View style={styles.player}> + {player.label ? <Text style={[styles.cardTitle, { color: t.ink }]}>{player.label}</Text> : null} + <FactGrid facts={facts} unlocked={Views.tableSize(progress)} showTime={showTime} /> + <Text style={[styles.cardTitle, { color: t.ink }]}>{player.label ? `${player.label}: facts to practice` : 'Facts to practice'}</Text> + {practice.length ? ( + practice.map((f) => ( + <View key={f.fact} style={[styles.card, { backgroundColor: t.surface, borderColor: t.line }]}> + <Text style={[styles.cardTitle, { color: t.ink }]}> + {f.fact} <Text style={{ color: t.bad }}>· missed {f.count}×</Text> + </Text> + <Text style={[styles.small, { color: t.muted }]}> + {f.rate} + {f.answers && f.answers.length ? ` · said ${f.answers.slice(-3).join(', ')}` : ''} + </Text> + </View> + )) + ) : ( + <Text style={[styles.small, { color: t.muted }]}>{Object.keys(facts).length ? 'Nothing missed so far! 🌟' : 'No answers yet.'}</Text> + )} + </View> + ); +} + +// A Times Table: each fact's colour (mastered, learning, needs practice, not +// seen), with a ★ on a line that's all mastered. size: a fixed size (a +// grown-up's view: 12), else up to what's unlocked. +export function FactGrid({ facts, size = null, unlocked = 12, showTime = () => false }) { + const t = useTheme(); + const grid = Stats.factGrid(facts, { size, unlocked, showTime }); + return ( + <View style={styles.grid} accessibilityLabel={`Times Table up to ${grid.max} × ${grid.max}`}> + <View style={styles.gridRow}> + <Cell text="×" t={t} /> + {grid.heads.map((h) => ( + <Cell key={h.n} text={h.mastered ? `★${h.n}` : String(h.n)} t={t} gold={h.mastered} /> + ))} + </View> + {grid.cells.map((row, r) => ( + <View key={r} style={styles.gridRow}> + <Cell text={grid.heads[r].mastered ? `★${r + 1}` : String(r + 1)} t={t} gold={grid.heads[r].mastered} /> + {row.map((c) => ( + <View key={c.b} accessibilityLabel={c.detail} style={[styles.cell, styles.fact, { backgroundColor: STATUS[c.status] || t.soft }]}> + <Text style={styles.cellMark}>{c.mark}</Text> + </View> + ))} + </View> + ))} + </View> + ); +} + +function Cell({ text, t, gold = false }) { + return ( + <View style={styles.cell}> + <Text style={[styles.head, { color: gold ? t.goldInk : t.muted }]} numberOfLines={1} adjustsFontSizeToFit> + {text} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 18, paddingBottom: 40 }, + empty: { fontSize: 16, textAlign: 'center', marginTop: 24, lineHeight: 22 }, + tiles: { flexDirection: 'row', gap: 8 }, + tile: { flex: 1, padding: 10, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth * 2, alignItems: 'center', gap: 2 }, + tileValue: { fontSize: 22, fontWeight: '900' }, + tileLabel: { fontSize: 12, textAlign: 'center' }, + group: { gap: 8 }, + groupTitle: { fontSize: 13, fontWeight: '700', textTransform: 'uppercase', letterSpacing: 1 }, + card: { padding: 12, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth * 2, gap: 8 }, + cardTitle: { fontSize: 16, fontWeight: '800' }, + values: { flexDirection: 'row', flexWrap: 'wrap', rowGap: 8 }, + value: { width: '33%' }, + valueNum: { fontSize: 17, fontWeight: '800' }, + valueLabel: { fontSize: 12 }, + legend: { flexDirection: 'row', flexWrap: 'wrap', gap: 12 }, + legendItem: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + legendCell: { width: 22, height: 22, borderRadius: 5, alignItems: 'center', justifyContent: 'center' }, + small: { fontSize: 13, lineHeight: 18 }, + player: { gap: 10 }, + grid: { gap: 2 }, + gridRow: { flexDirection: 'row', gap: 2 }, + cell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center' }, + head: { fontSize: 12, fontWeight: '800' }, + fact: { borderRadius: 3 }, + cellMark: { color: '#fff', fontSize: 11, fontWeight: '900' }, + recent: { paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, gap: 2 }, + recentText: { fontSize: 15 }, +}); diff --git a/apps/mobile/src/StudentPage.js b/apps/mobile/src/StudentPage.js new file mode 100644 index 0000000..b9c8cb8 --- /dev/null +++ b/apps/mobile/src/StudentPage.js @@ -0,0 +1,222 @@ +// One child, for their teacher or grown-up (as on the web game): their +// Times Table, homework (yours can come off; set more), and what they played +// lately. A parent can also delete their child's account. The words and rules +// are shared (@blockout/progress/grownups, /homework). +import { useEffect, useState } from 'react'; +import { ActivityIndicator, ScrollView, StyleSheet, Text, View } from 'react-native'; +import * as Grownups from '@blockout/progress/grownups'; +import * as Homework from '@blockout/progress/homework'; +import * as Account from './account'; +import { FactGrid } from './StatsScreen'; +import { Chips, ErrorLine, Field, Help, MasteryBar, Row, Section, SmallButton, TextBox, ask, dueChoices } from './forms'; +import { useTheme } from './theme'; +import { Button, ScreenHeader } from './ui'; + +// me: the signed-in teacher or parent; onBack(); onDeleted() (a parent deleted the account) +export default function StudentPage({ id, me, toast, onBack, onDeleted }) { + const t = useTheme(); + const [s, setS] = useState(null); + const [error, setError] = useState(null); + const load = () => + Account.api('GET', `/api/students/${id}`) + .then(setS) + .catch((e) => setError(e.message)); + useEffect(() => { + load(); + }, [id]); + + async function remove(h) { + if (!(await ask(`Remove “${h.label}”?`, 'It comes off their homework list.', 'Remove'))) return; + await Account.api('DELETE', `/api/homework/${h.id}`).catch((e) => toast('⚠️ Couldn’t remove it', e.message)); + load(); + } + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader title={s ? s.name : 'Student'} onClose={onBack} closeLabel="Back" /> + <ScrollView contentContainerStyle={styles.page} keyboardShouldPersistTaps="handled"> + {error ? <Help>{error}</Help> : !s ? <ActivityIndicator color={t.accent} /> : null} + {s ? ( + <> + <Help>Last played {Grownups.ago(s.lastPlayed)}</Help> + <Section title="Times Table"> + <View style={styles.counts}> + {Grownups.STATUS_ORDER.map(([k, label]) => ( + <Text key={k} style={[styles.count, { color: t.ink }]}> + {s.summary[k]} {label.toLowerCase()} + </Text> + ))} + </View> + <MasteryBar summary={s.summary} /> + {s.facts ? <FactGrid facts={s.facts} size={12} unlocked={12} /> : null} + {s.summary.needsPractice.length ? ( + <> + <Help>Needs practice:</Help> + <View style={styles.chips}> + {s.summary.needsPractice.map((f) => ( + <Text key={f} style={[styles.fact, { color: t.bad, borderColor: t.bad }]}> + {f} + </Text> + ))} + </View> + </> + ) : null} + </Section> + <Section title="Homework"> + {!s.assignments.length ? <Help>No homework.</Help> : null} + {s.assignments.map((h) => { + const row = Homework.homeworkRow(h); + return ( + <Row key={h.id} testID="student-homework" right={Grownups.canRemove(h, me) ? <SmallButton title="Remove" onPress={() => remove(h)} /> : null}> + <Text style={[styles.strong, { color: t.ink }]}>{row.label}</Text> + <Text style={[styles.small, { color: t.muted }]}> + {row.state} · {row.have} of {row.need} · {row.from} + </Text> + </Row> + ); + })} + <HomeworkForm + options={s.options} + who={s.name} + toast={toast} + onSubmit={async ({ goal, due }) => { + await Account.api('POST', '/api/homework', { studentId: s.id, goal, due }); + load(); + }} + /> + </Section> + <Section title="Recently"> + {!s.recent.length ? <Help>Nothing played yet.</Help> : null} + {s.recent.map((e, i) => ( + <View key={i} style={[styles.recent, { borderColor: t.line }]}> + <Text style={[styles.recentText, { color: t.ink }]}>{Grownups.activityText(e)}</Text> + <Text style={[styles.small, { color: t.muted }]}>{Grownups.ago(e.t)}</Text> + </View> + ))} + </Section> + {me.role === 'parent' ? <DeleteChild child={s} onDeleted={onDeleted} /> : null} + </> + ) : null} + </ScrollView> + </View> + ); +} + +// Set homework: what it is, its details, when it's due. Only what the child +// (or every child in a class) can play already: options from the server. +export function HomeworkForm({ options, who, toast, onSubmit }) { + const t = useTheme(); + const c = Grownups.homeworkChoices(options); + const [v, setV] = useState(c.start); + const [due, setDue] = useState(dueChoices()[3][0]); // (a week) + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const uses = Grownups.homeworkFields(v.type); + const set = (part) => setV((now) => ({ ...now, ...part })); + const go = async () => { + setError(null); + setBusy(true); + const goal = Grownups.goalFrom(v); + try { + await onSubmit({ goal, due: due || null }); + toast(...Grownups.homeworkSet(goal)); + } catch (e) { + setError(e.message); + } finally { + setBusy(false); + } + }; + return ( + <View style={[styles.form, { backgroundColor: t.paper, borderColor: t.line }]}> + <Text style={[styles.strong, { color: t.ink }]}>Add homework</Text> + <Field label="Homework"> + <Chips testID="hw-type" options={c.kinds} value={v.type} onChange={(type) => set({ type })} /> + </Field> + {uses.table ? ( + <Field label="Table"> + <Chips testID="hw-table" options={c.tables.map((n) => [n, `×${n}`])} value={v.table} onChange={(table) => set({ table })} /> + </Field> + ) : null} + {uses.rounds ? ( + <Field label="Rounds"> + <Chips testID="hw-rounds" options={Grownups.HOMEWORK_COUNTS.map((n) => [n, String(n)])} value={v.rounds} onChange={(rounds) => set({ rounds })} /> + </Field> + ) : null} + {uses.games ? ( + <Field label="Games"> + <Chips testID="hw-games" options={Grownups.HOMEWORK_COUNTS.map((n) => [n, String(n)])} value={v.games} onChange={(games) => set({ games })} /> + </Field> + ) : null} + {uses.size ? ( + <Field label="Board"> + <Chips testID="hw-size" options={c.boards.map((n) => [n, `${n}×${n}`])} value={v.size} onChange={(size) => set({ size })} /> + </Field> + ) : null} + {uses.difficulty ? ( + <Field label="Difficulty"> + <Chips testID="hw-difficulty" options={c.difficulties} value={v.difficulty} onChange={(difficulty) => set({ difficulty })} /> + </Field> + ) : null} + <Field label="Due"> + <Chips testID="hw-due" options={dueChoices()} value={due} onChange={setDue} /> + </Field> + <Help>{Grownups.homeworkNote(who)}</Help> + <ErrorLine error={error} /> + <Button testID="hw-set" title={busy ? 'Setting it…' : 'Set homework'} onPress={go} disabled={busy} /> + </View> + ); +} + +// A parent deletes their child's account: typed, because it can't be undone +function DeleteChild({ child, onDeleted }) { + const t = useTheme(); + const first = child.name.split(' ')[0]; + const [asking, setAsking] = useState(false); + const [typed, setTyped] = useState(''); + const [error, setError] = useState(null); + if (!asking) return <Button testID="delete-child" kind="secondary" title={`Delete ${child.name}’s account`} onPress={() => setAsking(true)} />; + const sure = typed.trim().toLowerCase() === first.toLowerCase(); + return ( + <View style={[styles.form, { borderColor: t.bad }]}> + <Text style={[styles.help, { color: t.ink }]}> + Delete {child.name}’s Blockout account, with their progress, classes and homework? This can’t be undone. Type {first} to go ahead. + </Text> + <TextBox testID="delete-child-typed" value={typed} onChangeText={setTyped} placeholder={first} autoCapitalize="words" /> + <ErrorLine error={error} /> + <View style={styles.buttons}> + <Button kind="secondary" title="Cancel" onPress={() => setAsking(false)} style={styles.flex} /> + <Button + testID="delete-child-confirm" + title="Delete" + disabled={!sure} + style={[styles.flex, { backgroundColor: t.bad, shadowColor: t.bad }]} + onPress={async () => { + try { + await Account.api('DELETE', `/api/students/${child.id}`); + onDeleted(); + } catch (e) { + setError(e.message); + } + }} + /> + </View> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + flex: { flex: 1 }, + page: { padding: 16, paddingTop: 4, gap: 22, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + help: { fontSize: 15, lineHeight: 21 }, + counts: { flexDirection: 'row', flexWrap: 'wrap', columnGap: 14, rowGap: 4 }, + count: { fontSize: 15, fontWeight: '700' }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + fact: { fontSize: 14, fontWeight: '800', borderWidth: 2, borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3, overflow: 'hidden' }, + strong: { fontSize: 16, fontWeight: '800' }, + small: { fontSize: 13, lineHeight: 18 }, + form: { borderWidth: 2, borderRadius: 14, padding: 12, gap: 12 }, + recent: { paddingVertical: 8, borderBottomWidth: StyleSheet.hairlineWidth, gap: 2 }, + recentText: { fontSize: 15 }, + buttons: { flexDirection: 'row', gap: 8 }, +}); diff --git a/apps/mobile/src/Wardrobe.js b/apps/mobile/src/Wardrobe.js new file mode 100644 index 0000000..506717e --- /dev/null +++ b/apps/mobile/src/Wardrobe.js @@ -0,0 +1,218 @@ +// The Wardrobe: every look by category, as chips (a preview and its name). +// Owned ones are worn with a tap; locked ones show their price and open the +// unlock sheet; later steps of a chain are blurred placeholders. Titles are +// earned, not bought. The rows are @blockout/progress/wardrobe's, as on the web. +import { useState } from 'react'; +import { Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native'; +import Svg, { Line, Rect } from 'react-native-svg'; +import * as Shop from '@blockout/progress/shop'; +import * as Wardrobe from '@blockout/progress/wardrobe'; +import { BoardDecor, PatternDefs, boardSkin, diceSkin, hasPattern, keypadSkin, patternFill, useLooks } from './looks'; +import { useTheme } from './theme'; +import { Die, ScreenHeader } from './ui'; + +const PREVIEW = 36; + +// onWear(categoryId, optionId); onAsk(itemId): a locked look was tapped. +// newKeys: the looks that were new when it opened (for the New filter). +export default function WardrobeScreen({ progress, newKeys, onWear, onAsk, onClose }) { + const t = useTheme(); + const looks = useLooks(); + const { width } = useWindowDimensions(); + const [onlyNew, setOnlyNew] = useState(false); + const only = onlyNew ? Wardrobe.availableKeys(progress, newKeys) : null; + const rows = Wardrobe.wardrobeRows(progress, looks, only); + const titles = Wardrobe.titleRow(progress, looks, only); + // three chips a row (four on a wide screen) + const columns = width >= 600 ? 4 : 3; + const chipWidth = (Math.min(width, 700) - 32 - 8 * (columns - 1)) / columns - 0.5; + + return ( + <View style={[styles.fill, { backgroundColor: t.bg }]}> + <ScreenHeader + title="Wardrobe" + onClose={onClose} + right={ + <Text testID="wardrobe-wallet" style={[styles.wallet, { color: t.goldInk, backgroundColor: t.goldBg }]}> + 🪙 {Shop.formatPoints(progress.wallet)} + </Text> + } + /> + <View style={styles.toggle}> + {[ + [false, 'All'], + [true, 'New'], + ].map(([value, label]) => ( + <Pressable + key={label} + testID={`wardrobe-${label.toLowerCase()}`} + accessibilityRole="radio" + accessibilityState={{ selected: onlyNew === value }} + onPress={() => setOnlyNew(value)} + style={[styles.toggleOption, { backgroundColor: onlyNew === value ? t.accentSoft : t.surface, borderColor: onlyNew === value ? t.accent : t.line }]} + > + <Text style={[styles.toggleText, { color: t.ink }]}>{label}</Text> + </Pressable> + ))} + </View> + <ScrollView contentContainerStyle={styles.page}> + {onlyNew && !rows.length && !titles.length ? <Text style={[styles.empty, { color: t.muted }]}>Nothing new to wear yet. Keep playing to earn points!</Text> : null} + {rows.map(({ category, options }) => ( + <View key={category.id} style={styles.group}> + <Text style={[styles.heading, { color: t.muted }]}>{category.name}</Text> + <View style={styles.chips}> + {options.map((o) => ( + <Chip key={o.key} o={o} category={category} width={chipWidth} onPress={() => (o.owned ? onWear(category.id, o.option.id) : onAsk(o.item))} /> + ))} + </View> + </View> + ))} + {titles.length ? ( + <View style={styles.group}> + <Text style={[styles.heading, { color: t.muted }]}>Your title</Text> + <View style={styles.chips}> + {titles.map(({ title, key, earned, worn }) => ( + <Pressable + key={key} + testID={`look-${key}`} + accessibilityRole="radio" + accessibilityState={{ selected: worn, disabled: !earned }} + accessibilityLabel={`${title.name}${earned ? '' : `, locked: ${title.need}`}`} + disabled={!earned} + onPress={() => onWear('title', title.id)} + style={({ pressed }) => [styles.chip, styles.titleChip, chipColors(t, worn), { width: chipWidth, opacity: earned ? 1 : 0.55, transform: [{ scale: pressed ? 0.96 : 1 }] }]} + > + <Text style={[styles.name, { color: t.ink }]} numberOfLines={2}> + {title.name} + </Text> + {earned ? null : ( + <Text style={[styles.need, { color: t.muted }]} numberOfLines={3}> + {title.need} + </Text> + )} + </Pressable> + ))} + </View> + </View> + ) : null} + </ScrollView> + </View> + ); +} + +const chipColors = (t, worn) => ({ backgroundColor: worn ? t.accentSoft : t.surface, borderColor: worn ? t.accent : t.line }); + +function Chip({ o, category, width, onPress }) { + const t = useTheme(); + const tag = o.hidden ? '🔒' : o.season ? `🔒 ${o.season}` : `🔒 ${Shop.formatPoints(o.price)}`; + return ( + <Pressable + testID={`look-${o.key}`} + accessibilityRole="radio" + accessibilityState={{ selected: o.worn }} + accessibilityLabel={o.hidden ? 'Locked: unlock the one before it first' : `${o.option.name}${o.owned ? '' : `, locked, ${o.season || `${o.price} points`}`}`} + onPress={onPress} + style={({ pressed }) => [styles.chip, chipColors(t, o.worn), { width, transform: [{ scale: pressed ? 0.96 : 1 }] }]} + > + {o.hidden ? ( + // a later step of the chain: no preview, no name + <View style={[styles.skeleton, { backgroundColor: t.soft }]} /> + ) : ( + <View style={{ opacity: o.owned ? 1 : 0.6 }}> + <Preview category={category.id} option={o.option} /> + </View> + )} + <Text style={[styles.name, { color: o.hidden ? t.muted : t.ink }]} numberOfLines={2}> + {o.hidden ? '???' : o.option.name} + </Text> + {o.owned ? null : ( + <Text style={[styles.lock, { color: t.goldInk, backgroundColor: t.goldBg }]} numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.7}> + {tag} + </Text> + )} + </Pressable> + ); +} + +// What a look looks like, small +function Preview({ category, option }) { + const t = useTheme(); + const looks = useLooks(); + const box = { width: PREVIEW, height: PREVIEW }; + if (category === 'dice') return <Die value={5} size={PREVIEW} skin={diceSkin(t, option.id, looks.pips)} />; + if (category === 'pips') return <Die value={5} size={PREVIEW} skin={diceSkin(t, looks.dice, option.id)} />; + if (category === 'colors') { + return ( + <View style={[box, styles.swatches]}> + {option.colors.map((c) => ( + <View key={c} style={[styles.swatch, { backgroundColor: c }]} /> + ))} + </View> + ); + } + if (category === 'pattern') { + return ( + <Svg {...box}> + <PatternDefs kind={option.id} /> + <Rect x={0} y={0} width={PREVIEW} height={PREVIEW} rx={6} fill={t.accent} /> + {hasPattern(option.id) ? <Rect x={0} y={0} width={PREVIEW} height={PREVIEW} rx={6} fill={patternFill(option.id)} /> : null} + </Svg> + ); + } + if (category === 'board') { + const skin = boardSkin(t, option.id); + const cell = PREVIEW / 4; + return ( + <Svg {...box}> + <Rect x={0} y={0} width={PREVIEW} height={PREVIEW} fill={skin.bg} /> + <BoardDecor kind={skin.decor} side={PREVIEW} cell={cell} /> + {[1, 2, 3].map((i) => [ + <Line key={`v${i}`} x1={i * cell} y1={0} x2={i * cell} y2={PREVIEW} stroke={skin.grid} />, + <Line key={`h${i}`} x1={0} y1={i * cell} x2={PREVIEW} y2={i * cell} stroke={skin.grid} />, + ])} + <Rect x={1} y={1} width={PREVIEW - 2} height={PREVIEW - 2} fill="none" stroke={skin.edge} strokeWidth={2} /> + </Svg> + ); + } + if (category === 'keypad') { + const k = keypadSkin(t, option.id).key; + return ( + <View style={[box, styles.key, { backgroundColor: k.bg, borderColor: k.edge, borderRadius: keypadSkin(t, option.id).radius / 2 }]}> + <Text style={[styles.keyText, { color: k.ink }]}>7</Text> + </View> + ); + } + const glyph = category === 'cpu' ? option.avatar : category === 'roll' ? '🎲' : category === 'place' ? '▦' : option.preview || ''; + return ( + <View style={[styles.center, { height: PREVIEW, minWidth: PREVIEW }]}> + <Text style={[styles.emoji, { color: t.ink }]} numberOfLines={1}> + {glyph} + </Text> + </View> + ); +} + +const styles = StyleSheet.create({ + fill: { flex: 1 }, + wallet: { fontSize: 16, fontWeight: '800', paddingHorizontal: 10, paddingVertical: 6, borderRadius: 999, overflow: 'hidden' }, + toggle: { flexDirection: 'row', gap: 8, paddingHorizontal: 16, paddingBottom: 6 }, + toggleOption: { flex: 1, height: 40, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + toggleText: { fontSize: 15, fontWeight: '800' }, + page: { padding: 16, paddingTop: 6, gap: 18, paddingBottom: 40, maxWidth: 700, width: '100%', alignSelf: 'center' }, + empty: { fontSize: 16, textAlign: 'center', marginTop: 24 }, + group: { gap: 8 }, + heading: { fontSize: 14, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5 }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, + chip: { minHeight: 92, borderRadius: 12, borderWidth: 2, padding: 6, alignItems: 'center', justifyContent: 'center', gap: 4 }, + titleChip: { minHeight: 64 }, + skeleton: { width: PREVIEW, height: PREVIEW, borderRadius: 8 }, + name: { fontSize: 14, fontWeight: '700', textAlign: 'center' }, + need: { fontSize: 11, textAlign: 'center' }, + lock: { fontSize: 12, fontWeight: '800', paddingHorizontal: 6, paddingVertical: 1, borderRadius: 999, overflow: 'hidden' }, + swatches: { flexDirection: 'row', flexWrap: 'wrap' }, + swatch: { width: PREVIEW / 2, height: PREVIEW / 2 }, + key: { borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + keyText: { fontSize: 18, fontWeight: '800' }, + center: { alignItems: 'center', justifyContent: 'center' }, + emoji: { fontSize: 26 }, +}); diff --git a/apps/mobile/src/account.js b/apps/mobile/src/account.js new file mode 100644 index 0000000..d2ce967 --- /dev/null +++ b/apps/mobile/src/account.js @@ -0,0 +1,234 @@ +// Signing in on a phone: Keycloak (and Google behind it) in the system +// browser, with PKCE, as the public client blockout-mobile. Its tokens carry +// "blockout" as an audience, so the server takes them like the web game's. +// Tokens are kept in the device's secure store; the save syncs with the +// account like on the web (src/sync.js). +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as AuthSession from 'expo-auth-session'; +import Constants from 'expo-constants'; +import * as SecureStore from 'expo-secure-store'; +import * as WebBrowser from 'expo-web-browser'; +import { Platform } from 'react-native'; +import * as Progress from '@blockout/progress'; +import { MERGED_KEY, GUEST_KEY, claims, expiresSoon, mergeOnSignIn, progressKey } from './sync'; + +WebBrowser.maybeCompleteAuthSession(); // (web: closes the sign-in popup) + +const { server: SERVER, keycloakClientId: CLIENT_ID } = Constants.expoConfig.extra; +const USER_KEY = 'blockout.user'; +const PUSH_DELAY_MS = 1500; + +// SecureStore is native only; the web build (for testing) keeps them in AsyncStorage +const secret = Platform.OS === 'web' + ? { get: (k) => AsyncStorage.getItem(k), set: (k, v) => AsyncStorage.setItem(k, v), remove: (k) => AsyncStorage.removeItem(k) } + : { get: (k) => SecureStore.getItemAsync(k), set: (k, v) => SecureStore.setItemAsync(k, v), remove: (k) => SecureStore.deleteItemAsync(k) }; +const TOKENS = ['blockout.token', 'blockout.refresh', 'blockout.idToken']; // (one value each: SecureStore's are small) + +let session = null; // { token, refresh, idToken, user } + +export class AccountError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +async function readJson(key) { + try { + return JSON.parse(await AsyncStorage.getItem(key)); + } catch (_) { + return null; + } +} +const writeJson = (key, value) => (value === null ? AsyncStorage.removeItem(key) : AsyncStorage.setItem(key, JSON.stringify(value))); + +async function saveSession(next) { + session = next; + const values = next ? [next.token, next.refresh, next.idToken] : []; + await Promise.all(TOKENS.map((k, i) => (values[i] ? secret.set(k, values[i]) : secret.remove(k)))); + await writeJson(USER_KEY, next ? next.user : null); +} + +// At start: whoever signed in last time +export async function loadSession() { + const [token, refresh, idToken] = await Promise.all(TOKENS.map((k) => secret.get(k))); + const who = await readJson(USER_KEY); + session = token && who ? { token, refresh, idToken, user: who } : null; + return user(); +} + +export const user = () => (session ? session.user : null); + +// The bearer token for other requests (a live class's), renewed when it's +// about to run out; null for a guest (or a sign-in that's over) +export async function freshToken() { + if (!session) return null; + if (expiresSoon(session.token) && !(await refresh())) return null; + return session ? session.token : null; +} + +let configCache = null; +async function discovery() { + if (!configCache) { + let c; + try { + c = await (await fetch(`${SERVER}/api/auth/config`)).json(); + } catch (_) { + throw new AccountError('offline', 'Can’t reach Blockout. Are you online?'); + } + if (c.provider !== 'keycloak') throw new AccountError('server', 'This Blockout doesn’t have accounts.'); + configCache = { issuer: c.issuer, discovery: await AuthSession.fetchDiscoveryAsync(c.issuer) }; + } + return configCache; +} + +// Where Keycloak sends the browser back to: blockout://auth in the app, an +// exp:// address in Expo Go, this page on the web +export const redirectUri = () => AuthSession.makeRedirectUri({ scheme: 'blockout', path: 'auth' }); + +// Opens Keycloak in the system browser. Resolves to the user, or null when +// they closed it. +export async function signIn() { + const { discovery: d } = await discovery(); + const request = new AuthSession.AuthRequest({ clientId: CLIENT_ID, redirectUri: redirectUri(), scopes: ['openid', 'profile', 'email'], usePKCE: true }); + // (iOS: a private browser session, so a shared iPad never stays signed in) + const result = await request.promptAsync(d, { preferEphemeralSession: true }); + if (result.type !== 'success') return null; + let t; + try { + t = await AuthSession.exchangeCodeAsync({ clientId: CLIENT_ID, code: result.params.code, redirectUri: redirectUri(), extraParams: { code_verifier: request.codeVerifier } }, d); + } catch (_) { + throw new AccountError('auth', 'Signing in didn’t work. Please try again.'); + } + await saveSession({ token: t.accessToken, refresh: t.refreshToken, idToken: t.idToken, user: null }); + const { user: me } = await api('GET', '/api/me'); + await saveSession({ ...session, user: me }); + return me; +} + +// Ends the Keycloak session too, so the next sign-in asks again +export async function signOut() { + await flushProgress(); + const ended = session; + await saveSession(null); + await endKeycloakSession(ended); +} + +async function endKeycloakSession(ended) { + if (!ended || !ended.refresh) return; + try { + const { discovery: d } = await discovery(); + await fetch(d.endSessionEndpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: `client_id=${encodeURIComponent(CLIENT_ID)}&refresh_token=${encodeURIComponent(ended.refresh)}`, + }); + } catch (_) { + // offline: the Keycloak session just runs out by itself + } +} + +let refreshing = null; +async function refresh() { + if (!refreshing) { + refreshing = (async () => { + if (!session || !session.refresh) return false; + try { + const { discovery: d } = await discovery(); + const t = await AuthSession.refreshAsync({ clientId: CLIENT_ID, refreshToken: session.refresh }, d); + await saveSession({ ...session, token: t.accessToken, refresh: t.refreshToken || session.refresh, idToken: t.idToken || session.idToken }); + return true; + } catch (_) { + return false; // the Keycloak session is over: sign in again + } + })().finally(() => (refreshing = null)); + } + return refreshing; +} + +// The Blockout API as the signed-in player. A token that can't be renewed +// signs them out here (their save stays on the phone). +export async function api(method, path, body) { + if (session && expiresSoon(session.token) && !(await refresh())) { + await saveSession(null); + throw new AccountError('expired', 'Please sign in again.'); + } + let res; + try { + res = await fetch(`${SERVER}${path}`, { + method, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(session ? { Authorization: `Bearer ${session.token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (_) { + throw new AccountError('offline', 'Can’t reach Blockout. Are you online?'); + } + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new AccountError(data.error || 'server', data.message || 'Something went wrong.'); + return data; +} + +// ---- the save + +export async function loadProgress() { + return Progress.normalize(await readJson(progressKey(user()))); +} + +export async function saveProgress(progress) { + await writeJson(progressKey(user()), progress); + if (user()) queueProgress(progress); +} + +let pushState = null; +let pushTimer = null; +function queueProgress(progress) { + pushState = Progress.withoutCheats(progress); + clearTimeout(pushTimer); + pushTimer = setTimeout(flushProgress, PUSH_DELAY_MS); +} + +export async function flushProgress() { + clearTimeout(pushTimer); + if (!pushState || !user()) return; + const state = pushState; + pushState = null; + await api('PUT', '/api/me/progress', { progress: state }).catch(() => { + pushState = pushState || state; // try again with the next save + }); +} + +// After signing in, and at start while signed in: the account's save and this +// phone's, together. Returns the save to play with. +export async function syncProgress() { + const local = await readJson(progressKey(user())); + if (!user()) return Progress.normalize(local); + const { progress: remote } = await api('GET', '/api/me/progress'); + const merged = (await readJson(MERGED_KEY)) || []; + const firstTimeHere = !merged.includes(user().id); + const { progress, push } = mergeOnSignIn({ remote, guest: await readJson(GUEST_KEY), local, firstTimeHere }); + if (firstTimeHere) await writeJson(MERGED_KEY, [...merged, user().id]); + const result = progress || Progress.createProgress(); + await writeJson(progressKey(user()), result); + if (push) await api('PUT', '/api/me/progress', { progress: Progress.withoutCheats(result) }); + return result; +} + +// The account as the server has it now (a teacher's or a grown-up's code changes its role) +export async function refreshUser() { + const { user: who } = await api('GET', '/api/me'); + if (session) await saveSession({ ...session, user: who }); + return who; +} + +// Deleting your own account (teachers and grown-ups; a child's is removed by +// their grown-up): everything on the server, and its save on this phone +export async function deleteAccount() { + const ended = session; + await api('DELETE', '/api/me'); + await writeJson(progressKey(ended.user), null); + await saveSession(null); + await endKeycloakSession(ended); +} + +// Which Google (or other) sign-in the account came from, for the account screen +export const signedInWith = () => (session && session.idToken ? claims(session.idToken).identity_provider || null : null); diff --git a/apps/mobile/src/boardMath.js b/apps/mobile/src/boardMath.js new file mode 100644 index 0000000..823fbba --- /dev/null +++ b/apps/mobile/src/boardMath.js @@ -0,0 +1,28 @@ +// Board geometry for touch: which square a finger is on, and the rectangle +// between where a drag started and where it is now. No React here: tested in +// test/boardMath.test.js. + +// The square under (x, y), in pixels from the board's top-left. Clamped to the +// board, so a drag that leaves it keeps the nearest square. +export function cellAt(x, y, cellSize, size) { + const clamp = (v) => Math.max(0, Math.min(size - 1, Math.floor(v / cellSize))); + return { c: clamp(x), r: clamp(y) }; +} + +// The rectangle from corner to corner, whichever way the drag went. +export function dragRect({ c0, r0, c1, r1 }) { + return { x: Math.min(c0, c1), y: Math.min(r0, r1), w: Math.abs(c1 - c0) + 1, h: Math.abs(r1 - r0) + 1 }; +} + +// What to say about a drawn rectangle that doesn't count (match.judge's verdict). +export function drawnMessage(rect, dice, verdict) { + const [a, b] = dice; + if (!verdict.rightSize) return `That's ${rect.w} by ${rect.h}, but you rolled ${a} and ${b}. Try again!`; + return 'Right size, but it bumps into another rectangle. Try a different spot!'; +} + +// The board fills the width it's given, in whole pixels per square. +export function boardLayout(width, size) { + const cell = Math.floor(width / size); + return { cell, side: cell * size }; +} diff --git a/apps/mobile/src/celebration.js b/apps/mobile/src/celebration.js new file mode 100644 index 0000000..583b40c --- /dev/null +++ b/apps/mobile/src/celebration.js @@ -0,0 +1,90 @@ +// Win celebrations, worked out ahead: every piece's path (the web game's +// confetti physics, in wallet.js and @blockout/ui/rewards), sampled at a few +// moments so the native animation driver can play them smoothly. Pure. + +export const COLORS = ['#e4572e', '#2e86de', '#2a9d5c', '#8e5bd6', '#f2c94c', '#ff8fab']; +export const DURATION_MS = 4000; +const FRAME_MS = 16.7; +export const SAMPLES = 24; + +// Wardrobe confetti: paper (null), or emoji pieces +const GLYPHS = { stars: ['⭐', '🌟'], hearts: ['💖', '💗', '❤️'], snow: ['❄️'], coins: ['🪙'], blossom: ['🌸', '🌼'] }; +export const confettiGlyphs = (confetti) => (confetti.id === 'emoji' ? confetti.glyphs : GLYPHS[confetti.id] || null); + +// One piece, flying until maxAge (frames), its path sampled SAMPLES + 1 times +// over DURATION_MS: { glyph, color, size, round, xs, ys, spins, opacities } +function fly(start, random) { + const p = { ...start, wobble: random() * Math.PI * 2, spin: random() * Math.PI, age: 0 }; + const frames = DURATION_MS / FRAME_MS; + const out = { glyph: p.glyph, color: p.color, size: p.size, round: p.round, delay: p.delay || 0, xs: [], ys: [], spins: [], opacities: [] }; + for (let f = 0, s = 0; s <= SAMPLES; f++) { + if (f >= (s / SAMPLES) * frames) { + out.xs.push(p.x); + out.ys.push(p.y); + out.spins.push(((p.upright ? Math.sin(p.wobble) * 0.25 : p.spin) * 180) / Math.PI); // (balloons sway, upright) + out.opacities.push(Math.max(0, Math.min(1, (p.maxAge - p.age) / 40, (frames - f) / 30))); // (and all gone at the end) + s++; + } + p.age += 1; + p.vy = Math.min(p.vy + p.gravity, 6); + p.vx *= 0.985; + p.wobble += 0.15; + p.x += p.vx + Math.sin(p.wobble) * 0.8; + p.y += p.vy; + p.spin += p.vspin; + } + return out; +} + +// The pieces for a win in this Wardrobe look (win: confetti, fireworks, +// balloons, trophies), on a w × h screen. glyphs: the confetti look's (null: paper). +export function winPieces(win, glyphs, w, h, random = Math.random) { + const pick = (list) => list[Math.floor(random() * list.length)]; + const pieces = []; + const add = (x, y, angleDeg, speed, maxAge, extra = {}) => { + const g = extra.glyphs !== undefined ? extra.glyphs : glyphs; + const a = (angleDeg * Math.PI) / 180; + pieces.push( + fly( + { + x, + y, + vx: Math.cos(a) * speed, + vy: Math.sin(a) * speed, + size: extra.size || (g ? 9 + random() * 6 : 5 + random() * 6) * (g ? 2 : 1), + vspin: (random() - 0.5) * (g ? 0.12 : 0.4), + color: extra.color || pick(COLORS), + round: random() < 0.3, + glyph: g ? pick(g) : null, + gravity: extra.gravity !== undefined ? extra.gravity : 0.28, + maxAge, + delay: extra.delay || 0, + upright: Boolean(extra.upright), + }, + random + ) + ); + }; + // (a phone shows fewer pieces than a big screen: each one is a view) + if (win === 'fireworks') { + for (let burst = 0; burst < 5; burst++) { + const x = w * (0.15 + random() * 0.7); + const y = h * (0.15 + random() * 0.35); + const color = pick(COLORS); + for (let i = 0; i < 24; i++) add(x, y, (i / 24) * 360, 3 + random() * 5, 90 + random() * 40, { glyphs: null, gravity: 0.1, color, size: 6, delay: burst * 380 }); + } + } else if (win === 'balloons') { + for (let i = 0; i < 24; i++) add(random() * w, h + 20 + random() * h * 0.6, -90 + (random() - 0.5) * 16, 2 + random() * 2.5, 520, { glyphs: ['🎈'], gravity: -0.01, size: 28 + random() * 16, upright: true }); + } else if (win === 'trophies') { + for (let i = 0; i < 40; i++) add(random() * w, -20 - random() * h * 0.9, 90 + (random() - 0.5) * 20, 1 + random() * 3, 420, { glyphs: ['🏆', '🪙', '⭐', '🥇'] }); + } else { + // a shower from the top, plus a cannon in each bottom corner + for (let i = 0; i < 60; i++) add(random() * w, -20 - random() * h * 0.9, 90 + (random() - 0.5) * 30, 1 + random() * 3, 420); + const reach = Math.sqrt(h) * 0.75; + for (let i = 0; i < 25; i++) { + add(0, h, -60 + (random() - 0.5) * 30, reach * (0.7 + random() * 0.5), 300); + add(w, h, -120 + (random() - 0.5) * 30, reach * (0.7 + random() * 0.5), 300); + } + } + return pieces; +} diff --git a/apps/mobile/src/classroom.js b/apps/mobile/src/classroom.js new file mode 100644 index 0000000..862732c --- /dev/null +++ b/apps/mobile/src/classroom.js @@ -0,0 +1,43 @@ +// The classroom server from the phone (@blockout/classroom/core): the +// server's address, the account's token when signed in, long polling (no +// event streams here), and which class you're in kept on the phone, so the +// app can rejoin after it was closed. +import AsyncStorage from '@react-native-async-storage/async-storage'; +import Constants from 'expo-constants'; +import { createClient } from '@blockout/classroom/core'; +import * as Account from './account'; + +const SESSION_KEY = 'blockout.class'; +const HOST_KEY = 'blockout.classHost'; // (a class this phone is hosting) +const NAME_KEY = 'blockout.className'; // a guest's name last time (like the web game's) + +// (the client reads its storage synchronously: a copy in memory, written through) +const kept = new Map(); +const storage = { + get: (k) => (kept.has(k) ? kept.get(k) : null), + set: (k, v) => { + if (v === null) { + kept.delete(k); + AsyncStorage.removeItem(k).catch(() => {}); + } else { + kept.set(k, v); + AsyncStorage.setItem(k, v).catch(() => {}); + } + }, +}; + +export const client = createClient({ base: Constants.expoConfig.extra.server, token: Account.freshToken, EventSource: null, storage }); + +// At start: the class this phone was in or hosting, if any: { session, host } +export async function loadSaved() { + for (const key of [SESSION_KEY, HOST_KEY]) { + try { + const v = await AsyncStorage.getItem(key); + if (v) kept.set(key, v); + } catch (_) {} + } + return { session: client.session(), host: client.host() }; +} + +export const lastName = async () => (await AsyncStorage.getItem(NAME_KEY).catch(() => null)) || ''; +export const saveName = (name) => AsyncStorage.setItem(NAME_KEY, name).catch(() => {}); diff --git a/apps/mobile/src/effects.js b/apps/mobile/src/effects.js new file mode 100644 index 0000000..bd62273 --- /dev/null +++ b/apps/mobile/src/effects.js @@ -0,0 +1,139 @@ +// The Wardrobe's moving looks: win celebrations (with the confetti look), +// how the dice roll, and the streak's flames. Nothing moves when the device +// asks for reduced motion. +import { useEffect, useRef, useState } from 'react'; +import { AccessibilityInfo, Animated, Easing, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { DURATION_MS, SAMPLES, confettiGlyphs, winPieces } from './celebration'; +import { useLook } from './looks'; + +const native = true; // (the native driver: transforms and opacity only) + +export function useReducedMotion() { + const [reduced, setReduced] = useState(false); + useEffect(() => { + let live = true; + AccessibilityInfo.isReduceMotionEnabled() + .then((on) => live && setReduced(Boolean(on))) + .catch(() => {}); + const sub = AccessibilityInfo.addEventListener('reduceMotionChanged', (on) => setReduced(Boolean(on))); + return () => { + live = false; + if (sub && sub.remove) sub.remove(); + }; + }, []); + return reduced; +} + +// A win: the Wardrobe's celebration over the whole screen, once, when it mounts +export function Celebration() { + const { width, height } = useWindowDimensions(); + const win = useLook('win').id; + const glyphs = confettiGlyphs(useLook('confetti')); + const reduced = useReducedMotion(); + const [pieces] = useState(() => winPieces(win, glyphs, width, height)); + const time = useRef(new Animated.Value(0)).current; + const end = DURATION_MS + Math.max(0, ...pieces.map((p) => p.delay)); + const [done, setDone] = useState(false); + useEffect(() => { + const run = Animated.timing(time, { toValue: end, duration: end, easing: Easing.linear, useNativeDriver: native }); + run.start(() => setDone(true)); + return () => run.stop(); + }, []); + if (reduced || done) return null; + return ( + <View pointerEvents="none" style={StyleSheet.absoluteFill} testID="celebration"> + {pieces.map((p, i) => { + const at = p.xs.map((_, s) => p.delay + (s / SAMPLES) * DURATION_MS); + // (hidden until its delay: a firework bursts later) + const inputRange = p.delay ? [p.delay - 1, ...at] : at; + const with0 = (list, first) => (p.delay ? [first, ...list] : list); + const move = (list) => time.interpolate({ inputRange, outputRange: with0(list, list[0]), extrapolate: 'clamp' }); + const style = { + position: 'absolute', + left: 0, + top: 0, + opacity: time.interpolate({ inputRange, outputRange: with0(p.opacities, 0), extrapolate: 'clamp' }), + transform: [{ translateX: move(p.xs) }, { translateY: move(p.ys) }, { rotate: time.interpolate({ inputRange, outputRange: with0(p.spins, p.spins[0]).map((d) => `${d}deg`), extrapolate: 'clamp' }) }], + }; + return p.glyph ? ( + <Animated.Text key={i} style={[style, { fontSize: p.size, marginLeft: -p.size / 2, marginTop: -p.size / 2 }]}> + {p.glyph} + </Animated.Text> + ) : ( + <Animated.View + key={i} + style={[style, p.round ? { width: p.size / 1.2, height: p.size / 1.2, borderRadius: p.size } : { width: p.size, height: p.size / 2 }, { backgroundColor: p.color }]} + /> + ); + })} + </View> + ); +} + +// A die rolling in the Wardrobe's roll look (wobble, tumble, bounce, sparkle) +export function Rolling({ rolling, children }) { + const kind = useLook('roll').id; + const reduced = useReducedMotion(); + const v = useRef(new Animated.Value(0)).current; + useEffect(() => { + if (!rolling || reduced) return undefined; + v.setValue(0); + const ms = kind === 'tumble' ? 300 : kind === 'bounce' ? 250 : kind === 'sparkle' ? 300 : 120; + const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: ms, easing: Easing.linear, useNativeDriver: native })); + loop.start(); + return () => { + loop.stop(); + v.setValue(0); + }; + }, [rolling, kind, reduced]); + const spin = (deg) => v.interpolate({ inputRange: [0, 1], outputRange: ['0deg', `${deg}deg`] }); + const transform = + kind === 'tumble' ? [{ rotate: spin(360) }] : + kind === 'bounce' ? [{ translateY: v.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0, -16, 0] }) }] : + kind === 'sparkle' ? [{ rotate: spin(360) }, { scale: v.interpolate({ inputRange: [0, 0.5, 1], outputRange: [1, 1.12, 1] }) }] : + [{ rotate: v.interpolate({ inputRange: [0, 0.25, 0.75, 1], outputRange: ['0deg', '-12deg', '12deg', '0deg'] }) }]; + return ( + <View> + <Animated.View style={{ transform }}>{children}</Animated.View> + {rolling && kind === 'sparkle' && !reduced ? ( + <Animated.Text pointerEvents="none" style={[styles.sparkle, { opacity: v.interpolate({ inputRange: [0, 0.5, 1], outputRange: [0.2, 1, 0.2] }) }]}> + ✨ + </Animated.Text> + ) : null} + </View> + ); +} + +// "🔥 5 in a row": with the Flames look, bigger and flickering the longer it gets +export function Streak({ n, color }) { + const flames = useLook('streak').id === 'flames'; + const reduced = useReducedMotion(); + const level = n >= 20 ? 3 : n >= 10 ? 2 : n >= 5 ? 1 : 0; + const lit = flames && level > 0; + const v = useRef(new Animated.Value(0)).current; + useEffect(() => { + if (!lit || reduced) return undefined; + const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: 600, easing: Easing.inOut(Easing.quad), useNativeDriver: native }), { iterations: -1 }); + loop.start(); + return () => loop.stop(); + }, [lit, reduced]); + if (n < 2) return null; + const size = 16 * (lit ? [1, 1.1, 1.35, 1.6][level] : 1); + return ( + <Animated.Text + testID="streak" + style={[ + styles.streak, + { color: lit ? '#e25822' : color, fontSize: size }, + lit ? { transform: [{ scale: v.interpolate({ inputRange: [0, 0.5, 1], outputRange: [1, 1.08, 1] }) }], textShadowColor: 'rgba(255, 110, 0, 0.6)', textShadowRadius: 4 * level } : null, + ]} + > + 🔥 {n} in a row + </Animated.Text> + ); +} + +const styles = StyleSheet.create({ + sparkle: { position: 'absolute', top: -10, right: -10, fontSize: 22 }, + streak: { fontWeight: '800', textAlign: 'center' }, +}); diff --git a/apps/mobile/src/forms.js b/apps/mobile/src/forms.js new file mode 100644 index 0000000..eb4ee94 --- /dev/null +++ b/apps/mobile/src/forms.js @@ -0,0 +1,152 @@ +// Small pieces for the grown-ups' screens: sections, labelled fields, choices +// as chips (a phone has no drop-downs to speak of), text boxes, an error +// line, asking first, and a child's mastery bar. +import { Alert, Platform, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; +import * as Grownups from '@blockout/progress/grownups'; +import { STATUS } from './StatsScreen'; +import { useTheme } from './theme'; + +export function Section({ title, help, children, testID }) { + const t = useTheme(); + return ( + <View style={styles.section} testID={testID}> + {title ? <Text style={[styles.heading, { color: t.muted }]}>{title}</Text> : null} + {help ? <Text style={[styles.help, { color: t.muted }]}>{help}</Text> : null} + {children} + </View> + ); +} + +export function Help({ children }) { + const t = useTheme(); + return <Text style={[styles.help, { color: t.muted }]}>{children}</Text>; +} + +export function ErrorLine({ error, testID }) { + const t = useTheme(); + return error ? ( + <Text testID={testID} style={[styles.error, { color: t.bad }]}> + {error} + </Text> + ) : null; +} + +export function Field({ label, children }) { + const t = useTheme(); + return ( + <View style={styles.field}> + <Text style={[styles.label, { color: t.ink }]}>{label}</Text> + {children} + </View> + ); +} + +// options: [[value, label]]; testID: each chip is `${testID}-${value}` +export function Chips({ options, value, onChange, testID }) { + const t = useTheme(); + return ( + <View style={styles.chips} accessibilityRole="radiogroup"> + {options.map(([v, label]) => { + const on = String(v) === String(value); + return ( + <Pressable + key={String(v)} + testID={testID ? `${testID}-${v}` : undefined} + accessibilityRole="radio" + accessibilityState={{ selected: on }} + onPress={() => onChange(v)} + style={({ pressed }) => [styles.chip, { backgroundColor: on ? t.accentSoft : t.surface, borderColor: on ? t.accent : t.line, transform: [{ scale: pressed ? 0.96 : 1 }] }]} + > + <Text style={[styles.chipText, { color: t.ink }]}>{label}</Text> + </Pressable> + ); + })} + </View> + ); +} + +export function TextBox({ style, ...props }) { + const t = useTheme(); + return <TextInput placeholderTextColor={t.muted} autoCorrect={false} {...props} style={[styles.input, { color: t.ink, backgroundColor: t.surface, borderColor: t.line }, style]} />; +} + +// A row in a list: its words on the left, its buttons on the right +export function Row({ children, right, testID, tone }) { + const t = useTheme(); + return ( + <View testID={testID} style={[styles.row, { backgroundColor: t.surface, borderColor: tone === 'live' ? t.gold : t.line }]}> + <View style={styles.rowText}>{children}</View> + {right} + </View> + ); +} + +// A small button for rows ("Remove", "Grown-ups") +export function SmallButton({ title, onPress, testID, kind = 'secondary', disabled = false }) { + const t = useTheme(); + const primary = kind === 'primary'; + return ( + <Pressable + testID={testID} + accessibilityRole="button" + disabled={disabled} + onPress={onPress} + style={({ pressed }) => [styles.small, { backgroundColor: primary ? t.accentButton : t.soft, borderColor: primary ? t.accentButton : t.line, opacity: disabled ? 0.5 : pressed ? 0.7 : 1 }]} + > + <Text style={[styles.smallText, { color: primary ? t.accentInk : t.ink }]}>{title}</Text> + </Pressable> + ); +} + +// Ask first; resolves true for yes +export function ask(title, text, yes = 'OK') { + if (Platform.OS === 'web') return Promise.resolve(window.confirm(`${title}\n\n${text}`)); + return new Promise((resolve) => + Alert.alert(title, text, [ + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, + { text: yes, style: 'destructive', onPress: () => resolve(true) }, + ]) + ); +} + +// Mastered / learning / needs practice / not seen, out of the whole table +export function MasteryBar({ summary }) { + const t = useTheme(); + return ( + <View style={[styles.bar, { backgroundColor: t.soft }]} accessibilityLabel={Grownups.STATUS_ORDER.map(([k, label]) => `${label}: ${summary[k]}`).join(', ')}> + {Grownups.STATUS_ORDER.map(([k]) => ( + <View key={k} style={{ width: `${(summary[k] / summary.total) * 100}%`, backgroundColor: STATUS[k] || t.soft }} /> + ))} + </View> + ); +} + +// Due dates to pick from: none, tomorrow, in 3 days, a week, two weeks +export function dueChoices(now = new Date()) { + const inDays = (n) => Grownups.dayString(new Date(now.getTime() + n * 86400000)); + return [ + ['', 'No due date'], + [inDays(1), 'Tomorrow'], + [inDays(3), 'In 3 days'], + [inDays(7), 'In a week'], + [inDays(14), 'In 2 weeks'], + ]; +} + +export const styles = StyleSheet.create({ + section: { gap: 8 }, + heading: { fontSize: 14, fontWeight: '800', textTransform: 'uppercase', letterSpacing: 0.5 }, + help: { fontSize: 15, lineHeight: 21 }, + error: { fontSize: 15, fontWeight: '600' }, + field: { gap: 6 }, + label: { fontSize: 15, fontWeight: '800' }, + chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + chip: { minHeight: 40, paddingHorizontal: 12, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + chipText: { fontSize: 15, fontWeight: '700' }, + input: { height: 50, minWidth: 0, borderWidth: 2, borderRadius: 12, paddingHorizontal: 12, fontSize: 17, fontWeight: '600' }, + row: { flexDirection: 'row', alignItems: 'center', gap: 10, padding: 12, borderWidth: 2, borderRadius: 12 }, + rowText: { flex: 1, gap: 3 }, + small: { minHeight: 38, paddingHorizontal: 12, borderRadius: 10, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + smallText: { fontSize: 14, fontWeight: '800' }, + bar: { flexDirection: 'row', height: 10, borderRadius: 5, overflow: 'hidden' }, +}); diff --git a/apps/mobile/src/history.js b/apps/mobile/src/history.js new file mode 100644 index 0000000..d438e68 --- /dev/null +++ b/apps/mobile/src/history.js @@ -0,0 +1,24 @@ +// Finished games, kept on this phone only (like the web game's blockout.history), +// newest last. What an entry holds and the report built from it are shared +// (@blockout/progress/stats). +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Stats from '@blockout/progress/stats'; + +const HISTORY_KEY = 'blockout.history'; + +export async function loadHistory() { + try { + const saved = JSON.parse(await AsyncStorage.getItem(HISTORY_KEY)); + return Array.isArray(saved) ? saved : []; + } catch (_) { + return []; + } +} + +// One more entry (a practice round's record, say) +export async function addToHistory(entry) { + await AsyncStorage.setItem(HISTORY_KEY, JSON.stringify(Stats.addToHistory(await loadHistory(), entry))); +} + +// A finished game: the match and its results (winners: the ranked players with their index) +export const recordGame = (match, results) => addToHistory(Stats.gameRecord(match, new Set(results.winners.map((p) => p.index)))); diff --git a/apps/mobile/src/links.js b/apps/mobile/src/links.js new file mode 100644 index 0000000..ba8da06 --- /dev/null +++ b/apps/mobile/src/links.js @@ -0,0 +1,20 @@ +// Links into the app: a one-time code (a teacher's or a grown-up's), a +// class roster to join, or a live class to play in. The web game's links +// (https://…/#code=ABCD2345, #roster=ABC123, #class=ABCD) work, and so do the +// app's own: blockout://code/ABCD2345, or in Expo Go exp://…/--/code/ABCD2345. Pure. + +// { kind: 'code' | 'roster' | 'class', code } or null +export function parseLink(url) { + if (!url) return null; + const m = + /#(code|roster)=([A-Za-z0-9]+)$/.exec(url) || + /#(class)=([A-Za-z]{4})(?:&play=[0-9a-f]+)?$/.exec(url) || // (a teacher's play link is for the web game) + /(?:^blockout:\/\/|\/--\/)(code|roster|class)\/([A-Za-z0-9]+)\/?(?:[?#].*)?$/.exec(url); + return m ? { kind: m[1], code: m[2].toUpperCase() } : null; +} + +// A code's link to share: the web game's, so it opens on any phone or computer +export const codeLink = (server, code) => `${server}/#code=${code}`; + +// A code as people read it: ABCD 2345 +export const spaced = (code) => code.replace(/(.{4})/, '$1 '); diff --git a/apps/mobile/src/looks.js b/apps/mobile/src/looks.js new file mode 100644 index 0000000..c61fc3d --- /dev/null +++ b/apps/mobile/src/looks.js @@ -0,0 +1,134 @@ +// The Wardrobe's looks in the app: which ones are worn (a context, so every +// screen can read them), and how each is drawn here. What a look is and who +// owns it is shared with the web game (@blockout/progress/cosmetics, /wardrobe); +// the colours are the web game's (dice-skins.css, wardrobe.css, render.js). +import { createContext, useContext } from 'react'; +import { Circle, Defs, Line, Path, Pattern, Rect } from 'react-native-svg'; +import * as Wardrobe from '@blockout/progress/wardrobe'; +import * as Cosmetics from '@blockout/progress/cosmetics'; +import { useTheme } from './theme'; + +// The looks being worn ({ dice, pips, roll, colors, … , title }) +export const DEFAULT_LOOKS = { ...Object.fromEntries(Cosmetics.CATEGORIES.map((c) => [c.id, c.options[0].id])), title: 'none' }; +export const Looks = createContext(DEFAULT_LOOKS); +export const useLooks = () => useContext(Looks); +export const useLook = (categoryId) => Wardrobe.look(useLooks(), categoryId); + +// The players' colours (the Player colors look) +export const usePlayerColors = () => Wardrobe.playerColors(useLooks()); + +// Dice: face (two shades, top-left to bottom-right), edge, dots and number +const DICE_SKINS = { + wood: { face: ['#d09b5a', '#b98244'], edge: '#6b4420', pip: '#3b240f' }, + candy: { face: ['#ffffff', '#ffb3c7'], edge: '#d6336c', pip: '#a61e4d' }, + glass: { face: ['#e4f2ff', '#b8dcff'], edge: '#3c82c8', pip: '#1d5fa0' }, + neon: { face: ['#1c1838', '#120f24'], edge: '#3cf2ff', pip: '#ff4fd8', num: '#3cf2ff' }, + galaxy: { face: ['#6a4bc4', '#0d0b26'], edge: '#8f7cff', pip: '#ffffff' }, + golden: { face: ['#fff3b0', '#d99a1c'], edge: '#9a6a0c', pip: '#4a3200' }, + pumpkin: { face: ['#ffb04a', '#c85a00'], edge: '#7a3500', pip: '#2b1200' }, +}; +// (a die's skin: these dice and dots, in theme t) +export function diceSkin(t, diceId, pipsId) { + const base = DICE_SKINS[diceId] || { face: [t.surface, t.surface], edge: t.edge, pip: t.ink }; + const pips = Cosmetics.option('pips', pipsId); + return { id: DICE_SKINS[diceId] ? diceId : 'classic', ...base, num: base.num || base.pip, glyph: (pips && pips.glyph) || null }; +} +export function useDiceSkin() { + const looks = useLooks(); + return diceSkin(useTheme(), looks.dice, looks.pips); +} + +// Keypads: keys, ✓ and ⌫ ({ bg, edge, ink }), and corner rounding +const KEYPAD_SKINS = { + candy: { + key: { bg: '#ffc4d6', edge: '#d6336c', ink: '#7a1340' }, + check: { bg: '#9fe4b9', edge: '#d6336c', ink: '#0f4d2a' }, + back: { bg: '#ffb38b', edge: '#d6336c', ink: '#6b2400' }, + radius: 16, + }, + retro: { + key: { bg: '#dbd6c6', edge: '#5c5747', ink: '#2a271f' }, + check: { bg: '#e5943a', edge: '#5c5747', ink: '#2a1500' }, + back: { bg: '#6c6756', edge: '#5c5747', ink: '#ffffff' }, + radius: 6, + mono: true, + }, + neon: { + key: { bg: '#120f24', edge: '#3cf2ff', ink: '#3cf2ff' }, + check: { bg: '#0e1d14', edge: '#7dff9a', ink: '#7dff9a' }, + back: { bg: '#22101d', edge: '#ff4fd8', ink: '#ff4fd8' }, + radius: 12, + }, +}; +export const keypadSkin = (t, id) => + KEYPAD_SKINS[id] || { + key: { bg: t.surface, edge: t.line, ink: t.ink }, + check: { bg: t.good, edge: t.line, ink: '#fff' }, + back: { bg: t.bad, edge: t.bad, ink: '#fff' }, // (red, as on the web game's keypad) + radius: 12, + }; +export const useKeypadSkin = () => keypadSkin(useTheme(), useLooks().keypad); + +// Boards: colours plus a little decoration. Graph paper follows the theme. +const BOARD_SKINS = { + notebook: { bg: '#fdfdf6', grid: '#b9d3ea', edge: '#2b2a33', fade: 'rgba(253, 253, 246, 0.65)', decor: 'notebook' }, + chalkboard: { bg: '#2f4f3a', grid: 'rgba(255, 255, 255, 0.16)', edge: '#1b3024', fade: 'rgba(47, 79, 58, 0.7)', wrong: '#c9d6cc', decor: 'chalk' }, + beach: { bg: '#f4e1b0', grid: 'rgba(150, 110, 50, 0.25)', edge: '#b58a4a', fade: 'rgba(244, 225, 176, 0.65)', decor: 'sand' }, + space: { bg: '#10143a', grid: 'rgba(255, 255, 255, 0.12)', edge: '#3a3f7a', fade: 'rgba(16, 20, 58, 0.7)', wrong: '#aab0e0', decor: 'stars' }, + snow: { bg: '#eef6ff', grid: '#c8dcf0', edge: '#7a9cc0', fade: 'rgba(238, 246, 255, 0.65)', decor: 'flakes' }, +}; +export const boardSkin = (t, id) => ({ bg: t.boardBg, grid: t.boardGrid, edge: t.boardEdge, wrong: t.boardWrong, decor: null, ...BOARD_SKINS[id] }); +export const useBoardSkin = () => boardSkin(useTheme(), useLooks().board); + +// Repeatable pseudo-random numbers, so the decoration stays put +function decorRandom(seed) { + let a = seed; + return () => { + a = (a * 1103515245 + 12345) % 2147483648; + return a / 2147483648; + }; +} + +// A board's decoration under the grid (the web's drawBoardDecor, for a side × side board) +export function BoardDecor({ kind, side, cell }) { + if (kind === 'notebook') return <Line x1={cell * 0.5} y1={0} x2={cell * 0.5} y2={side} stroke="rgba(230, 110, 110, 0.7)" strokeWidth={2} />; + if (!kind) return null; + const r = decorRandom(7); + // (the web board is about 600 px; fewer specks on a smaller one) + const count = Math.round((kind === 'sand' ? 260 : kind === 'chalk' ? 40 : 90) * Math.min(1, side / 600) ** 2) + 10; + const dots = []; + for (let i = 0; i < count; i++) { + const x = r() * side; + const y = r() * side; + const size = kind === 'chalk' ? 6 + r() * 14 : 0.6 + r() * (kind === 'flakes' ? 2.2 : 1.6); + const fill = + kind === 'stars' ? `rgba(255, 255, 255, ${(0.35 + r() * 0.6).toFixed(2)})` : + kind === 'flakes' ? 'rgba(160, 190, 225, 0.6)' : + kind === 'sand' ? 'rgba(160, 120, 60, 0.35)' : 'rgba(255, 255, 255, 0.05)'; + dots.push(<Circle key={i} cx={x} cy={y} r={size} fill={fill} />); + } + return dots; +} + +// Rectangle patterns: light marks over your own rectangles. <PatternDefs /> +// goes once in the Svg; fill a rectangle with patternFill(kind). +const MARK = 'rgba(255, 255, 255, 0.35)'; +const TILES = { + stripes: <Path d="M-4 20 L20 -4 M-4 4 L4 -4 M12 20 L20 12" stroke={MARK} strokeWidth={3} />, + dots: [<Circle key="a" cx={4} cy={4} r={2.5} fill={MARK} />, <Circle key="b" cx={12} cy={12} r={2.5} fill={MARK} />], + bricks: [<Rect key="a" x={0} y={0.75} width={16} height={7.5} fill="none" stroke={MARK} strokeWidth={1.5} />, <Line key="b" x1={8} y1={8} x2={8} y2={16} stroke={MARK} strokeWidth={1.5} />], + checker: [<Rect key="a" x={0} y={0} width={8} height={8} fill={MARK} />, <Rect key="b" x={8} y={8} width={8} height={8} fill={MARK} />], + wood: <Path d="M0 4 C5 2 11 6 16 4 M0 11 C5 13 11 9 16 11" fill="none" stroke={MARK} strokeWidth={1.5} />, +}; +export const hasPattern = (kind) => Boolean(TILES[kind]); +export const patternFill = (kind) => `url(#pattern-${kind})`; +export function PatternDefs({ kind }) { + if (!TILES[kind]) return null; + return ( + <Defs> + <Pattern id={`pattern-${kind}`} patternUnits="userSpaceOnUse" x={0} y={0} width={16} height={16}> + {TILES[kind]} + </Pattern> + </Defs> + ); +} diff --git a/apps/mobile/src/notes.js b/apps/mobile/src/notes.js new file mode 100644 index 0000000..c89cbf8 --- /dev/null +++ b/apps/mobile/src/notes.js @@ -0,0 +1,32 @@ +// What a game earns, stacked: the same kind of bonus said once with how many +// times and the points added up ("⚡ Speedy ×3 · +9 bonus points"), and the +// stickers on one line. Used for the results screens' summary and to merge +// repeats waiting in the pop-up queue. Pure. + +// A note's kind: its title without numbers ("🔥 Streak 4" and "🔥 Streak 5" +// are one kind); every sticker is one kind +export const kindOf = (title) => (title.startsWith('🎁') ? '🎁' : title.replace(/\s*\d+\s*$/, '').replace(/\s+/g, ' ').trim()); + +const pointsIn = (text) => { + const m = /\+(\d+)/.exec(text || ''); + return m ? Number(m[1]) : 0; +}; + +// One more note on a list (a new list): { kind, title, text, count, points, emojis } +export function stack(list, title, text = '') { + const kind = kindOf(title); + const sticker = kind === '🎁' ? (title.split(':').pop() || '').trim() : null; + const at = list.findIndex((n) => n.kind === kind); + if (at < 0) return [...list, { kind, title: sticker ? '🎁 Stickers' : title.replace(/\s*\d+\s*$/, '').trim() || title, text, count: 1, points: pointsIn(text), emojis: sticker ? [sticker] : [] }]; + const n = list[at]; + const merged = { ...n, count: n.count + 1, points: n.points + pointsIn(text), emojis: sticker ? [...n.emojis, sticker] : n.emojis }; + return [...list.slice(0, at), merged, ...list.slice(at + 1)]; +} + +// How a stacked note reads: [title, text] +export function words(n) { + if (n.kind === '🎁') return [`🎁 ${n.count === 1 ? 'Sticker' : 'Stickers'}: ${n.emojis.join(' ')}`, n.count === 1 ? n.text : 'in your album']; + const title = n.count > 1 ? `${n.title} ×${n.count}` : n.title; + const text = n.count > 1 && n.points ? n.text.replace(/\+\d+/, `+${n.points}`) : n.text; + return [title, text]; +} diff --git a/apps/mobile/src/rewards.js b/apps/mobile/src/rewards.js new file mode 100644 index 0000000..5b61944 --- /dev/null +++ b/apps/mobile/src/rewards.js @@ -0,0 +1,88 @@ +// What a game puts in the save, as the web game does it (play.js): every +// answer goes into the player's fact record, achievements are checked, and a +// finished game adds its points to the wallet. Single-player and class games +// count towards stats and achievements; two players on one phone only earn points. +// Each function changes the save and returns the achievements it earned. +import * as Progress from '@blockout/progress'; +import * as Views from '@blockout/progress/views'; +import * as Setup from '@blockout/progress/setup'; + +export const STATS_NAME = 'You'; // whose times tables the single player's answers count towards + +// (and a live class's games, except the teacher's own player's: as on the web) +export const countsForStats = (match) => ['single', 'class', 'pair'].includes(match.mode) && !match.teacherPlayer; + +// The biggest times table this player has unlocked: achievements for +// "every fact up to…" only count facts in reach (shared with the web game) +export const tableSize = Views.tableSize; + +const facts = (progress) => (progress.facts[Progress.playerKey(STATS_NAME)] || { facts: {} }).facts; + +// result: match.answer's, when it was right +export function onAnswer(progress, match, result, ms) { + if (!countsForStats(match)) return []; + const { rect, firstTry, streak, wrongAnswers } = result; + Progress.recordAnswer(progress, STATS_NAME, { a: rect.a, b: rect.b, firstTry, correct: true, wrongAnswers, ms }); + const learned = Progress.learnedAfterHelp(progress, rect.a, rect.b, firstTry); + return Progress.awardAchievements(progress, { event: 'answer', correct: true, firstTry, ms, streak, a: rect.a, b: rect.b, facts: facts(progress), size: tableSize(progress), learned }); +} + +export function onHelp(progress, match, rect) { + if (!countsForStats(match)) return []; + Progress.noteHelp(progress, rect.a, rect.b); + return Progress.awardAchievements(progress, { event: 'help' }); +} + +export function onTimeout(progress, match, { rect, wrongAnswers }) { + if (!countsForStats(match)) return; + Progress.recordAnswer(progress, STATS_NAME, { a: rect.a, b: rect.b, firstTry: false, correct: false, wrongAnswers, timeout: true }); +} + +// The end of a game: everyone's points into the wallet, then (single player) +// the finishing bonus, the board win and the game achievements, and on a +// Journey level (`level`) moving up. Returns { points, boardBonus, earned, journey } +// (journey: Progress.recordLevelGame's answer, or null). +export function onGameOver(progress, match, results, level = null) { + const humans = match.players.filter((p) => !p.cpu); + const points = humans.reduce((sum, p) => sum + p.score, 0); + Progress.addPoints(progress, points); + if (!countsForStats(match)) return { points, boardBonus: 0, earned: [] }; + progress.counters.games++; + const boardBonus = Progress.boardFinishBonus(match.size); + if (boardBonus) Progress.addPoints(progress, boardBonus); + Progress.noteActivity(progress, { kind: 'game', mode: match.mode, difficulty: match.difficulty, size: match.size, won: results.humanWon }); + if (results.humanWon) Progress.recordBoardWin(progress, match.size); + const earned = Progress.awardAchievements(progress, { + event: 'game', + noHelp: results.noHelp, + games: progress.counters.games, + wonVsCpu: results.humanWon, + humanWon: results.humanWon, + full: results.empty === 0, + perfect: results.perfect, + difficulty: match.difficulty, + bestScore: results.bestScore, + }); + let journey = null; + if (level && match.mode === 'single') { + const you = match.players.find((p) => !p.cpu); + journey = Progress.recordLevelGame(progress, level, { won: results.humanWon, answered: you.answered + you.stats.timeouts, firstTry: you.firstTry, helped: you.stats.helped }); + earned.push(...journey.earned); + } + return { points, boardBonus, earned, journey }; +} + +// What can be played here: Easy and the 6×6 board always, the rest once +// the Journey reaches them +export const DIFFICULTIES = Setup.DIFFICULTY_CHOICES; +export const BOARDS = [6, 8, 10, 12, 16, 20, 24]; +export const PLAYER_COUNTS = [2, 3, 4]; + +export const difficultyOpen = Setup.difficultyOpen; +export const playersOpen = (progress, n) => n === 2 || Progress.isUnlocked(progress, `players${n}`); +export const boardOpen = (progress, size) => size === 6 || Progress.isUnlocked(progress, `board${size}`); +// Two players on one phone is the Shop's "Multiplayer", as on the web +export const modeOpen = (progress, mode) => mode === 'single' || Progress.isUnlocked(progress, mode === 'practice' ? 'practice' : 'multiplayer'); + +// The Shop item a locked menu choice stands for (to offer it when tapped) +export const itemFor = { mode: (mode) => (mode === 'practice' ? 'practice' : 'multiplayer'), difficulty: (id) => `difficulty.${id}`, size: (n) => `board${n}`, players: (n) => `players${n}` }; diff --git a/apps/mobile/src/sync.js b/apps/mobile/src/sync.js new file mode 100644 index 0000000..f59c5c0 --- /dev/null +++ b/apps/mobile/src/sync.js @@ -0,0 +1,41 @@ +// Signing in and keeping saves in step, the parts with no React Native in +// them (tested in test/sync.test.js). The rules are the web game's +// (packages/auth/src/client.js): a signed-in player's save lives under its own +// key, and the first time an account is used on this device, the device's +// guest save goes into the account, once. After that the server's copy wins. +import * as Progress from '@blockout/progress'; + +export const GUEST_KEY = 'blockout.progress'; +export const MERGED_KEY = 'blockout.mergedInto'; +export const progressKey = (user) => (user ? `${GUEST_KEY}.${user.id}` : GUEST_KEY); + +// The token's claims (no checking here: the server checks every request) +export function claims(token) { + try { + const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'); + return JSON.parse(decodeURIComponent(escape(atob(part.padEnd(part.length + ((4 - (part.length % 4)) % 4), '='))))); + } catch (_) { + return {}; + } +} + +// Renew a minute before the access token runs out +export const REFRESH_EARLY_S = 60; +export function expiresSoon(token, now = Date.now()) { + const { exp } = claims(token); + return Boolean(exp) && exp - now / 1000 < REFRESH_EARLY_S; +} + +// The save to use after signing in, and whether to send it to the account. +// remote: the account's save (or null); guest: this device's guest save; +// local: the account's cached save on this device; firstTimeHere: this account +// hasn't been used on this device before. +export function mergeOnSignIn({ remote, guest, local, firstTimeHere }) { + let result; + if (firstTimeHere) result = [remote, guest, local].filter(Boolean).reduce((a, b) => Progress.mergeProgress(b, a), null); + else result = remote ? Progress.normalize(remote) : local ? Progress.normalize(local) : null; + if (!result) return { progress: null, push: false }; + // Cheats belong to the device: switch this device's back on + for (const code of Object.keys((local && local.cheats) || {})) Progress.cheatOn(result, code); + return { progress: result, push: firstTimeHere || !remote }; +} diff --git a/apps/mobile/src/theme.js b/apps/mobile/src/theme.js new file mode 100644 index 0000000..8fca8e8 --- /dev/null +++ b/apps/mobile/src/theme.js @@ -0,0 +1,66 @@ +// The web game's colours (packages/ui/src/css/tokens.css), light and dark. +import { createContext, useContext } from 'react'; +import { useColorScheme } from 'react-native'; + +const light = { + bg: '#f6f1e7', + paper: '#fffdf8', + surface: '#ffffff', + soft: '#f3eee4', + ink: '#2b2a33', + muted: '#6d6a75', + line: '#ddd5c6', + edge: '#2b2a33', + accent: '#e4572e', + accentButton: '#c4421c', + accentInk: '#ffffff', + accentSoft: '#fff1ec', + good: '#2a9d5c', + goodInk: '#1d6e41', + goodBg: '#e6f6ec', + bad: '#b0342a', + badBg: '#fbe9e7', + gold: '#e0a800', + goldBg: '#fff6d6', + goldInk: '#8a5a00', + boardBg: '#ffffff', + boardGrid: '#d9d2c3', + boardEdge: '#2b2a33', + boardWrong: '#8a8578', +}; + +const dark = { + ...light, + bg: '#16151b', + paper: '#211f27', + surface: '#2a2731', + soft: '#322e3a', + ink: '#ece9f2', + muted: '#a8a3b4', + line: '#3f3a49', + edge: '#57525f', + accent: '#ef6a44', + accentButton: '#c94a26', + accentSoft: '#3b2620', + good: '#34a466', + goodInk: '#86dba9', + goodBg: '#1d3528', + bad: '#e26c5e', + badBg: '#3d2320', + gold: '#f2c14e', + goldBg: '#342c1c', + goldInk: '#f0c96a', + boardBg: '#1d1b22', + boardGrid: '#3a3643', + boardEdge: '#57525f', + boardWrong: '#8f8a99', +}; + +// The Theme setting: 'auto' follows the device, 'light' or 'dark' forces one +export const ThemeSetting = createContext('auto'); + +export function useTheme() { + const device = useColorScheme(); + const setting = useContext(ThemeSetting); + return (setting === 'auto' ? device : setting) === 'dark' ? dark : light; +} diff --git a/apps/mobile/src/ui.js b/apps/mobile/src/ui.js new file mode 100644 index 0000000..a6ab2e6 --- /dev/null +++ b/apps/mobile/src/ui.js @@ -0,0 +1,169 @@ +// Small shared pieces: buttons, dice and the answer keypad. +import { Platform, Pressable, StyleSheet, Text, View } from 'react-native'; +import Svg, { Circle, Defs, LinearGradient, Rect, Stop, Text as SvgText } from 'react-native-svg'; +import { useTheme } from './theme'; +import * as Notes from './notes'; +import { useDiceSkin, useKeypadSkin } from './looks'; + +// primary: the one thing to do next (solid, with a soft shadow); secondary: +// everything else (a light fill and a thin edge) +export function Button({ title, onPress, kind = 'primary', disabled = false, style, testID }) { + const t = useTheme(); + const primary = kind === 'primary'; + return ( + <Pressable + testID={testID} + accessibilityRole="button" + accessibilityState={{ disabled }} + disabled={disabled} + onPress={onPress} + style={({ pressed }) => [ + styles.button, + primary ? [styles.primary, { backgroundColor: t.accentButton, shadowColor: t.accentButton }] : { backgroundColor: t.soft, borderColor: t.line, borderWidth: StyleSheet.hairlineWidth * 2 }, + { opacity: disabled ? 0.45 : 1, transform: [{ scale: pressed && !disabled ? 0.97 : 1 }] }, + style, + ]} + > + {/* (one line: a label that doesn't fit gets smaller rather than cut off, e.g. with larger text) */} + <Text numberOfLines={1} adjustsFontSizeToFit minimumFontScale={0.75} style={[styles.buttonText, { color: primary ? t.accentInk : t.ink }]}> + {title} + </Text> + </Pressable> + ); +} + +// A screen's top bar: ✕ back, the title, and something on the right (points) +export function ScreenHeader({ title, onClose, right = null, closeLabel = 'Back to the menu' }) { + const t = useTheme(); + return ( + <View style={styles.header}> + <Pressable onPress={onClose} accessibilityRole="button" accessibilityLabel={closeLabel} hitSlop={10} style={[styles.close, { borderColor: t.line, backgroundColor: t.surface }]}> + <Text style={[styles.closeText, { color: t.ink }]}>✕</Text> + </Pressable> + <Text style={[styles.headerTitle, { color: t.ink }]} numberOfLines={1}> + {title} + </Text> + {right} + </View> + ); +} + +// Pips for 1 to 6, as on a real die; bigger numbers are written out +const PIPS = { + 1: [[1, 1]], + 2: [[0, 0], [2, 2]], + 3: [[0, 0], [1, 1], [2, 2]], + 4: [[0, 0], [2, 0], [0, 2], [2, 2]], + 5: [[0, 0], [2, 0], [1, 1], [0, 2], [2, 2]], + 6: [[0, 0], [2, 0], [0, 1], [2, 1], [0, 2], [2, 2]], +}; + +// A die, in the Wardrobe's dice and dots (skin: another look, for previews) +export function Die({ value, size = 56, skin }) { + const worn = useDiceSkin(); + const k = skin || worn; + const pips = PIPS[value]; + const grad = `die-${k.id}`; + return ( + <View style={{ width: size, height: size }} accessibilityLabel={value ? `Die: ${value}` : 'Die'}> + <Svg width={size} height={size}> + <Defs> + <LinearGradient id={grad} x1="0" y1="0" x2="1" y2="1"> + <Stop offset="0" stopColor={k.face[0]} /> + <Stop offset="1" stopColor={k.face[1]} /> + </LinearGradient> + </Defs> + <Rect x={2} y={2} width={size - 4} height={size - 4} rx={size * 0.18} fill={`url(#${grad})`} stroke={k.edge} strokeWidth={2} /> + {pips + ? pips.map(([c, r], i) => + k.glyph ? ( + <SvgText key={i} x={size * (0.27 + c * 0.23)} y={size * (0.27 + r * 0.23) + size * 0.065} fontSize={size * 0.18} textAnchor="middle"> + {k.glyph} + </SvgText> + ) : ( + <Circle key={i} cx={size * (0.27 + c * 0.23)} cy={size * (0.27 + r * 0.23)} r={size * 0.08} fill={k.pip} /> + ) + ) + : null} + </Svg> + {value && !pips ? ( + <View style={StyleSheet.absoluteFill}> + <Text style={[styles.dieNumber, { color: k.num, lineHeight: size, fontSize: size * 0.45 }]}>{value}</Text> + </View> + ) : null} + </View> + ); +} + +const KEYS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'back', '0', 'check']; +const LABELS = { back: '⌫', check: '✓' }; + +// The answer keypad, in the Wardrobe's keypad +export function Keypad({ onKey, disabled }) { + const k = useKeypadSkin(); + return ( + <View style={styles.keypad}> + {KEYS.map((key) => { + const c = k[key] || k.key; + return ( + <Pressable + key={key} + testID={`key-${key}`} + accessibilityRole="button" + accessibilityLabel={key === 'back' ? 'Delete' : key === 'check' ? 'Check' : key} + disabled={disabled} + onPress={() => onKey(key)} + style={({ pressed }) => [styles.key, { backgroundColor: c.bg, borderColor: c.edge, borderRadius: k.radius, opacity: disabled ? 0.45 : pressed ? 0.7 : 1 }]} + > + <Text style={[styles.keyText, { color: c.ink }, k.mono ? styles.mono : null]}>{LABELS[key] || key}</Text> + </Pressable> + ); + })} + </View> + ); +} + +// What a game or round earned (held back while playing, stacked: notes.js), +// for its results screen +export function Earned({ notes }) { + const t = useTheme(); + if (!notes || !notes.length) return null; + return ( + <View testID="earned" style={styles.earned} accessibilityLabel="You earned"> + {notes.map((n) => { + const [title, text] = Notes.words(n); + return ( + <View key={n.kind} style={[styles.note, { backgroundColor: t.goldBg, borderColor: t.gold }]}> + <Text style={[styles.noteTitle, { color: t.ink }]} numberOfLines={1}> + {title} + </Text> + {text ? ( + <Text style={[styles.noteText, { color: t.goldInk }]} numberOfLines={1}> + {text} + </Text> + ) : null} + </View> + ); + })} + </View> + ); +} + +const styles = StyleSheet.create({ + header: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingHorizontal: 16, paddingVertical: 8 }, + close: { width: 40, height: 40, borderRadius: 20, borderWidth: 2, alignItems: 'center', justifyContent: 'center' }, + closeText: { fontSize: 18, fontWeight: '800' }, + headerTitle: { flex: 1, fontSize: 26, fontWeight: '900' }, + button: { height: 52, paddingHorizontal: 12, borderRadius: 12, alignItems: 'center', justifyContent: 'center' }, + primary: { shadowOpacity: 0.3, shadowRadius: 8, shadowOffset: { width: 0, height: 3 }, elevation: 3 }, + buttonText: { fontSize: 17, fontWeight: '700', letterSpacing: 0.2 }, + dieNumber: { textAlign: 'center', fontWeight: '800' }, + earned: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 }, + note: { borderWidth: 1.5, borderRadius: 10, paddingHorizontal: 8, paddingVertical: 3, maxWidth: '100%' }, + noteTitle: { fontSize: 13, fontWeight: '800' }, + noteText: { fontSize: 11, fontWeight: '700' }, + keypad: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'center' }, + key: { width: '30%', paddingVertical: 12, borderRadius: 12, borderWidth: 2, alignItems: 'center' }, + keyText: { fontSize: 24, fontWeight: '700' }, + mono: { fontFamily: Platform.select({ ios: 'Courier New', android: 'monospace', default: 'ui-monospace, monospace' }) }, +}); diff --git a/apps/mobile/test/boardMath.test.js b/apps/mobile/test/boardMath.test.js new file mode 100644 index 0000000..719586e --- /dev/null +++ b/apps/mobile/test/boardMath.test.js @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { boardLayout, cellAt, dragRect, drawnMessage } from '../src/boardMath.js'; + +test('the square under a finger, kept on the board', () => { + assert.deepEqual(cellAt(0, 0, 40, 6), { c: 0, r: 0 }); + assert.deepEqual(cellAt(85, 41, 40, 6), { c: 2, r: 1 }); + assert.deepEqual(cellAt(-10, 999, 40, 6), { c: 0, r: 5 }); +}); + +test('a drag makes the same rectangle whichever way it goes', () => { + assert.deepEqual(dragRect({ c0: 1, r0: 1, c1: 3, r1: 2 }), { x: 1, y: 1, w: 3, h: 2 }); + assert.deepEqual(dragRect({ c0: 3, r0: 2, c1: 1, r1: 1 }), { x: 1, y: 1, w: 3, h: 2 }); + assert.deepEqual(dragRect({ c0: 4, r0: 4, c1: 4, r1: 4 }), { x: 4, y: 4, w: 1, h: 1 }); +}); + +test('the reason a drawn rectangle is turned down', () => { + const rect = { w: 3, h: 3 }; + assert.match(drawnMessage(rect, [2, 3], { rightSize: false }), /3 by 3, but you rolled 2 and 3/); + assert.match(drawnMessage(rect, [3, 3], { rightSize: true, fits: false }), /bumps into/); +}); + +test('the board fits the width in whole pixels per square', () => { + assert.deepEqual(boardLayout(361, 6), { cell: 60, side: 360 }); +}); diff --git a/apps/mobile/test/celebration.test.js b/apps/mobile/test/celebration.test.js new file mode 100644 index 0000000..9c83444 --- /dev/null +++ b/apps/mobile/test/celebration.test.js @@ -0,0 +1,34 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { SAMPLES, confettiGlyphs, winPieces } from '../src/celebration.js'; + +// (the same numbers every run) +const seeded = (seed) => () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646; + +test('every win look makes pieces with their whole path sampled', () => { + for (const win of ['confetti', 'fireworks', 'balloons', 'trophies']) { + const pieces = winPieces(win, null, 390, 844, seeded(3)); + assert.ok(pieces.length >= 20, win); + for (const p of pieces) { + for (const list of [p.xs, p.ys, p.spins, p.opacities]) assert.equal(list.length, SAMPLES + 1, win); + assert.ok(p.opacities.every((o) => o >= 0 && o <= 1), win); + } + } +}); + +test('confetti falls, balloons rise, and pieces fade out by the end', () => { + const fall = winPieces('trophies', null, 390, 844, seeded(5)); + assert.ok(fall.every((p) => p.ys[SAMPLES] > p.ys[0])); + const rise = winPieces('balloons', null, 390, 844, seeded(5)); + assert.ok(rise.every((p) => p.ys[SAMPLES] < p.ys[0] && p.glyph === '🎈')); + assert.ok(rise.every((p) => p.spins.every((d) => Math.abs(d) <= 15)), 'balloons stay upright'); + assert.ok(winPieces('confetti', null, 390, 844, seeded(7)).every((p) => p.opacities[SAMPLES] === 0)); +}); + +test('the confetti look picks paper or emoji pieces', () => { + assert.equal(confettiGlyphs({ id: 'paper' }), null); + assert.deepEqual(confettiGlyphs({ id: 'snow' }), ['❄️']); + assert.deepEqual(confettiGlyphs({ id: 'emoji', glyphs: ['🍕', '🌮'] }), ['🍕', '🌮']); + const emoji = winPieces('confetti', ['🍕'], 390, 844, seeded(9)); + assert.ok(emoji.every((p) => p.glyph === '🍕')); +}); diff --git a/apps/mobile/test/links.test.js b/apps/mobile/test/links.test.js new file mode 100644 index 0000000..f584dfb --- /dev/null +++ b/apps/mobile/test/links.test.js @@ -0,0 +1,21 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { codeLink, parseLink, spaced } from '../src/links.js'; + +test('codes and classes come from web links and app links', () => { + assert.deepEqual(parseLink('https://blockout.deepspacecartel.com/#code=abcd2345'), { kind: 'code', code: 'ABCD2345' }); + assert.deepEqual(parseLink('https://blockout.deepspacecartel.com/#roster=QX7K2P'), { kind: 'roster', code: 'QX7K2P' }); + assert.deepEqual(parseLink('blockout://code/ABCD2345'), { kind: 'code', code: 'ABCD2345' }); + assert.deepEqual(parseLink('exp://slejycq-deepspacecartel-8081.exp.direct/--/roster/qx7k2p'), { kind: 'roster', code: 'QX7K2P' }); + assert.equal(parseLink('blockout://auth?code=xyz'), null, 'the sign-in redirect is not a code'); + assert.equal(parseLink('exp://host/--/auth?code=abc'), null); + assert.equal(parseLink(null), null); + assert.deepEqual(parseLink('https://blockout.deepspacecartel.com/#class=qxkp'), { kind: 'class', code: 'QXKP' }); + assert.deepEqual(parseLink('https://blockout.deepspacecartel.com/#class=QXKP&play=0a1b'), { kind: 'class', code: 'QXKP' }); + assert.deepEqual(parseLink('blockout://class/QXKP'), { kind: 'class', code: 'QXKP' }); +}); + +test('a code is shared as the web link, and read in two halves', () => { + assert.equal(codeLink('https://b.example', 'ABCD2345'), 'https://b.example/#code=ABCD2345'); + assert.equal(spaced('ABCD2345'), 'ABCD 2345'); +}); diff --git a/apps/mobile/test/notes.test.js b/apps/mobile/test/notes.test.js new file mode 100644 index 0000000..831d6a5 --- /dev/null +++ b/apps/mobile/test/notes.test.js @@ -0,0 +1,31 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { kindOf, stack, words } from '../src/notes.js'; + +test('repeats stack: counted, points added up', () => { + let list = []; + list = stack(list, '⚡ Speedy', '+3 bonus points'); + list = stack(list, '✨ First try', '+2 bonus points'); + list = stack(list, '⚡ Speedy', '+3 bonus points'); + list = stack(list, '⚡ Speedy', '+4 bonus points'); + assert.equal(list.length, 2); + assert.deepEqual(words(list[0]), ['⚡ Speedy ×3', '+10 bonus points']); + assert.deepEqual(words(list[1]), ['✨ First try', '+2 bonus points']); +}); + +test('a streak is one kind whatever its number; achievements stay apart', () => { + assert.equal(kindOf('🔥 Streak 4'), kindOf('🔥 Streak 5')); + let list = stack([], '🔥 Streak 3', '+1 bonus points'); + list = stack(list, '🔥 Streak 4', '+2 bonus points'); + list = stack(list, '🏫 Class act', 'Achievement! +15 points'); + list = stack(list, '⭐ Daily star', 'Achievement! +15 points'); + assert.deepEqual(words(list[0]), ['🔥 Streak ×2', '+3 bonus points']); + assert.equal(list.length, 3); +}); + +test('stickers go on one line', () => { + let list = stack([], '🎁 New sticker: 🐧', 'rare · added to your album'); + assert.deepEqual(words(list[0]), ['🎁 Sticker: 🐧', 'rare · added to your album']); + list = stack(list, '🎁 Sticker: 🐝', 'a spare you can swap for points'); + assert.deepEqual(words(list[0]), ['🎁 Stickers: 🐧 🐝', 'in your album']); +}); diff --git a/apps/mobile/test/rewards.test.js b/apps/mobile/test/rewards.test.js new file mode 100644 index 0000000..10c9426 --- /dev/null +++ b/apps/mobile/test/rewards.test.js @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import * as Progress from '@blockout/progress'; +import * as Match from '@blockout/engine/match'; +import { boardOpen, difficultyOpen, onAnswer, onGameOver, onHelp, tableSize } from '../src/rewards.js'; + +const seeded = (s = 7) => () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646; + +function playOut(match, progress) { + let results = null; + while (!results) { + if (match.phase === 'cpu') Match.cpuTurn(match); + else { + Match.roll(match); + if (match.phase === 'stuck') Match.pass(match); + else { + const [a, b] = match.dice; + const spot = [...Array(match.size * match.size).keys()].map((i) => ({ x: i % match.size, y: Math.floor(i / match.size) })).flatMap(({ x, y }) => [{ x, y, w: a, h: b }, { x, y, w: b, h: a }]).find((r) => Match.judge(match, r).ok); + Match.place(match, spot); + onAnswer(progress, match, Match.answer(match, a * b, { ms: 2000 }), 2000); + } + } + results = Match.endTurn(match); + } + return results; +} + +test('a single-player game fills the fact record and pays into the wallet', () => { + const progress = Progress.createProgress(); + const match = Match.createMatch({ players: [{ name: 'You' }, { name: 'CPU', cpu: true }], rng: seeded(), answerBonus: Progress.answerBonus }); + const results = playOut(match, progress); + const before = progress.wallet; + const { points, boardBonus, earned } = onGameOver(progress, match, results); + assert.equal(points, match.players[0].score); + const rewards = earned.reduce((sum, a) => sum + a.reward, 0); // achievements pay too + assert.equal(progress.wallet, before + points + boardBonus + rewards); + assert.equal(progress.counters.games, 1); + assert.ok(Object.keys(progress.facts[Progress.playerKey('You')].facts).length > 0); +}); + +test('two players on one phone: points only, nothing permanent', () => { + const progress = Progress.createProgress(); + const match = Match.createMatch({ players: [{ name: 'Ann' }, { name: 'Ben' }], mode: 'multi', rng: seeded(3) }); + const results = playOut(match, progress); + const { earned } = onGameOver(progress, match, results); + assert.deepEqual(earned, []); + assert.equal(progress.counters.games, 0); + assert.deepEqual(onHelp(progress, match, { a: 2, b: 3 }), []); +}); + +test('only unlocked difficulties and boards can be picked', () => { + const progress = Progress.createProgress(); + assert.equal(difficultyOpen(progress, 'easy'), true); + assert.equal(difficultyOpen(progress, 'hard'), false); + assert.equal(boardOpen(progress, 6), true); + assert.equal(boardOpen(progress, 8), false); + assert.equal(tableSize(progress), 6); + progress.unlocks.push('board8', 'difficulty.medium'); + assert.equal(boardOpen(progress, 8), true); + assert.equal(difficultyOpen(progress, 'medium'), true); + assert.equal(tableSize(progress), 8); +}); + +test('a Journey game won with right answers moves you up a level', () => { + const progress = Progress.createProgress(); + const match = Match.createMatch({ size: 2, sides: 2, players: [{ name: 'You' }, { name: 'CPU', cpu: true }], rng: seeded(3), answerBonus: Progress.answerBonus }); + const results = playOut(match, progress); + const { journey } = onGameOver(progress, match, results, 3); + assert.equal(journey.current, false, 'not the level the save is on: nothing moves'); + progress.journey.level = 2; + const again = onGameOver(progress, match, results, 2); + assert.equal(again.journey.advanced, results.humanWon); + assert.equal(onGameOver(progress, match, results).journey, null, 'Free play: no Journey'); +}); diff --git a/apps/mobile/test/sync.test.js b/apps/mobile/test/sync.test.js new file mode 100644 index 0000000..307aa40 --- /dev/null +++ b/apps/mobile/test/sync.test.js @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import * as Progress from '@blockout/progress'; +import { claims, expiresSoon, mergeOnSignIn, progressKey } from '../src/sync.js'; + +const jwt = (payload) => `x.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.y`; + +test('the token says who it is and when it runs out', () => { + assert.deepEqual(claims(jwt({ sub: 'u1', name: 'Zoë' })), { sub: 'u1', name: 'Zoë' }); + assert.deepEqual(claims('not a token'), {}); + const now = Date.UTC(2026, 9, 17); + assert.equal(expiresSoon(jwt({ exp: now / 1000 + 30 }), now), true); + assert.equal(expiresSoon(jwt({ exp: now / 1000 + 600 }), now), false); +}); + +test('saves are kept per account, apart from the guest save', () => { + assert.equal(progressKey(null), 'blockout.progress'); + assert.equal(progressKey({ id: 'u1' }), 'blockout.progress.u1'); +}); + +test('first time on this device: the guest save goes into the account, and is sent', () => { + const remote = Progress.createProgress(); + Progress.addPoints(remote, 100); + const guest = Progress.createProgress(); + Progress.addPoints(guest, 40); + const { progress, push } = mergeOnSignIn({ remote, guest, local: null, firstTimeHere: true }); + assert.ok(progress.wallet >= 100); + assert.equal(push, true); +}); + +test('after that the account wins, and an empty account gets this device’s save', () => { + const remote = Progress.createProgress(); + Progress.addPoints(remote, 100); + const guest = Progress.createProgress(); + Progress.addPoints(guest, 999); + const later = mergeOnSignIn({ remote, guest, local: null, firstTimeHere: false }); + assert.equal(later.progress.wallet, 100); + assert.equal(later.push, false); + const local = Progress.createProgress(); + Progress.addPoints(local, 7); + const empty = mergeOnSignIn({ remote: null, guest, local, firstTimeHere: false }); + assert.equal(empty.progress.wallet, 7); + assert.equal(empty.push, true); +}); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..21272aa --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,19 @@ +{ + "name": "@blockout/server", + "version": "0.11.0", + "private": true, + "type": "module", + "description": "The Blockout server: serves the built games, runs classrooms (whole class, pairs, tournaments) and accounts (MongoDB).", + "dependencies": { + "@blockout/engine": "*", + "@blockout/progress": "*", + "mongodb": "^7.7.0", + "redis": "^6.3.0" + }, + "scripts": { + "dev": "node --watch src/index.js 8080", + "start": "node src/index.js 8080", + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/apps/server/scripts/loadtest.js b/apps/server/scripts/loadtest.js new file mode 100644 index 0000000..1e2db73 --- /dev/null +++ b/apps/server/scripts/loadtest.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +// A load test for live classes: CLASSES classes of STUDENTS students each. A +// teacher hosts each class, the students join as guests and keep an event +// stream open, and every round each student answers; the teacher rolls the next +// round when everyone has. It reports how long a new roll takes to reach every +// student (from the teacher's request to the last student's stream), and errors. +// +// node apps/server/scripts/loadtest.js https://blockout.example.org --classes 30 --students 30 --rounds 10 +// +// Hosting needs a teacher: with dev sign-in the script makes one; with Keycloak, +// pass a teacher's access token in TEACHER_TOKEN. Each class sends its own +// X-Forwarded-For (as a school would have its own address); the server only +// believes it with TRUST_PROXY, so run the target with TRUST_PROXY=1. +import { performance } from 'node:perf_hooks'; + +const args = process.argv.slice(2); +const opt = (name, def) => { + const i = args.indexOf(`--${name}`); + return i === -1 ? def : Number(args[i + 1]); +}; +const BASE = (args.find((a) => /^https?:/.test(a)) || 'http://localhost:8080').replace(/\/$/, ''); +const CLASSES = opt('classes', 5); +const STUDENTS = opt('students', 25); +const ROUNDS = opt('rounds', 5); + +const errors = new Map(); // what -> count +const fail = (what) => errors.set(what, (errors.get(what) || 0) + 1); +const latencies = []; // ms from "next round" to the last student seeing it + +async function call(method, path, body, { token, ip } = {}) { + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch(BASE + path, { + method, + headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(ip ? { 'X-Forwarded-For': ip } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + const data = await res.json().catch(() => ({})); + if (res.ok) return data; + if (res.status === 503 || res.status === 429) { + fail(`${method} ${path.replace(/\/[A-Z0-9]{4}\//, '/:code/')} ${res.status} (retried)`); + await new Promise((r) => setTimeout(r, 200 * (attempt + 1))); + continue; + } + throw new Error(`${method} ${path}: ${res.status} ${data.message || ''}`); + } + throw new Error(`${method} ${path}: gave up`); +} + +// An event stream read with fetch: calls onView(view) for every view +function stream(path, ip, onView) { + const ctl = new AbortController(); + (async () => { + try { + const res = await fetch(BASE + path, { headers: { 'X-Forwarded-For': ip }, signal: ctl.signal }); + if (!res.ok) return fail(`stream ${res.status}`); + const dec = new TextDecoder(); + let buf = ''; + for await (const chunk of res.body) { + buf += dec.decode(chunk, { stream: true }); + let i; + while ((i = buf.indexOf('\n\n')) >= 0) { + const block = buf.slice(0, i); + buf = buf.slice(i + 2); + const name = /^event: (.*)$/m.exec(block); + const data = /^data: (.*)$/m.exec(block); + if (name && name[1] === 'view' && data) onView(JSON.parse(data[1])); + } + } + } catch (e) { + if (e.name !== 'AbortError') fail(`stream ${e.message}`); + } + })(); + return () => ctl.abort(); +} + +async function runClass(n, teacherToken) { + const ip = `198.51.100.${n + 1}`; // (a documentation address range: one per "school") + const { code, teacherKey } = await call('POST', '/api/rooms', { settings: { type: 'class', rounds: Math.max(ROUNDS, 10) } }, { token: teacherToken, ip }); + const students = []; + for (let s = 0; s < STUDENTS; s++) { + const me = await call('POST', `/api/rooms/${code}/join`, { name: `Kid${s}` }, { ip }); + students.push({ ...me, round: 0, seen: null }); + } + // every student listens; a round "arrives" when its view shows the new round number + let waiting = null; // { round, left, t0, done } + const closers = students.map((st) => + stream(`/api/rooms/${code}/events?id=${st.id}&key=${st.key}`, ip, (view) => { + const round = view.round ?? (view.game && view.game.round); + if (typeof round !== 'number' || round === st.round) return; + st.round = round; + st.view = view; + if (waiting && round >= waiting.round && !st.counted) { + st.counted = true; + if (--waiting.left === 0) waiting.done(performance.now() - waiting.t0); + } + }) + ); + await new Promise((r) => setTimeout(r, 1000)); // (streams open) + const arrive = (round, fire) => + new Promise((resolve) => { + for (const st of students) st.counted = st.round >= round; + const left = students.filter((st) => !st.counted).length; + waiting = { round, left, t0: performance.now(), done: resolve }; + if (!left) resolve(0); + fire(); + setTimeout(() => resolve(null), 15000); // (counted as lost below) + }); + for (let round = 1; round <= ROUNDS; round++) { + const ms = await arrive(round, () => call('POST', `/api/rooms/${code}/teacher`, { key: teacherKey, action: round === 1 ? 'start' : 'next' }, { ip }).catch((e) => fail(e.message))); + if (ms === null) fail('a roll never reached every student (15 s)'); + else latencies.push(ms); + // everyone answers (a timeout answer: no board logic needed) + await Promise.all(students.map((st) => call('POST', `/api/rooms/${code}/result`, { id: st.id, key: st.key, round, kind: 'timeout' }, { ip }).catch((e) => fail(e.message)))); + } + await call('POST', `/api/rooms/${code}/teacher`, { key: teacherKey, action: 'close' }, { ip }).catch(() => {}); + closers.forEach((close) => close()); +} + +const pct = (list, p) => { + if (!list.length) return null; + const sorted = [...list].sort((a, b) => a - b); + return Math.round(sorted[Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length))]); +}; + +async function main() { + let token = process.env.TEACHER_TOKEN; + if (!token) token = (await call('POST', '/api/auth/dev', { role: 'teacher', firstName: 'Load', lastInitial: 'T' }, { ip: '198.51.100.250' })).token; + console.log(`${CLASSES} classes × ${STUDENTS} students, ${ROUNDS} rounds, against ${BASE}`); + const t0 = performance.now(); + const results = await Promise.allSettled(Array.from({ length: CLASSES }, (_, n) => runClass(n, token))); + for (const r of results) if (r.status === 'rejected') fail(`class: ${r.reason.message}`); + const report = { + seconds: Math.round((performance.now() - t0) / 1000), + streams: CLASSES * STUDENTS, + rounds: latencies.length, + rollToEveryStudentMs: { p50: pct(latencies, 50), p95: pct(latencies, 95), max: pct(latencies, 100) }, + errors: Object.fromEntries(errors), + }; + console.log(JSON.stringify(report, null, 2)); + const failedClasses = results.filter((r) => r.status === 'rejected').length; + process.exit(failedClasses || errors.has('a roll never reached every student (15 s)') ? 1 : 0); +} + +main(); diff --git a/apps/server/scripts/sqlite-to-mongo.js b/apps/server/scripts/sqlite-to-mongo.js new file mode 100644 index 0000000..9d58794 --- /dev/null +++ b/apps/server/scripts/sqlite-to-mongo.js @@ -0,0 +1,54 @@ +#!/usr/bin/env node +// Copies the accounts in an old sqlite file (data/blockout.db) into MongoDB. +// Safe to run again: every record is written by its id, so a second run +// changes nothing. Prints how many of each it found and how many MongoDB has. +// +// BLOCKOUT_MONGO_URL=mongodb://… node apps/server/scripts/sqlite-to-mongo.js [data/blockout.db] [--dry-run] +import path from 'path'; +import { openSqliteStore } from '../src/store/sqlite.js'; +import { openMongoStore } from '../src/store/mongo.js'; + +// Writes one export (store.exportAll()) into a MongoDB store's database +async function copyInto(db, all) { + // Each record replaces the one with the same key, or is added + const put = (name, docs, key) => Promise.all(docs.map((d) => db.collection(name).replaceOne(key(d), d, { upsert: true }))); + const byId = ({ _id }) => ({ _id }); + const withId = ({ id, ...rest }) => ({ _id: id, ...rest }); + for (const name of ['schools', 'classes', 'assignments', 'schedules']) await put(name, all[name].map(withId), byId); + // (permanent parent codes are retired: grown-ups get invites now) + await put('users', all.users.map(({ parentCode: _retired, ...u }) => ({ ...withId(u), invitedBy: null })), byId); + await put('progress', all.progress.map((p) => ({ _id: p.userId, json: JSON.stringify(p.data), updatedAt: p.updatedAt })), byId); + await put('classMembers', all.classMembers, ({ classId, userId }) => ({ classId, userId })); + await put('parentLinks', all.parentLinks, ({ parentId, childId }) => ({ parentId, childId })); + for (const [key, value] of Object.entries(all.meta)) await db.collection('meta').updateOne({ _id: key }, { $set: { value } }, { upsert: true }); +} + +const counts = (all) => Object.fromEntries(Object.entries(all).map(([k, v]) => [k, Array.isArray(v) ? v.length : Object.keys(v).length])); + +async function main(argv) { + const dryRun = argv.includes('--dry-run'); + const file = path.resolve(argv.find((a) => !a.startsWith('--')) || path.join(import.meta.dirname, '..', '..', '..', 'data', 'blockout.db')); + const sqlite = openSqliteStore(file); + const all = await sqlite.exportAll(); + await sqlite.close(); + console.log(`In ${file}:`, counts(all)); + if (dryRun) return; + if (!process.env.BLOCKOUT_MONGO_URL) throw new Error('Set BLOCKOUT_MONGO_URL to the MongoDB to copy into.'); + const mongo = await openMongoStore(process.env.BLOCKOUT_MONGO_URL); + try { + await mongo.migrate(); + await copyInto(mongo.db, all); + console.log('In MongoDB now:', counts(await mongo.exportAll())); + } finally { + await mongo.close(); + } +} + +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { + main(process.argv.slice(2)).catch((e) => { + console.error(e.message); + process.exit(1); + }); +} + +export { copyInto }; diff --git a/apps/server/src/accounts.js b/apps/server/src/accounts.js new file mode 100644 index 0000000..8e77d59 --- /dev/null +++ b/apps/server/src/accounts.js @@ -0,0 +1,713 @@ +// Blockout accounts: who can do what with classes, rosters, homework, parent +// links, invites and scheduled tournaments. Rules over the store (store/) and +// an auth provider (auth.js); index.js wires them to HTTP. Every function +// takes the signed-in user (or null for a guest) first. +// +// Roles live here, not in the sign-in provider: anyone who signs in is a +// student. A one-time invite makes someone a teacher (from an admin or a +// teacher) or a child's grown-up (from the child, their teacher or an admin); +// an admin can also change a role directly. Admins are the accounts with the +// `admin` realm role in Keycloak. +import crypto from 'crypto'; +import * as Progress from '@blockout/progress'; +import * as Auth from './auth.js'; +import * as Tournament from './tournament.js'; +import { silentLogger } from './log.js'; + +class AccountError extends Error { + constructor(code, message, status = 400) { + super(message); + this.code = code; + this.status = status; + } +} + +const MAX_PROGRESS_BYTES = 512 * 1024; +const DAY_MS = 24 * 60 * 60 * 1000; +const INVITE_DAYS = { teacher: 7, parent: 14 }; +const TEACHER_INVITES_OPEN = 10; // a teacher's unused teacher invites at one time… +const TEACHER_INVITES_A_DAY = 20; // …and made in a day +const PARENT_INVITES_A_DAY = 50; +const ROLES = ['student', 'teacher', 'parent']; +const CLASS_NAME_MAX = 40; +const TITLE_MAX = 60; +// Class codes and invites: no 0/O, 1/I/L, so they're easy to read out loud +const CODE_LETTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + +function randomCode(length, taken) { + for (;;) { + let code = ''; + // randomInt, not a byte % 31: every letter equally likely + for (let i = 0; i < length; i++) code += CODE_LETTERS[crypto.randomInt(CODE_LETTERS.length)]; + if (!taken(code)) return code; + } +} +const normCode = (code) => String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); +// Invites are kept as hashes: a copy of the database can't be used to redeem them +const hashCode = (code) => crypto.createHash('sha256').update(normCode(code)).digest('hex'); + +// 'YYYY-MM-DD' for a time (the server's local day) +function day(t = Date.now()) { + const d = new Date(t); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function createAccounts({ store, auth, now = () => Date.now(), log = silentLogger }) { + // Audit trail: who changed what, by id only (no names, no codes) + const audit = (event, user, attrs = {}) => log.info(event, { 'user.id': user.id, 'user.role': user.role, ...attrs }); + + // need(user, 'teacher', 'admin'): signed in, with one of these roles ('admin': an admin, whatever their role) + const need = (user, ...roles) => { + if (!user) throw new AccountError('signin', 'Please sign in first.', 401); + if (roles.length && !roles.includes(user.role) && !(roles.includes('admin') && user.isAdmin)) { + throw new AccountError('role', `Only ${roles.map((r) => `${r}s`).join(' and ')} can do that.`, 403); + } + return user; + }; + + // A code nobody has yet (taken is async: it asks the store) + async function freshCode(length, taken) { + for (;;) { + const code = randomCode(length, () => false); + if (!(await taken(code))) return code; + } + } + + // Schools change rarely and every sign-in needs them: keep them a minute + const SCHOOLS_MS = 60 * 1000; + let schoolsCache = null; + async function schools() { + if (!schoolsCache || now() - schoolsCache.at > SCHOOLS_MS) schoolsCache = { at: now(), list: await store.schools() }; + return schoolsCache.list; + } + + // What the page sees about a user (never the subject or the domain) + async function publicUser(u) { + const school = u.schoolId ? await store.school(u.schoolId) : null; + return { id: u.id, role: u.role, isAdmin: Boolean(u.isAdmin), name: u.name, firstName: u.firstName, lastInitial: u.lastInitial, school: school ? { id: school.id, name: school.name } : null }; + } + + // ---- signing in + + // Every request is signed in, but the account only needs writing when + // something in the token changed, or once a minute for "last seen" + const TOUCH_MS = 60 * 1000; + const recent = new Map(); // sub -> { at, same: identity as JSON } + + async function authenticate(token) { + if (!token) return null; + const claims = await auth.verify(token); + const list = await schools(); + const domain = (d) => String(d || '').toLowerCase(); + const id = Auth.identity(claims, { schoolByDomain: (d) => list.find((s) => s.domain === domain(d)) || null }); + const same = JSON.stringify(id); + const t = now(); + const seen = recent.get(id.sub); + let user = seen && seen.same === same && t - seen.at < TOUCH_MS ? await store.userBySub(id.sub) : null; + if (!user) { + if (recent.size > 10000) recent.clear(); + recent.set(id.sub, { at: t, same }); + user = await store.upsertUser(id, t); + } + return { ...user, isAdmin: id.isAdmin }; // (from the token, never stored) + } + + // Dev sign-in only: make a token for a made-up person. + async function devSignIn(body = {}) { + if (auth.name !== 'dev') throw new AccountError('provider', 'Dev sign-in is switched off on this server.', 403); + let email = body.email; + // A dev teacher picks a school; use a made-up address at its domain + if (body.role === 'teacher' && body.schoolId && !email) { + const school = await store.school(body.schoolId); + if (school) email = `teacher@${school.domain}`; + } + let token; + try { + token = auth.issue({ firstName: body.firstName, lastInitial: body.lastInitial, role: body.role, admin: Boolean(body.admin), email }); + } catch (e) { + throw new AccountError('name', e.message); + } + const user = await authenticate(token); + return { token, user: await publicUser(user) }; + } + + async function config() { + return { + provider: auth.name, + ...(auth.config || {}), + schools: (await store.schools()).map((s) => ({ id: s.id, name: s.name, domain: s.domain })), + }; + } + + // ---- progress + + async function getProgress(user) { + need(user); + const saved = await store.getProgress(user.id); + return saved ? { progress: saved.data, updatedAt: saved.updatedAt } : { progress: null }; + } + + async function putProgress(user, data) { + need(user); + if (!data || typeof data !== 'object') throw new AccountError('progress', 'That isn’t a save.'); + const clean = Progress.withoutCheats(data); // cheats (and what they unlocked) stay on the device + if (JSON.stringify(clean).length > MAX_PROGRESS_BYTES) throw new AccountError('progress', 'That save is too big.'); + await store.putProgress(user.id, clean, now()); + return { ok: true, updatedAt: now() }; + } + + const progressOf = async (userId) => ((await store.getProgress(userId)) || { data: null }).data; + + // ---- a student's stats (teacher and parent views) + + async function studentStats(student) { + const state = await progressOf(student.id); + const summary = Progress.factSummary(state, 12); + const homework = await Promise.all((await store.assignmentsForStudent(student.id)).map((a) => homeworkRow(state, a))); + const activity = ((state && state.activity) || []).slice(-10).reverse(); + return { + id: student.id, + name: student.name, + lastSeen: student.lastSeen, + lastPlayed: activity.length ? activity[0].t : null, + summary, + homework: { done: homework.filter((h) => h.status === 'done').length, late: homework.filter((h) => h.status === 'late').length, total: homework.length }, + assignments: homework, + recent: activity, + }; + } + + async function homeworkRow(state, a) { + const creator = await store.user(a.createdBy); + return { + id: a.id, + goal: a.goal, + due: a.due, + createdAt: a.createdAt, + classId: a.classId, + from: creator ? (creator.role === 'teacher' ? 'teacher' : 'parent') : 'teacher', + fromName: creator ? creator.name : '', + ...Progress.homeworkStatus(state, a, day(now())), + }; + } + + // ---- classes + + async function ownClass(user, classId) { + need(user, 'teacher'); + const c = await store.classById(classId); + if (!c || c.teacherId !== user.id) throw new AccountError('class', 'That class isn’t one of yours.', 404); + return c; + } + + async function createClass(user, body = {}) { + need(user, 'teacher'); + const name = String(body.name || '').replace(/\s+/g, ' ').trim().slice(0, CLASS_NAME_MAX); + if (!name) throw new AccountError('name', 'Give the class a name, like “Room 12”.'); + const code = await freshCode(6, async (c) => Boolean(await store.classByCode(c))); + const c = await store.createClass({ teacherId: user.id, schoolId: user.schoolId, name, code }, now()); + audit('class.created', user, { 'class.id': c.id }); + return classSummary(c); + } + + async function classSummary(c) { + const school = c.schoolId ? await store.school(c.schoolId) : null; + return { id: c.id, name: c.name, code: c.code, school: school ? school.name : null, students: (await store.members(c.id)).length }; + } + + const listClasses = async (user) => ({ classes: await Promise.all((await store.classesOfTeacher(need(user, 'teacher').id)).map(classSummary)) }); + + async function classDetail(user, classId) { + const c = await ownClass(user, classId); + const members = await store.members(c.id); + const students = await Promise.all(members.map((s) => studentStats(s))); + const assignments = (await store.assignmentsForClass(c.id)).map((a) => ({ + id: a.id, + goal: a.goal, + label: Progress.describeGoal(a.goal), + due: a.due, + createdAt: a.createdAt, + done: students.filter((s) => s.assignments.some((h) => h.id === a.id && h.status === 'done')).length, + of: students.length, + })); + // homework can only ask for what every student can already play + const options = Progress.sharedOptions(await Promise.all(members.map(async (m) => Progress.availableOptions(await progressOf(m.id))))); + return { class: await classSummary(c), students, assignments, options }; + } + + async function removeStudent(user, classId, studentId) { + const c = await ownClass(user, classId); + await store.removeMember(c.id, studentId); + audit('class.student_removed', user, { 'class.id': c.id, 'student.id': studentId }); + return { ok: true }; + } + + // A student joins a class roster with its code. School classes only take + // accounts from that school's domain (e.g. @iusd.org). + async function joinClass(user, body = {}) { + need(user, 'student'); + const c = await store.classByCode(normCode(body.code)); + if (!c) throw new AccountError('code', 'No class has that code. Check it with your teacher.', 404); + const school = c.schoolId ? await store.school(c.schoolId) : null; + if (school && user.domain !== school.domain) { + throw new AccountError('school', `This class is for ${school.name}. Sign in with your school account (@${school.domain}).`, 403); + } + await store.addMember(c.id, user.id, now()); + audit('class.joined', user, { 'class.id': c.id }); + return { class: { id: c.id, name: c.name } }; + } + + const myClasses = async (user) => ({ classes: (await store.classesOfStudent(need(user, 'student').id)).map((c) => ({ id: c.id, name: c.name })) }); + + // ---- invites + + // A fresh invite code, shown once: only its hash is kept + async function newInvite(kind, createdBy, { childId = null, schoolId = null } = {}) { + for (;;) { + const code = randomCode(8, () => false); + try { + await store.createInvite({ kind, codeHash: hashCode(code), createdBy: createdBy.id, childId, schoolId, expiresAt: now() + INVITE_DAYS[kind] * DAY_MS }, now()); + audit('invite.created', createdBy, { 'invite.kind': kind, ...(childId ? { 'student.id': childId } : {}) }); + return { kind, code, expiresAt: now() + INVITE_DAYS[kind] * DAY_MS }; + } catch (e) { + if (!/taken|duplicate|E11000/i.test(e.message)) throw e; // (else: the same code twice, try another) + } + } + } + + // A code for a child's grown-up: the child makes one for themself; their + // teacher or an admin for them. A new one replaces the maker's last unused one. + async function parentInvite(user, studentId = null) { + need(user, 'student', 'teacher', 'admin'); + const childId = user.role === 'student' && !studentId ? user.id : studentId; + const child = childId ? await store.user(childId) : null; + const allowed = child && child.role === 'student' && (child.id === user.id || user.isAdmin || (user.role === 'teacher' && (await canSee(user, child.id)))); + if (!allowed) throw new AccountError('student', 'You can’t make a code for that student.', 404); + if ((await store.countInvites({ createdBy: user.id, kind: 'parent', since: now() - DAY_MS })) >= PARENT_INVITES_A_DAY) { + throw new AccountError('limit', 'That’s a lot of codes today. Try again tomorrow.', 429); + } + await store.revokeInvites({ createdBy: user.id, kind: 'parent', childId: child.id }); + return newInvite('parent', user, { childId: child.id }); + } + + // A code that makes a new teacher: from an admin, or from a teacher bringing in a colleague + async function teacherInvite(user, body = {}) { + need(user, 'teacher', 'admin'); + let schoolId = user.schoolId || null; + if (user.isAdmin) schoolId = body.schoolId && (await store.school(body.schoolId)) ? body.schoolId : null; + else { + const open = await store.countInvites({ createdBy: user.id, kind: 'teacher', unused: true }); + const today = await store.countInvites({ createdBy: user.id, kind: 'teacher', since: now() - DAY_MS }); + if (open >= TEACHER_INVITES_OPEN || today >= TEACHER_INVITES_A_DAY) throw new AccountError('limit', 'You have lots of teacher codes waiting. Remove some, or wait for them to be used.', 429); + } + return newInvite('teacher', user, { schoolId }); + } + + // Invites someone made (never the codes themselves: only hashes are kept) + async function inviteView(i) { + const [usedBy, child, maker] = await Promise.all([i.usedBy && store.user(i.usedBy), i.childId && store.user(i.childId), store.user(i.createdBy)]); + return { + id: i.id, + kind: i.kind, + createdAt: i.createdAt, + expiresAt: i.expiresAt, + by: maker ? maker.name : null, + child: child ? child.name : null, + usedBy: usedBy ? usedBy.name : i.usedBy ? 'a deleted account' : null, + usedAt: i.usedAt, + expired: !i.usedAt && i.expiresAt <= now(), + }; + } + + const myTeacherInvites = async (user) => ({ + invites: await Promise.all((await store.invitesBy(need(user, 'teacher', 'admin').id)).filter((i) => i.kind === 'teacher').map(inviteView)), + }); + + async function deleteMyInvite(user, id) { + need(user); + const i = await store.invite(id); + if (!i || (i.createdBy !== user.id && !user.isAdmin)) throw new AccountError('invite', 'That isn’t one of your codes.', 404); + await store.deleteInvite(id); + audit('invite.deleted', user, { 'invite.id': id, 'invite.kind': i.kind }); + return { ok: true }; + } + + // Someone already playing or linked keeps their account as it is: a new role + // needs a fresh one (a child's classes or grown-ups never move to a teacher) + async function isFresh(user) { + const [classesIn, children, parents] = await Promise.all([store.classesOfStudent(user.id), store.childrenOf(user.id), store.parentsOf(user.id)]); + return !classesIn.length && !children.length && !parents.length; + } + + // Uses an invite: { code }. Answers with the account as it is now. + async function redeemInvite(user, body = {}, { only = null } = {}) { + need(user); + const notFound = () => new AccountError('code', 'That code doesn’t work. Check it, or ask for a new one.', 404); + const invite = await store.inviteByHash(hashCode(body.code)); + if (!invite || invite.usedAt !== null || invite.expiresAt <= now() || (only && invite.kind !== only)) throw notFound(); + let role = user.role; + if (invite.kind === 'teacher') { + if (user.role === 'teacher') throw new AccountError('role', 'You’re a teacher already.', 409); + if (user.role !== 'student' || !(await isFresh(user))) { + throw new AccountError('role', 'This account is already a student or a grown-up. Sign in with another account to teach.', 409); + } + role = 'teacher'; + } else { + if (invite.childId === user.id) throw new AccountError('role', 'That code is for a grown-up: give it to them.', 409); + if (user.role === 'teacher') throw new AccountError('role', 'Teachers see their students in My classes. Use a separate account as a grown-up.', 409); + if (user.role === 'student') { + if (!(await isFresh(user))) throw new AccountError('role', 'This account plays in a class. Sign in with your own account to be a grown-up.', 409); + role = 'parent'; + } + } + const used = await store.redeemInvite(invite.codeHash, user.id, now()); + if (!used) throw notFound(); // (someone else used it just now) + if (role !== user.role) await store.setRole(user.id, role, { invitedBy: invite.createdBy }); + if (invite.kind === 'parent') await store.linkParent(user.id, invite.childId, now()); + const after = { ...(await store.user(user.id)), isAdmin: user.isAdmin }; + audit('invite.redeemed', after, { 'invite.id': invite.id, 'invite.kind': invite.kind, ...(role !== user.role ? { 'role.from': user.role } : {}) }); + const child = invite.childId ? await store.user(invite.childId) : null; + return { user: await publicUser(after), ...(child ? { child: { id: child.id, name: child.name } } : {}) }; + } + + // ---- parents + + // A grown-up links a child with the code the child (or their teacher) made + const linkChild = (user, body = {}) => redeemInvite(user, body, { only: 'parent' }); + + async function unlinkChild(user, childId) { + need(user, 'parent'); + await store.unlinkParent(user.id, childId); + audit('parent.unlinked', user, { 'student.id': childId }); + return { ok: true }; + } + + // A child's grown-ups, for their teacher (to spot a wrong one) or an admin + async function studentParents(user, studentId) { + need(user, 'teacher', 'admin'); + if (!user.isAdmin && !(await canSee(user, studentId))) throw new AccountError('student', 'You can’t see that student.', 404); + return { parents: (await store.parentsOf(studentId)).map((p) => ({ id: p.id, name: p.name })) }; + } + + async function removeStudentParent(user, studentId, parentId) { + await studentParents(user, studentId); + await store.unlinkParent(parentId, studentId); + audit('parent.removed', user, { 'student.id': studentId, 'parent.id': parentId }); + return { ok: true }; + } + + const children = async (user) => ({ children: await Promise.all((await store.childrenOf(need(user, 'parent').id)).map((c) => studentStats(c))) }); + + // Who may see a student: their linked parents, and teachers of their classes + async function canSee(user, childId) { + if (user.role === 'parent') return store.isParentOf(user.id, childId); + if (user.role === 'teacher') return (await store.classesOfStudent(childId)).some((c) => c.teacherId === user.id); + return false; + } + + async function childDetail(user, childId) { + need(user, 'parent', 'teacher'); + const child = await store.user(childId); + if (!child || !(await canSee(user, childId))) throw new AccountError('student', 'You can’t see that student.', 404); + // the detail view also draws the whole Times Table grid + const state = await progressOf(child.id); + const facts = ((state && state.facts && state.facts.you) || { facts: {} }).facts; + return { ...(await studentStats(child)), facts, options: Progress.availableOptions(state) }; + } + + // ---- homework + + async function createAssignment(user, body = {}) { + need(user, 'teacher', 'parent'); + const goal = Progress.cleanGoal(body.goal); + if (!goal) throw new AccountError('goal', 'Pick what the homework is.'); + const due = body.due && /^\d{4}-\d{2}-\d{2}$/.test(body.due) ? body.due : null; + if (user.role === 'teacher') { + if (body.classId) { + await ownClass(user, body.classId); + const a = await store.createAssignment({ createdBy: user.id, classId: body.classId, goal, due }, now()); + audit('homework.created', user, { 'homework.id': a.id, 'class.id': body.classId }); + return { assignment: a }; + } + if (!body.studentId || !(await canSee(user, body.studentId))) throw new AccountError('student', 'Pick one of your classes or students.', 404); + } else if (!body.studentId || !(await store.isParentOf(user.id, body.studentId))) { + throw new AccountError('student', 'Link your child’s account first.', 404); + } + const a = await store.createAssignment({ createdBy: user.id, studentId: body.studentId, goal, due }, now()); + audit('homework.created', user, { 'homework.id': a.id, 'student.id': body.studentId }); + return { assignment: a }; + } + + async function deleteAssignment(user, id) { + need(user, 'teacher', 'parent'); + const a = await store.assignment(id); + if (!a || a.createdBy !== user.id) throw new AccountError('assignment', 'Only whoever set that homework can remove it.', 404); + await store.deleteAssignment(id); + audit('homework.deleted', user, { 'homework.id': id }); + return { ok: true }; + } + + async function myHomework(user) { + need(user, 'student'); + const state = await progressOf(user.id); + return { homework: await Promise.all((await store.assignmentsForStudent(user.id)).map((a) => homeworkRow(state, a))) }; + } + + // ---- scheduled tournaments + + async function createSchedule(user, body = {}) { + need(user, 'teacher'); + const scope = body.scope === 'school' ? 'school' : 'class'; + let classId = null; + if (scope === 'class') classId = (await ownClass(user, body.classId)).id; + else if (!user.schoolId) throw new AccountError('school', 'School tournaments need a school account.'); + const startsAt = Number(body.startsAt); + if (!Number.isFinite(startsAt) || startsAt < now() - 60 * 1000) throw new AccountError('time', 'Pick a time that hasn’t passed yet.'); + const s = body.settings || {}; + const settings = { + type: 'tournament', + format: Tournament.FORMATS.includes(s.format) ? s.format : 'knockout', + size: [6, 8, 10, 12].includes(Number(s.size)) ? Number(s.size) : 8, + difficulty: ['easy', 'medium', 'hard'].includes(s.difficulty) ? s.difficulty : 'easy', + }; + const title = String(body.title || '').replace(/\s+/g, ' ').trim().slice(0, TITLE_MAX) || (scope === 'school' ? 'School tournament' : 'Class tournament'); + const created = await store.createSchedule({ teacherId: user.id, scope, classId, schoolId: user.schoolId, title, startsAt, settings }, now()); + audit('schedule.created', user, { 'schedule.id': created.id, 'schedule.scope': scope }); + return { schedule: await scheduleView(created) }; + } + + async function scheduleView(s) { + const c = s.classId ? await store.classById(s.classId) : null; + return { id: s.id, title: s.title, scope: s.scope, classId: s.classId, className: c ? c.name : null, startsAt: s.startsAt, settings: s.settings, open: Boolean(s.roomCode), roomCode: s.roomCode }; + } + + const listSchedules = async (user) => ({ schedules: await Promise.all((await store.schedulesOfTeacher(need(user, 'teacher').id)).map(scheduleView)) }); + + async function ownSchedule(user, id) { + need(user, 'teacher'); + const s = await store.schedule(id); + if (!s || s.teacherId !== user.id) throw new AccountError('schedule', 'That isn’t one of your tournaments.', 404); + return s; + } + + async function deleteSchedule(user, id) { + await ownSchedule(user, id); + await store.deleteSchedule(id); + audit('schedule.deleted', user, { 'schedule.id': id }); + return { ok: true }; + } + + // The teacher's keys to an opened tournament's lobby + async function hostSchedule(user, id) { + const s = await ownSchedule(user, id); + if (!s.roomCode) throw new AccountError('schedule', 'That tournament hasn’t started yet.', 409); + return { code: s.roomCode, teacherKey: s.teacherKey }; + } + + // Called every few seconds: opens the lobby of every tournament that's due. + // createRoom(settings) -> { code, teacherKey } (a live room), or a promise of one. + // If another server opened it first, closeRoom(code) closes ours again. + async function openDue(createRoom, closeRoom = () => {}) { + const opened = []; + for (const s of await store.dueSchedules(now())) { + const room = await createRoom(s.settings); + if (!(await store.markOpened(s.id, room.code, room.teacherKey, now()))) { + await closeRoom(room.code); + continue; + } + opened.push({ id: s.id, code: room.code }); + } + return opened; + } + + // ---- admins + + // What an admin sees of an account: no more than the account holds (ADR-0004) + const adminUser = (u) => ({ id: u.id, name: u.name, role: u.role, domain: u.domain, schoolId: u.schoolId, invitedBy: u.invitedBy, createdAt: u.createdAt, lastSeen: u.lastSeen }); + + async function listUsers(user, query = {}) { + need(user, 'admin'); + const limit = Math.min(100, Math.max(1, Number(query.limit) || 50)); + const page = Math.max(0, Number(query.page) || 0); + const role = ROLES.includes(query.role) ? query.role : null; + const { users, total } = await store.listUsers({ q: String(query.q || '').slice(0, 40), role, limit, offset: page * limit }); + return { users: users.map(adminUser), total, page, limit }; + } + + // A teacher who still has classes or tournaments keeps the role until they're gone + async function stillTeaching(id) { + const [classesTaught, schedules] = await Promise.all([store.classesOfTeacher(id), store.schedulesOfTeacher(id)]); + return classesTaught.length > 0 || schedules.length > 0; + } + + async function setUserRole(user, id, body = {}) { + need(user, 'admin'); + const target = await store.user(id); + if (!target) throw new AccountError('user', 'No such account.', 404); + if (!ROLES.includes(body.role)) throw new AccountError('role', 'Pick student, teacher or parent.'); + if (target.role === body.role) return { user: adminUser(target) }; + if (target.role === 'teacher' && (await stillTeaching(id))) throw new AccountError('role', 'This teacher still has classes or tournaments. Move or delete them first.', 409); + await store.setRole(id, body.role, { invitedBy: user.id }); + audit('role.changed', user, { 'target.id': id, 'role.from': target.role, 'role.to': body.role }); + return { user: adminUser(await store.user(id)) }; + } + + async function deleteAccount(user, id, reason) { + await store.deleteUser(id); + recent.clear(); // (a cached sign-in mustn't bring it back for a minute) + audit('user.deleted', user, { 'target.id': id, 'deletion.reason': reason }); + return { ok: true }; + } + + // Admins can remove any account (a school's or a family's request) + async function adminDeleteUser(user, id) { + need(user, 'admin'); + if (!(await store.user(id))) throw new AccountError('user', 'No such account.', 404); + return deleteAccount(user, id, 'admin'); + } + + // Teachers and grown-ups can remove their own account (a teacher's classes go first) + async function deleteMe(user) { + need(user, 'teacher', 'parent'); + if (user.role === 'teacher' && (await stillTeaching(user.id))) throw new AccountError('role', 'Delete your classes and tournaments first.', 409); + return deleteAccount(user, user.id, 'self'); + } + + // A child's grown-up can remove the child's account and everything in it + async function deleteStudent(user, studentId) { + need(user, 'parent', 'admin'); + const child = await store.user(studentId); + if (!child || child.role !== 'student' || !(user.isAdmin || (await store.isParentOf(user.id, studentId)))) throw new AccountError('student', 'You can’t remove that account.', 404); + return deleteAccount(user, studentId, user.isAdmin ? 'admin' : 'parent'); + } + + const adminInvites = async (user, query = {}) => ({ + invites: await Promise.all((await store.allInvites({ kind: query.kind === 'parent' ? 'parent' : 'teacher' })).map(inviteView)), + }); + + const listSchools = async (user) => (need(user, 'admin'), { schools: await store.schools() }); + + async function addSchool(user, body = {}) { + need(user, 'admin'); + const name = String(body.name || '').replace(/\s+/g, ' ').trim().slice(0, 60); + const domain = String(body.domain || '').trim().toLowerCase(); + if (!name || !/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(domain)) throw new AccountError('school', 'Give the school a name and its email domain, like iusd.org.'); + if (await store.schoolByDomain(domain)) throw new AccountError('school', 'A school already has that domain.', 409); + const id = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || domain.replace(/\./g, '-'); + await store.addSchool({ id: (await store.school(id)) ? `${id}-${randomCode(4, () => false).toLowerCase()}` : id, name, domain }); + schoolsCache = null; + audit('school.added', user, { 'school.domain': domain }); + return { school: await store.schoolByDomain(domain) }; + } + + // ---- what's kept, and for how long + + // Everything Blockout keeps about an account, as one JSON document: for the + // person (or a child's grown-up) to download + async function exportOf(id) { + const u = await store.user(id); + if (!u) throw new AccountError('user', 'No such account.', 404); + const [saved, classesIn, classesTaught, children, parents, homeworkFor, invitesMade] = await Promise.all([ + store.getProgress(id), + store.classesOfStudent(id), + store.classesOfTeacher(id), + store.childrenOf(id), + store.parentsOf(id), + store.assignmentsForStudent(id), + store.invitesBy(id), + ]); + const school = u.schoolId ? await store.school(u.schoolId) : null; + return { + exportedAt: new Date(now()).toISOString(), + account: { id: u.id, signInId: u.sub, role: u.role, firstName: u.firstName, lastInitial: u.lastInitial, emailDomain: u.domain, school: school ? school.name : null, invitedBy: u.invitedBy, createdAt: u.createdAt, lastSeen: u.lastSeen }, + progress: saved ? { savedAt: saved.updatedAt, data: saved.data } : null, + classes: classesIn.map((c) => ({ name: c.name })), + classesTaught: classesTaught.map((c) => ({ name: c.name, code: c.code, createdAt: c.createdAt })), + children: children.map((c) => ({ name: c.name })), + grownUps: parents.map((p) => ({ name: p.name })), + homework: homeworkFor.map((a) => ({ goal: a.goal, due: a.due, createdAt: a.createdAt })), + codesMade: invitesMade.map((i) => ({ kind: i.kind, createdAt: i.createdAt, expiresAt: i.expiresAt, used: Boolean(i.usedAt) })), + }; + } + + async function exportMe(user) { + need(user); + audit('user.exported', user, { 'target.id': user.id }); + return exportOf(user.id); + } + + async function exportStudent(user, studentId) { + need(user, 'parent', 'admin'); + if (!user.isAdmin && !(await store.isParentOf(user.id, studentId))) throw new AccountError('student', 'You can’t see that student.', 404); + audit('user.exported', user, { 'target.id': studentId }); + return exportOf(studentId); + } + + // Accounts unused for retentionDays are deleted, with everything in them + // (a daily job on one server). Returns how many went. + async function deleteStale(retentionDays) { + if (!retentionDays) return 0; + const ids = await store.staleUsers(now() - retentionDays * DAY_MS, 500); + for (const id of ids) { + await store.deleteUser(id); + log.info('user.expired', { 'target.id': id, 'retention.days': retentionDays }); + } + if (ids.length) recent.clear(); + return ids.length; + } + + // Tournaments open right now for a student (their classes' and their school's) + async function live(user) { + if (!user || user.role !== 'student') return { live: [] }; + return { live: (await store.openSchedulesForStudent(user)).map((s) => ({ id: s.id, title: s.title, code: s.roomCode, startsAt: s.startsAt })) }; + } + + return { + authenticate, + devSignIn, + config, + publicUser, + me: async (user) => ({ user: await publicUser(need(user)) }), + getProgress, + putProgress, + createClass, + listClasses, + classDetail, + removeStudent, + joinClass, + myClasses, + parentInvite, + teacherInvite, + myTeacherInvites, + deleteMyInvite, + redeemInvite, + studentParents, + removeStudentParent, + listUsers, + setUserRole, + adminDeleteUser, + deleteMe, + deleteStudent, + adminInvites, + exportMe, + exportStudent, + deleteStale, + listSchools, + addSchool, + linkChild, + unlinkChild, + children, + childDetail, + createAssignment, + deleteAssignment, + myHomework, + createSchedule, + listSchedules, + deleteSchedule, + hostSchedule, + openDue, + live, + }; +} + +export { createAccounts, AccountError, day, normCode, hashCode }; diff --git a/apps/server/src/auth.js b/apps/server/src/auth.js new file mode 100644 index 0000000..1fb5c74 --- /dev/null +++ b/apps/server/src/auth.js @@ -0,0 +1,186 @@ +// Blockout sign-in: one interface, swappable providers. No dependencies. +// +// Every provider turns a bearer token into the same claims shape (OIDC-style): +// { sub, given_name, family_name, email?, email_verified?, roles: [...] } +// and `identity(claims)` turns claims into what we store (first name, last +// initial, email *domain* only, and only from a verified address). Roles are +// Blockout's own (accounts.js): a new account is a student, and only `admin` +// comes from the token. +// +// Providers: +// dev issues and checks HMAC-SHA256 signed JWTs ("dev login": pick any +// name and role). For development and the first classroom trials. +// keycloak checks RS256 JWTs from a Keycloak realm (OIDC with PKCE in the +// browser), shared with other apps: Google sign-in and +// self-registration. The client role `admin` on Blockout's own +// client makes a Blockout admin; realm roles mean nothing here. +// +// BLOCKOUT_AUTH=keycloak KEYCLOAK_ISSUER=https://id.example.org/realms/dev KEYCLOAK_CLIENT_ID=blockout +import crypto from 'crypto'; + +const ROLES = ['student', 'teacher', 'parent']; +const TOKEN_HOURS = 12; // dev sign-in tokens can't be renewed: a school day, then sign in again + +class AuthError extends Error { + constructor(message) { + super(message); + this.code = 'auth'; + } +} + +const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + +function decodeJwt(token) { + const parts = String(token || '').split('.'); + if (parts.length !== 3) throw new AuthError('Not a sign-in token.'); + try { + return { header: JSON.parse(fromB64url(parts[0])), payload: JSON.parse(fromB64url(parts[1])), signed: `${parts[0]}.${parts[1]}`, signature: fromB64url(parts[2]) }; + } catch (e) { + throw new AuthError('Not a sign-in token.'); + } +} + +function checkTimes(payload, now) { + const secs = Math.floor(now / 1000); + if (typeof payload.exp === 'number' && payload.exp < secs) throw new AuthError('Your sign-in has expired. Please sign in again.'); + if (typeof payload.nbf === 'number' && payload.nbf > secs + 60) throw new AuthError('Sign-in token not valid yet.'); +} + +// ---------------------------------------------------------------- dev provider + +function devProvider({ secret, now = () => Date.now() }) { + if (!secret) throw new Error('dev auth needs a secret'); + const sign = (data) => crypto.createHmac('sha256', secret).update(data).digest(); + return { + name: 'dev', + // Issue a token for a made-up person: { firstName, lastInitial, role, admin?, email? } + issue(person) { + const role = ROLES.includes(person.role) ? person.role : 'student'; + const firstName = cleanFirstName(person.firstName); + if (!firstName) throw new AuthError('Please type a first name.'); + const lastInitial = cleanInitial(person.lastInitial); + const email = person.email ? String(person.email).trim().toLowerCase() : ''; + if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new AuthError('That email doesn’t look right.'); + // The same person (name, role, email) always gets the same subject, so + // signing in again finds the same account. + const sub = `dev:${crypto.createHash('sha256').update(`${role}|${firstName.toLowerCase()}|${lastInitial}|${email}`).digest('hex').slice(0, 24)}`; + const iat = Math.floor(now() / 1000); + const payload = { iss: 'blockout-dev', sub, given_name: firstName, family_name: lastInitial, roles: person.admin ? [role, 'admin'] : [role], iat, exp: iat + TOKEN_HOURS * 3600 }; + if (email) Object.assign(payload, { email, email_verified: true }); // (made up, so as good as verified) + const head = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = b64url(JSON.stringify(payload)); + return `${head}.${body}.${b64url(sign(`${head}.${body}`))}`; + }, + async verify(token) { + const t = decodeJwt(token); + if (t.header.alg !== 'HS256') throw new AuthError('Wrong kind of sign-in token.'); + const expected = sign(t.signed); + if (expected.length !== t.signature.length || !crypto.timingSafeEqual(expected, t.signature)) throw new AuthError('Sign-in token isn’t valid.'); + checkTimes(t.payload, now()); + return t.payload; + }, + }; +} + +// ---------------------------------------------------------------- keycloak provider + +// issuer: https://host/realms/NAME. fetchJson(url) is injectable for tests. +function keycloakProvider({ issuer, clientId, fetchJson = defaultFetchJson, now = () => Date.now() }) { + if (!issuer || !clientId) throw new Error('keycloak auth needs KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID'); + let keys = null; // kid -> KeyObject + let fetchedAt = 0; + async function keyFor(kid) { + if (!keys || !keys.has(kid) || now() - fetchedAt > 3600 * 1000) { + const jwks = await fetchJson(`${issuer}/protocol/openid-connect/certs`); + keys = new Map((jwks.keys || []).filter((k) => k.kty === 'RSA').map((k) => [k.kid, crypto.createPublicKey({ key: k, format: 'jwk' })])); + fetchedAt = now(); + } + const key = keys.get(kid); + if (!key) throw new AuthError('Unknown sign-in key.'); + return key; + } + return { + name: 'keycloak', + config: { issuer, clientId }, + async verify(token) { + const t = decodeJwt(token); + if (t.header.alg !== 'RS256') throw new AuthError('Wrong kind of sign-in token.'); + const ok = crypto.verify('RSA-SHA256', Buffer.from(t.signed), await keyFor(t.header.kid), t.signature); + if (!ok) throw new AuthError('Sign-in token isn’t valid.'); + const p = t.payload; + if (p.iss !== issuer) throw new AuthError('Sign-in token is from somewhere else.'); + const aud = [].concat(p.aud || []); + if (p.azp !== clientId && !aud.includes(clientId)) throw new AuthError('Sign-in token is for another app.'); + checkTimes(p, now()); + // Only Blockout's client roles (resource_access.blockout.roles): the realm + // is shared, so its realm roles belong to every app (ADR-0016) + const own = p.resource_access && p.resource_access[clientId]; + return { ...p, roles: (own && own.roles) || [] }; + }, + }; +} + +async function defaultFetchJson(url) { + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new AuthError('Can’t reach the sign-in server.'); + return res.json(); +} + +// ---------------------------------------------------------------- identity + +function cleanFirstName(name) { + const n = String(name || '') + .normalize('NFC') + .replace(/[^\p{L}\p{M}' -]/gu, '') + .trim() + .split(/\s+/)[0] || ''; + return n.slice(0, 20); +} + +function cleanInitial(s) { + const c = String(s || '').normalize('NFC').replace(/[^\p{L}]/gu, '').charAt(0); + return c ? c.toUpperCase() : ''; +} + +// Claims -> what we keep. A new account starts as a student (Blockout keeps the +// role from then on); only dev sign-in picks the starting role. The token's +// `admin` role makes an admin for as long as the token says so (never stored). +// An unverified address counts as none: anyone can register any address in +// the shared realm, and the domain picks a school. +function identity(claims, { schoolByDomain = () => null } = {}) { + const email = claims.email_verified === true ? String(claims.email || '').toLowerCase() : ''; + const domain = email.includes('@') ? email.split('@').pop() : null; + const school = domain ? schoolByDomain(domain) : null; + const roles = [].concat(claims.roles || []); + const devRole = claims.iss === 'blockout-dev' ? ROLES.find((r) => roles.includes(r)) : undefined; + const firstName = cleanFirstName(claims.given_name || claims.name || claims.preferred_username) || 'Player'; + return { + sub: String(claims.sub), + ...(devRole ? { role: devRole } : {}), + isAdmin: roles.includes('admin'), + firstName, + lastInitial: cleanInitial(claims.family_name), + domain, + schoolId: school ? school.id : null, + }; +} + +async function fromEnv(env, store) { + if (env.BLOCKOUT_AUTH === 'keycloak') return keycloakProvider({ issuer: env.KEYCLOAK_ISSUER, clientId: env.KEYCLOAK_CLIENT_ID }); + // The dev secret is kept in the database so sign-ins survive a restart + let secret = env.BLOCKOUT_AUTH_SECRET || (await store.getMeta('devSecret')); + if (!secret) { + secret = crypto.randomBytes(32).toString('hex'); + await store.setMeta('devSecret', secret); + } + return devProvider({ secret }); +} + +// "Bearer xyz" -> "xyz" +const bearer = (req) => { + const h = req.headers.authorization || ''; + return h.startsWith('Bearer ') ? h.slice(7).trim() : null; +}; + +export { ROLES, AuthError, devProvider, keycloakProvider, identity, fromEnv, bearer, cleanFirstName, cleanInitial, b64url }; diff --git a/server/blocklist.txt b/apps/server/src/blocklist.txt similarity index 100% rename from server/blocklist.txt rename to apps/server/src/blocklist.txt diff --git a/apps/server/src/config.js b/apps/server/src/config.js new file mode 100644 index 0000000..035160a --- /dev/null +++ b/apps/server/src/config.js @@ -0,0 +1,90 @@ +// The server's settings, from the command line and the environment, in one +// place. Pure: loadConfig(env, argv) either returns the settings or throws a +// ConfigError naming everything that's wrong, so a bad deployment stops at +// start-up instead of running with unsafe defaults. + +class ConfigError extends Error { + constructor(problems) { + super(`Blockout can't start:\n${problems.map((p) => ` - ${p}`).join('\n')}`); + this.problems = problems; + } +} + +function loadConfig(env = {}, argv = []) { + const flag = (name) => { + const i = argv.indexOf(name); + return i === -1 ? null : argv[i + 1]; + }; + const problems = []; + const production = env.NODE_ENV === 'production'; + + const port = Number(argv.find((a) => /^\d+$/.test(a)) || env.PORT || 8080); + if (!Number.isInteger(port) || port < 0 || port > 65535) problems.push(`PORT must be a port number, not "${env.PORT}"`); + + // The address students should use, when it isn't this computer's own (tunnels, proxies) + const publicUrl = (flag('--public-url') || env.PUBLIC_URL || '').replace(/\/*$/, '/').replace(/^\/$/, ''); + if (publicUrl && !/^https?:\/\/[^/]+/.test(publicUrl)) problems.push(`PUBLIC_URL must be a web address like https://blockout.example.org, not "${publicUrl}"`); + + const authKind = env.BLOCKOUT_AUTH || 'dev'; + if (!['dev', 'keycloak'].includes(authKind)) problems.push(`BLOCKOUT_AUTH must be "dev" or "keycloak", not "${authKind}"`); + if (authKind === 'keycloak') { + if (!env.KEYCLOAK_ISSUER) problems.push('BLOCKOUT_AUTH=keycloak needs KEYCLOAK_ISSUER (the realm URL)'); + if (!env.KEYCLOAK_CLIENT_ID) problems.push('BLOCKOUT_AUTH=keycloak needs KEYCLOAK_CLIENT_ID'); + } + + // How many proxies in front of us to trust for X-Forwarded-For (0: none, use the socket's address) + const trustProxy = Number(env.TRUST_PROXY || 0); + if (!Number.isInteger(trustProxy) || trustProxy < 0) problems.push(`TRUST_PROXY must be a number of proxies (0 or more), not "${env.TRUST_PROXY}"`); + + // Where accounts are kept: MongoDB (BLOCKOUT_MONGO_URL), or memory (nothing kept: tests and quick tries). + // (The old sqlite file is only read by scripts/sqlite-to-mongo.js.) + const storeKind = env.BLOCKOUT_STORE || (env.BLOCKOUT_MONGO_URL ? 'mongo' : 'memory'); + if (!['mongo', 'memory'].includes(storeKind)) problems.push(`BLOCKOUT_STORE must be mongo or memory, not "${storeKind}"${storeKind === 'sqlite' ? ' (copy the sqlite file into MongoDB with apps/server/scripts/sqlite-to-mongo.js)' : ''}`); + if (storeKind === 'mongo' && !env.BLOCKOUT_MONGO_URL) problems.push('BLOCKOUT_STORE=mongo needs BLOCKOUT_MONGO_URL (mongodb://…)'); + + // Where live classes are kept: Redis/Valkey (BLOCKOUT_REDIS_URL), shared by every server, or this server's memory + const roomsKind = env.BLOCKOUT_ROOMS || (env.BLOCKOUT_REDIS_URL ? 'redis' : 'memory'); + if (!['redis', 'memory'].includes(roomsKind)) problems.push(`BLOCKOUT_ROOMS must be redis or memory, not "${roomsKind}"`); + if (roomsKind === 'redis' && !env.BLOCKOUT_REDIS_URL) problems.push('BLOCKOUT_ROOMS=redis needs BLOCKOUT_REDIS_URL (redis://…)'); + + const logLevel = env.LOG_LEVEL || 'info'; + if (!['debug', 'info', 'warn', 'error', 'silent'].includes(logLevel)) problems.push(`LOG_LEVEL must be debug, info, warn, error or silent, not "${logLevel}"`); + // JSON lines for a log stack in production; short text lines at a terminal + const logFormat = env.LOG_FORMAT || (production ? 'json' : 'text'); + if (!['json', 'text'].includes(logFormat)) problems.push(`LOG_FORMAT must be json or text, not "${logFormat}"`); + + // Accounts nobody signs in to for this many days are deleted (0: kept for ever) + const retentionDays = Number(env.BLOCKOUT_RETENTION_DAYS ?? 395); + if (!Number.isInteger(retentionDays) || retentionDays < 0) problems.push(`BLOCKOUT_RETENTION_DAYS must be a number of days (0 to keep accounts), not "${env.BLOCKOUT_RETENTION_DAYS}"`); + + // Prometheus metrics on a port of their own (never the public one): 0 for none + const metricsPort = Number(env.METRICS_PORT || 0); + if (!Number.isInteger(metricsPort) || metricsPort < 0 || metricsPort > 65535) problems.push(`METRICS_PORT must be a port number, not "${env.METRICS_PORT}"`); + + // A public server must not run with the practice sign-in (anyone can pick any role) or over plain HTTP + if (production) { + if (authKind !== 'keycloak') problems.push('NODE_ENV=production needs BLOCKOUT_AUTH=keycloak: dev sign-in lets anyone be a teacher'); + if (!publicUrl.startsWith('https://')) problems.push('NODE_ENV=production needs PUBLIC_URL to be an https:// address'); + if (storeKind !== 'mongo') problems.push('NODE_ENV=production needs BLOCKOUT_MONGO_URL: accounts must be kept in MongoDB'); + if (roomsKind !== 'redis') problems.push('NODE_ENV=production needs BLOCKOUT_REDIS_URL: live classes must be shared by every server'); + } + + if (problems.length) throw new ConfigError(problems); + return { + production, + port, + publicUrl, + https: publicUrl.startsWith('https://'), + trustProxy, + metricsPort, + retentionDays, + log: { level: logLevel, format: logFormat }, + // Names are also checked with OpenAI's free moderation model when this is set (and only then are names sent to OpenAI) + openaiKey: env.OPENAI_API_KEY || '', + store: { kind: storeKind, url: env.BLOCKOUT_MONGO_URL || undefined }, + rooms: { kind: roomsKind, url: env.BLOCKOUT_REDIS_URL || undefined, prefix: env.BLOCKOUT_REDIS_PREFIX || '' }, + auth: { kind: authKind, issuer: env.KEYCLOAK_ISSUER || null, clientId: env.KEYCLOAK_CLIENT_ID || null, secret: env.BLOCKOUT_AUTH_SECRET || null }, + }; +} + +export { loadConfig, ConfigError }; diff --git a/apps/server/src/index.js b/apps/server/src/index.js new file mode 100644 index 0000000..7338663 --- /dev/null +++ b/apps/server/src/index.js @@ -0,0 +1,689 @@ +#!/usr/bin/env node +// Blockout server: the games (the apps' built files), classrooms and accounts. No dependencies: Node's http module serves the +// game itself plus a small JSON API, and pushes live updates with Server-Sent Events. +// Where a proxy holds event streams back (Cloudflare quick tunnels, some school +// filters) pages fall back to long polling: /poll answers as soon as anything changes. +// +// npm run build, then: node apps/server/src/index.js [port] [--public-url https://example.com] +// +// Then open http://localhost:8080 on the teacher's computer. Chromebooks on the +// same network join at the address printed below (the teacher's screen shows it too). +// Behind a tunnel or proxy (e.g. cloudflared tunnel --url http://localhost:8080), +// pass its address as --public-url (or PUBLIC_URL=...) so the join code and QR use it. +import http from 'http'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as Rooms from './rooms.js'; +import * as Moderation from './moderation.js'; +import * as Auth from './auth.js'; +import { openStore } from './store/index.js'; +import { createAccounts, AccountError } from './accounts.js'; +import { loadConfig, ConfigError } from './config.js'; +import { createLimiter, createRedisLimiter, clientIp } from './ratelimit.js'; +import { createLive } from './live.js'; +import { createMetrics } from './metrics.js'; +import { createMemoryBackend } from './roombackend/memory.js'; +import { createRedisBackend } from './roombackend/redis.js'; +import { createLogger, exception } from './log.js'; +import crypto from 'crypto'; + +const IS_MAIN = Boolean(process.argv[1]) && path.resolve(process.argv[1]) === import.meta.filename; +let config; +try { + config = loadConfig(process.env, process.argv.slice(2)); +} catch (e) { + if (!(e instanceof ConfigError) || !IS_MAIN) throw e; + console.error(e.message); + process.exit(1); +} +const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version; +const log = createLogger({ ...config.log, version: VERSION }); + +// Prometheus metrics (served on METRICS_PORT, see the end of this file) +const metrics = createMetrics(); +const httpRequests = metrics.counter('http_requests_total', 'HTTP requests answered', ['method', 'route', 'status']); +const httpSeconds = metrics.histogram('http_request_duration_seconds', 'Time to answer an HTTP request (event streams: until they end)', ['route']); +const rateLimited = metrics.counter('rate_limited_total', 'Requests refused by a rate limit', ['rule']); +const serverErrors = metrics.counter('server_errors_total', 'Requests that failed with a 500', ['route']); +metrics.gauge('build_info', 'This server\'s version', () => [{ labels: { version: VERSION }, value: 1 }], ['version']); +const PORT = config.port; +const OPENAI_API_KEY = config.openaiKey; +const PUBLIC_URL = config.publicUrl; +// The built pages (apps/web/dist): the multiplication game at /, the others at +// /<name>/ (ADR-0015). +const WEB = path.resolve(import.meta.dirname, '..', '..', 'web', 'dist'); +const MAX_BODY = 16 * 1024; +const MAX_PROGRESS_BODY = 600 * 1024; // a signed-in student's whole save +const BODY_TIMEOUT_MS = 10 * 1000; +const SCHEDULE_TICK_MS = 10 * 1000; // how often due tournaments are opened + +// ---------------------------------------------------------------- static files + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.woff2': 'font/woff2', + '.webmanifest': 'application/manifest+json', +}; + +// /addition/ -> apps/web/dist/addition/index.html; nothing outside dist +function staticFile(pathname) { + const rel = pathname.endsWith('/') ? `${pathname}index.html` : pathname; + const file = path.resolve(WEB, `.${rel}`); + return file.startsWith(WEB + path.sep) ? file : null; +} + +function serveStatic(req, res) { + let pathname; + try { + pathname = decodePath(new URL(req.url, 'http://x').pathname); + } catch (e) { + return sendProblem(res, e.status, e.code, e.message); + } + // "/addition" without the slash: go to "/addition/" so relative paths work + if (/^\/[a-z-]+$/.test(pathname) && fs.existsSync(path.join(WEB, pathname.slice(1), 'index.html'))) { + res.writeHead(301, { Location: `${pathname}/`, ...SECURITY_HEADERS }); + return res.end(); + } + const file = staticFile(pathname); + if (!file) return send(res, 404, 'Not found', 'text/plain'); + fs.readFile(file, (err, data) => { + if (err) return send(res, 404, err.code === 'ENOENT' && !fs.existsSync(WEB) ? 'Not built yet: run npm run build' : 'Not found', 'text/plain'); + // Vite's hashed assets never change; everything else is checked each time + const cache = /\/assets\/.+-[\w-]{8,}\.\w+$/.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache'; + res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': cache, ...SECURITY_HEADERS }); + res.end(data); + }); +} + +// Only this server's own scripts, styles and images (plus the QR code's data: +// image), and only this server and the sign-in provider (it hands out tokens) +// to talk to. No inline scripts or style attributes: the pages don't need them. +function contentSecurityPolicy() { + const signIn = config.auth.kind === 'keycloak' ? ` ${new URL(config.auth.issuer).origin}` : ''; + return [ + "default-src 'self'", + "script-src 'self'", + "style-src 'self'", + "img-src 'self' data:", + "font-src 'self'", + `connect-src 'self'${signIn}`, + "object-src 'none'", + "base-uri 'none'", + "frame-ancestors 'none'", + `form-action 'self'${signIn}`, + ].join('; '); +} + +// Sent with every response: no sniffing content types, no framing by other +// sites, no full URLs (with class codes) leaked to other sites. +// No camera, microphone or location for any page. Over HTTPS, browsers are told +// to stay on HTTPS for a year. +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'same-origin', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=()', + 'Content-Security-Policy': contentSecurityPolicy(), + ...(config.https ? { 'Strict-Transport-Security': 'max-age=31536000' } : {}), +}; + +// An error as RFC 9457 problem details. `type` stays about:blank (the status +// says what kind of problem it is); `error` (a short code) and `message` (for +// the player) are extension members the games read. +const STATUS_TITLES = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 408: 'Request Timeout', 409: 'Conflict', 429: 'Too Many Requests', 500: 'Internal Server Error', 503: 'Service Unavailable' }; + +// A problem with the request itself (bad URL, slow body), answered with its own status +class HttpError extends Error { + constructor(status, code, message) { + super(message); + this.status = status; + this.code = code; + } +} + +// A malformed escape like %E0 makes decodeURIComponent throw; that's the client's mistake, not ours +function decodePath(s) { + try { + return decodeURIComponent(s); + } catch { + throw new HttpError(400, 'bad-request', 'Bad address.'); + } +} +function sendProblem(res, status, code, message) { + const body = { type: 'about:blank', title: STATUS_TITLES[status] || 'Error', status, detail: message, error: code, message }; + send(res, status, body, 'application/problem+json; charset=utf-8'); +} + +function send(res, status, body, type = 'application/json; charset=utf-8') { + res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', ...SECURITY_HEADERS }); + res.end(typeof body === 'string' ? body : JSON.stringify(body)); +} + +function readJson(req, max = MAX_BODY) { + return new Promise((resolve, reject) => { + let size = 0; + const chunks = []; + // A body that never finishes (or a client that goes away) must not hold the request forever + const timer = setTimeout(() => { + reject(new HttpError(408, 'timeout', 'The request took too long.')); + req.destroy(); + }, BODY_TIMEOUT_MS); + const fail = () => { + clearTimeout(timer); + reject(new HttpError(400, 'aborted', 'The request was cut off.')); + }; + req.on('error', fail); + req.on('close', () => (req.complete ? clearTimeout(timer) : fail())); + req.on('data', (c) => { + size += c.length; + if (size > max) { + reject(new Rooms.RoomError('size', 'Too much data.')); + req.destroy(); + } else chunks.push(c); + }); + req.on('end', () => { + clearTimeout(timer); + try { + resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {}); + } catch (e) { + reject(new Rooms.RoomError('json', 'Bad request.')); + } + }); + }); +} + +// ---------------------------------------------------------------- live classes +// Kept in a shared backend so several servers can run them (live.js): Redis +// (Valkey) on a server, memory for one server and tests. + +const roomBackend = + config.rooms.kind === 'redis' ? await createRedisBackend(config.rooms.url, { prefix: config.rooms.prefix, log }) : createMemoryBackend(); +const live = createLive({ backend: roomBackend, log }); +live.start(); +metrics.gauge('streams', 'Event streams open on this server', () => live.stats().streams); +metrics.gauge('polls', 'Long polls waiting on this server', () => live.stats().polls); +metrics.gauge('rooms_owned', 'Live classes whose timers this server runs', () => live.stats().owned); + +// Open streams are cheap but not free: a class needs ~41 (more with spare tabs), +// and a school shares one address +const MAX_ROOM_STREAMS = 200; +const MAX_IP_STREAMS = 500; +const ipStreams = new Map(); // address -> open streams + +// An event stream for a teacher or a student: their view now and after every change +async function openStream(req, res, code, role, playerId, ip) { + if (live.streamsOf(code) >= MAX_ROOM_STREAMS || (ipStreams.get(ip) || 0) >= MAX_IP_STREAMS) return tooMany(res, 30); + // no-transform / X-Accel-Buffering: proxies and tunnels must pass events straight through + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-store, no-transform', + 'X-Accel-Buffering': 'no', + Connection: 'keep-alive', + ...SECURITY_HEADERS, + }); + res.write('retry: 2000\n\n'); + ipStreams.set(ip, (ipStreams.get(ip) || 0) + 1); + await live.openStream(code, role, playerId, res, () => { + const n = ipStreams.get(ip) - 1; + if (n > 0) ipStreams.set(ip, n); + else ipStreams.delete(ip); + }); +} + +// Long polling: GET /api/rooms/CODE/poll?v=VERSION&teacher=KEY (or &id=ID&key=KEY). +// Answers straight away if the room has moved on from VERSION, otherwise at the +// next change (or after a while). Polling students count as here while they poll. +function openPoll(req, res, code, role, playerId, version) { + return live.openPoll(code, role, playerId, version, (body) => send(res, 200, body), (onGone) => req.on('close', onGone)); +} + +function teacherOnly(room, key) { + if (key !== room.teacherKey) throw new Rooms.RoomError('teacher', 'Only the teacher can do that.'); +} + +// Addresses students can use to reach this server: the public one first, then +// this computer's addresses on the local network. +function lanUrls() { + const out = PUBLIC_URL ? [PUBLIC_URL] : []; + if (config.production) return out; // a public server's own addresses are nobody's business + for (const list of Object.values(os.networkInterfaces())) { + for (const a of list || []) if (a.family === 'IPv4' && !a.internal) out.push(`http://${a.address}:${PORT}/`); + } + return out; +} + +// ---------------------------------------------------------------- accounts +// Teachers, students and parents (server/accounts.js), stored in sqlite +// (server/store.js). Sign-in is swappable (server/auth.js): dev sign-in for now, +// Keycloak later. Guests keep playing without any of this. + +const store = await openStore(config.store); +await store.migrate(); +const auth = await Auth.fromEnv(process.env, store); +const accounts = createAccounts({ store, auth, log }); + +// The signed-in user for a request, or null (a guest) +const signedIn = (req) => accounts.authenticate(Auth.bearer(req)); + +const openRoom = (settings) => live.createRoom(settings); + +// [method, path pattern, handler(user, params, req)]; :names in the pattern become params +const accountRoutes = [ + ['GET', '/api/auth/config', () => accounts.config()], + ['POST', '/api/auth/dev', async (user, p, req) => accounts.devSignIn(await readJson(req))], + ['GET', '/api/me', (user) => accounts.me(user)], + ['GET', '/api/me/progress', (user) => accounts.getProgress(user)], + ['PUT', '/api/me/progress', async (user, p, req) => accounts.putProgress(user, (await readJson(req, MAX_PROGRESS_BODY)).progress)], + ['GET', '/api/me/homework', (user) => accounts.myHomework(user)], + ['GET', '/api/me/classes', (user) => accounts.myClasses(user)], + ['POST', '/api/me/classes', async (user, p, req) => accounts.joinClass(user, await readJson(req))], + ['POST', '/api/me/parent-invites', (user) => accounts.parentInvite(user)], + ['GET', '/api/me/teacher-invites', (user) => accounts.myTeacherInvites(user)], + ['POST', '/api/me/teacher-invites', async (user, p, req) => accounts.teacherInvite(user, await readJson(req))], + ['DELETE', '/api/me/invites/:id', (user, p) => accounts.deleteMyInvite(user, p.id)], + ['POST', '/api/invites/redeem', async (user, p, req) => accounts.redeemInvite(user, await readJson(req))], + ['DELETE', '/api/me', (user) => accounts.deleteMe(user)], + ['GET', '/api/me/export', (user) => accounts.exportMe(user)], + ['GET', '/api/me/live', (user) => accounts.live(user)], + ['GET', '/api/classes', (user) => accounts.listClasses(user)], + ['POST', '/api/classes', async (user, p, req) => accounts.createClass(user, await readJson(req))], + ['GET', '/api/classes/:id', (user, p) => accounts.classDetail(user, p.id)], + ['DELETE', '/api/classes/:id/students/:student', (user, p) => accounts.removeStudent(user, p.id, p.student)], + ['GET', '/api/students/:id', (user, p) => accounts.childDetail(user, p.id)], + ['DELETE', '/api/students/:id', (user, p) => accounts.deleteStudent(user, p.id)], + ['GET', '/api/students/:id/export', (user, p) => accounts.exportStudent(user, p.id)], + ['POST', '/api/students/:id/parent-invites', (user, p) => accounts.parentInvite(user, p.id)], + ['GET', '/api/students/:id/parents', (user, p) => accounts.studentParents(user, p.id)], + ['DELETE', '/api/students/:id/parents/:parent', (user, p) => accounts.removeStudentParent(user, p.id, p.parent)], + ['POST', '/api/homework', async (user, p, req) => accounts.createAssignment(user, await readJson(req))], + ['DELETE', '/api/homework/:id', (user, p) => accounts.deleteAssignment(user, p.id)], + ['GET', '/api/children', (user) => accounts.children(user)], + ['POST', '/api/children', async (user, p, req) => accounts.linkChild(user, await readJson(req))], + ['DELETE', '/api/children/:id', (user, p) => accounts.unlinkChild(user, p.id)], + ['GET', '/api/schedules', (user) => accounts.listSchedules(user)], + ['POST', '/api/schedules', async (user, p, req) => accounts.createSchedule(user, await readJson(req))], + ['DELETE', '/api/schedules/:id', (user, p) => accounts.deleteSchedule(user, p.id)], + ['GET', '/api/schedules/:id/host', (user, p) => accounts.hostSchedule(user, p.id)], + // admins (the Keycloak realm role admin) + ['GET', '/api/admin/users', (user, p, req) => accounts.listUsers(user, Object.fromEntries(new URL(req.url, 'http://x').searchParams))], + ['PUT', '/api/admin/users/:id/role', async (user, p, req) => accounts.setUserRole(user, p.id, await readJson(req))], + ['DELETE', '/api/admin/users/:id', (user, p) => accounts.adminDeleteUser(user, p.id)], + ['GET', '/api/admin/invites', (user, p, req) => accounts.adminInvites(user, Object.fromEntries(new URL(req.url, 'http://x').searchParams))], + ['POST', '/api/admin/invites', async (user, p, req) => accounts.teacherInvite(user, await readJson(req))], + ['GET', '/api/admin/schools', (user) => accounts.listSchools(user)], + ['POST', '/api/admin/schools', async (user, p, req) => accounts.addSchool(user, await readJson(req))], +]; + +function matchAccountRoute(method, pathname) { + const parts = pathname.split('/').filter(Boolean); + for (const [m, pattern, handler] of accountRoutes) { + if (m !== method) continue; + const want = pattern.split('/').filter(Boolean); + if (want.length !== parts.length) continue; + const params = {}; + if (want.every((w, i) => (w.startsWith(':') ? (params[w.slice(1)] = decodePath(parts[i])) : w === parts[i]))) return { handler, params, pattern }; + } + return null; +} + +// Scheduled tournaments: open each one's lobby when it's due +// (by one server at a time) +const scheduleTimer = setInterval(async () => { + if (!(await live.lead('schedules', SCHEDULE_TICK_MS * 3))) return; + accounts.openDue(openRoom, (code) => live.closeRoom(code)).catch((e) => log.error('schedule.error', exception(e))); +}, SCHEDULE_TICK_MS).unref(); + +// Accounts nobody has used for a long time go, with everything in them (one +// server at a time, checked every hour) +const retentionTimer = setInterval(async () => { + if (!(await live.lead('retention', 2 * 60 * 60 * 1000))) return; + accounts + .deleteStale(config.retentionDays) + .then((n) => n && log.info('retention.deleted', { 'user.count': n, 'retention.days': config.retentionDays })) + .catch((e) => log.error('retention.error', exception(e))); +}, 60 * 60 * 1000).unref(); + +const routes = { + 'GET /api/health': () => ({ ok: true, lan: lanUrls() }), + + // Hosting a class needs a teacher account + 'POST /api/rooms': async (req) => { + const user = await signedIn(req); + if (!user || user.role !== 'teacher') throw new AccountError('teacher', 'Teachers: please sign in to host a class.', 401); + const body = await readJson(req); + const room = await openRoom(body.settings); + return { code: room.code, teacherKey: room.teacherKey, lan: lanUrls() }; + }, + + // Students send { name }. The teacher can join as a player (pairs: to play the + // odd one out) with { teacher: KEY } from the projector screen or { play: KEY } + // from another device. + 'POST /api/rooms/:code/join': async (req, code) => { + const room = await live.getRoom(code); + const body = await readJson(req); + let join; + if (body.teacher || body.play) { + if (body.teacher !== room.teacherKey && body.play !== room.playKey) throw new Rooms.RoomError('teacher', 'That teacher link isn’t right.'); + join = (r) => Rooms.joinTeacher(r, body.look); + } else { + // Signed-in students play under their account's name (their school + // already knows it): no name check, nothing sent to OpenAI. + const user = await signedIn(req); + if (user && user.role === 'student') join = (r) => Rooms.join(r, user.firstName, body.look); + else { + const check = await Moderation.checkName(body.name, { apiKey: OPENAI_API_KEY, log }); + if (!check.ok) throw new Rooms.RoomError('name', 'Please use your real first name.'); + join = (r) => Rooms.join(r, body.name, body.look); + } + } + const p = await live.withRoom(room.code, join); + return { id: p.id, key: p.key, name: p.name, teacher: Boolean(p.isTeacher) }; + }, + + // Does this class exist? (A join link checks before asking for a name.) + 'GET /api/rooms/:code/info': async (req, code) => { + const room = await live.getRoom(code); + return { code: room.code, type: room.type, state: room.state }; + }, + + // Pairs: roll for your turn. + 'POST /api/rooms/:code/roll': async (req, code) => { + const body = await readJson(req); + return live.withRoom(code, (r) => Rooms.pairRoll(r, Rooms.findPlayer(r, body.id, body.key))); + }, + + // Pairs: what the player on turn is doing (dragging, answering), for their partner + 'POST /api/rooms/:code/live': async (req, code) => { + const body = await readJson(req); + await live.setLive(code, body.id, body.key, body.live); + return { ok: true }; + }, + + 'POST /api/rooms/:code/leave': async (req, code) => { + const body = await readJson(req); + await live.withRoom(code, (r) => Rooms.removePlayer(r, Rooms.findPlayer(r, body.id, body.key).id)); + return { ok: true }; + }, + + 'POST /api/rooms/:code/result': async (req, code) => { + const body = await readJson(req); + const score = await live.withRoom(code, (r) => { + const p = Rooms.findPlayer(r, body.id, body.key); + Rooms.submit(r, p, body); + return p.score; + }); + return { ok: true, score }; + }, + + // (The owning server starts the next round by itself when it's time: live.js.) + 'POST /api/rooms/:code/teacher': async (req, code) => { + const body = await readJson(req); + if (body.action === 'close') { + teacherOnly(await live.getRoom(code), body.key); + await live.closeRoom(code); + return { ok: true }; + } + await live.withRoom(code, (room) => { + teacherOnly(room, body.key); + switch (body.action) { + case 'settings': + return Rooms.setSettings(room, body.settings || {}); + case 'pairOptions': + return Rooms.setPairOptions(room, body.options || {}); + case 'swap': + return Rooms.swapOrder(room, body.a, body.b); + case 'start': + return Rooms.start(room); + case 'next': + if (room.type !== 'tournament') return Rooms.nextRound(room); + if (room.roundReady) Rooms.advanceTournament(room); + return; + case 'autoNext': + room.autoNext = Boolean(body.on); + return; + case 'end': + return Rooms.end(room); + case 'lobby': + return Rooms.backToLobby(room); + case 'remove': + return Rooms.removePlayer(room, body.id); + default: + throw new Rooms.RoomError('action', 'Unknown action.'); + } + }); + return { ok: true }; + }, +}; + +// ---------------------------------------------------------------- rate limits + +// (in Redis when live classes are: then every server counts together). If +// Redis can't answer, requests go through: a limit is no reason to stop a class. +const counted = roomBackend.client ? createRedisLimiter(roomBackend.client, { prefix: config.rooms.prefix }) : createLimiter(); +const allow = (e) => (log.warn('rate.unavailable', exception(e)), { ok: true }); +const limiter = { + check: (rule, key) => Promise.resolve(counted.check(rule, key)).catch(allow), + take: (rule, key) => Promise.resolve(counted.take(rule, key)).catch(allow), + prune: () => counted.prune(), +}; +setInterval(() => limiter.prune(), 60 * 1000).unref(); + +// Where a code is looked up: a run of wrong codes from one address is someone guessing +const CODE_ROUTES = new Set(['POST /api/me/classes', 'POST /api/children', 'POST /api/invites/redeem']); +const isCodeLookup = (req, r) => (r.code && !r.live) || CODE_ROUTES.has(`${req.method} ${r.url.pathname}`); +const isMiss = (e) => (e instanceof Rooms.RoomError && e.code === 'room') || (e instanceof AccountError && e.status === 404 && e.code === 'code'); +const tokenKey = (req) => crypto.createHash('sha256').update(Auth.bearer(req) || '').digest('hex').slice(0, 16); + +function tooMany(res, retryAfter, rule = 'streams') { + rateLimited.inc({ rule }); + res.setHeader('Retry-After', String(retryAfter)); + sendProblem(res, 429, 'rate', 'Too many tries. Wait a minute and try again.'); +} + +// ---------------------------------------------------------------- health and shutdown + +// One endpoint per Kubernetes probe. None of them sign anyone in, and none +// count towards rate limits. +// startup: everything is set up (later: databases connected and migrated) +// live: the event loop still turns; never checks other services, so an +// outage there doesn't get every server restarted +// ready: send traffic here (not while shutting down, not without a database) +const LAG_LIMIT_MS = 5000; +let started = false; +let metricsServer = null; // (set where the server starts listening) +let draining = false; +let lag = 0; +let lastTick = performance.now(); +setInterval(() => { + const t = performance.now(); + lag = Math.max(0, t - lastTick - 1000); + lastTick = t; +}, 1000).unref(); + +// The databases answer (accounts, and live classes when they're in Redis) +async function storeOk() { + try { + return Boolean(await store.ping()) && Boolean(await roomBackend.ping()); + } catch { + return false; + } +} + +const HEALTH = { + '/health/startup': () => (started ? [200, { status: 'started' }] : [503, { status: 'starting' }]), + '/health/live': () => (lag < LAG_LIMIT_MS ? [200, { status: 'live' }] : [503, { status: 'stuck', lagMs: Math.round(lag) }]), + '/health/ready': async () => { + if (!started) return [503, { status: 'starting' }]; + if (draining) return [503, { status: 'draining' }]; + if (!(await storeOk())) return [503, { status: 'database' }]; + return [200, { status: 'ready' }]; + }, +}; + +// Stop taking new work, hand classes over and send browsers on, and finish. +// With Redis the classes carry on at another server; with memory they end here. +async function drain({ timeoutMs = 20000 } = {}) { + if (draining) return; + draining = true; + clearInterval(scheduleTimer); + clearInterval(retentionTimer); + const closed = new Promise((resolve) => server.close(resolve)); + await live.drain(); // (streams end so browsers reconnect, polls are answered, classes go to another server) + if (metricsServer) metricsServer.close(); + server.closeIdleConnections(); + const timeout = new Promise((resolve) => setTimeout(resolve, timeoutMs).unref()); + await Promise.race([closed, timeout]); + server.closeAllConnections(); + await store.close(); + await roomBackend.quit(); +} + +function route(req) { + const url = new URL(req.url, 'http://x'); + const parts = url.pathname.split('/').filter(Boolean); // api, rooms, CODE, action + if (parts[0] !== 'api') return null; + const account = matchAccountRoute(req.method, url.pathname); + if (account) return { handler: async (r) => account.handler(await signedIn(r), account.params, r), url, name: account.pattern }; + if (parts.length === 4 && parts[1] === 'rooms') { + const handler = routes[`${req.method} /api/rooms/:code/${parts[3]}`]; + const live = req.method === 'GET' && /^(events|poll)$/.test(parts[3]); + // (the route's name for logs never includes the code, and only known actions) + return { handler, code: parts[2], url, name: handler || live ? `/api/rooms/:code/${parts[3]}` : '/api/rooms/:code/:action' }; + } + const handler = routes[`${req.method} ${url.pathname}`]; + return { handler, url, name: handler ? url.pathname : '/api/:unknown' }; +} + +const server = http.createServer(async (req, res) => { + const health = HEALTH[req.url.split('?')[0]]; + if (health) { + const [status, body] = await health(); + return send(res, status, body); + } + const ip = clientIp(req, config.trustProxy); + const t0 = performance.now(); + let r; + // One log record per request: the route's pattern (never a path with codes in it), status and time + // (at a terminal only with LOG_LEVEL=debug: a class polls several times a second) + res.on('finish', () => { + const route = r ? r.name : 'static'; + const ms = performance.now() - t0; + httpRequests.inc({ method: req.method, route, status: res.statusCode }); + httpSeconds.observe({ route }, ms / 1000); + log[config.log.format === 'json' ? 'info' : 'debug']('http.request', { + 'http.request.method': req.method, + 'http.route': route, + 'http.response.status_code': res.statusCode, + duration_ms: Math.round(ms), + }); + }); + try { + const any = await limiter.take('request', ip); + if (!any.ok) return tooMany(res, any.retryAfter, 'request'); + r = route(req); + if (!r) return serveStatic(req, res); + // Live updates: /api/rooms/CODE/events?teacher=KEY or ?id=ID&key=KEY + // or, where streams don't get through, /poll with the same keys plus ?v=VERSION + r.live = req.method === 'GET' && r.code && /\/(events|poll)$/.test(r.url.pathname); + if (isCodeLookup(req, r)) { + const misses = await limiter.check('miss', ip); + if (!misses.ok) return tooMany(res, misses.retryAfter, 'miss'); + if (CODE_ROUTES.has(`${req.method} ${r.url.pathname}`) && Auth.bearer(req)) { + const tries = await limiter.take('codeTry', tokenKey(req)); + if (!tries.ok) return tooMany(res, tries.retryAfter, 'codeTry'); + } + } + if (r.url.pathname === '/api/auth/dev') { + const signIns = await limiter.take('devSignIn', ip); + if (!signIns.ok) return tooMany(res, signIns.retryAfter, 'devSignIn'); + } + if (r.live) { + const poll = r.url.pathname.endsWith('/poll'); + const code = String(r.code).toUpperCase(); + if (poll && (await live.isClosed(code))) return send(res, 200, { closed: true }); + const room = await live.getRoom(code); + const q = r.url.searchParams; + const version = Number(q.get('v')); + // A wrong key looks the same as no class, so streams can't be used to find codes + const noRoom = () => new Rooms.RoomError('room', 'No class with that code. Check the code on the board.'); + if (q.has('teacher')) { + if (q.get('teacher') !== room.teacherKey) throw noRoom(); + return poll ? await openPoll(req, res, code, 'teacher', null, version) : await openStream(req, res, code, 'teacher', null, ip); + } + const player = room.players.find((x) => x.id === q.get('id')); + if (poll && !player) return send(res, 200, { removed: true }); + let p; + try { + p = Rooms.findPlayer(room, q.get('id'), q.get('key')); + } catch { + throw noRoom(); + } + return poll ? await openPoll(req, res, code, 'student', p.id, version) : await openStream(req, res, code, 'student', p.id, ip); + } + if (!r.handler) return sendProblem(res, 404, 'not-found', 'Not found.'); + send(res, 200, await r.handler(req, r.code)); + } catch (e) { + if (r && isMiss(e) && isCodeLookup(req, r)) await limiter.take('miss', ip); + if (e instanceof HttpError) return sendProblem(res, e.status, e.code, e.message); + if (e instanceof AccountError) return sendProblem(res, e.status, e.code, e.message); + if (e instanceof Auth.AuthError) return sendProblem(res, 401, 'auth', e.message); + if (e instanceof Rooms.RoomError) { + const status = e.code === 'room' || e.code === 'player' ? 404 : e.code === 'teacher' ? 403 : e.code === 'busy' ? 503 : 409; + return sendProblem(res, status, e.code, e.message); + } + serverErrors.inc({ route: r ? r.name : 'static' }); + log.error('http.error', { 'http.request.method': req.method, 'http.route': r ? r.name : 'static', ...exception(e) }); + sendProblem(res, 500, 'server', 'Something went wrong.'); // (never the error itself: it stays in the log) + } +}); + + +if (IS_MAIN) { + // Anything that slips past the request handlers: log it and exit, so the + // process manager (Kubernetes, systemd) restarts a clean server + const fatal = (event) => (e) => { + log.error(event, exception(e)); + process.exit(1); + }; + process.on('unhandledRejection', fatal('process.unhandled_rejection')); + process.on('uncaughtException', fatal('process.uncaught_exception')); + // Kubernetes sends SIGTERM before stopping a pod; Ctrl+C is SIGINT + for (const signal of ['SIGTERM', 'SIGINT']) { + process.once(signal, async () => { + log.info('server.draining', { signal }); + await drain(); + process.exit(0); + }); + } + // Metrics on their own port: the gateway only ever reaches PORT + if (config.metricsPort) { + metricsServer = http.createServer((req, res) => { + if (req.url !== '/metrics') return send(res, 404, 'Not found', 'text/plain'); + send(res, 200, metrics.render(), 'text/plain; version=0.0.4; charset=utf-8'); + }); + metricsServer.listen(config.metricsPort); + } + server.listen(PORT, () => { + log.info('server.started', { 'server.port': PORT, 'auth.kind': auth.name, 'store.kind': store.kind, 'rooms.kind': config.rooms.kind, 'moderation.openai': Boolean(OPENAI_API_KEY), 'deployment.production': config.production }); + if (store.kind === 'memory') log.warn('store.memory', { 'store.kind': 'memory' }); // accounts aren't kept: set BLOCKOUT_MONGO_URL + if (config.log.format === 'json') return; + // a person at a terminal: where to go + console.log(`Blockout server on http://localhost:${PORT}/`); + for (const url of lanUrls()) console.log(url === PUBLIC_URL ? ` Students join at: ${url}` : ` Chromebooks on this network: ${url}`); + console.log(` Name check: word list${OPENAI_API_KEY ? ' + OpenAI moderation' : ' (set OPENAI_API_KEY to add OpenAI moderation)'}`); + console.log(` Accounts: ${store.kind === 'mongo' ? 'MongoDB' : 'memory: nothing is kept after a restart (set BLOCKOUT_MONGO_URL, or run npm run services)'}`); + console.log(` Live classes: ${config.rooms.kind === 'redis' ? 'Redis' : 'this server only (set BLOCKOUT_REDIS_URL to share them)'}`); + console.log(` Sign-in: ${auth.name === 'dev' ? 'dev sign-in (pick any name and role; set BLOCKOUT_AUTH=keycloak for Keycloak)' : `Keycloak at ${auth.config.issuer}`}`); + }); +} + +started = true; + +export { metrics, server, live, store, accounts, drain }; diff --git a/apps/server/src/live.js b/apps/server/src/live.js new file mode 100644 index 0000000..6fb9ebb --- /dev/null +++ b/apps/server/src/live.js @@ -0,0 +1,477 @@ +// Live classes across several servers. The rooms themselves (rooms.js) stay +// pure; this runtime keeps them in a shared backend (Redis, or memory for one +// server and tests) and runs everything around them: +// +// withRoom(code, fn) every change: read, change, write back only if nobody +// else wrote in between (else try again), then everyone hears +// owner one server per room runs its timers (the CPU's moves, +// "next round" by itself); if it goes away another takes over +// presence who has a stream open (on any server) or polled lately, +// laid over the room when it's read, never saved in it +// listeners the streams and long polls connected to *this* server, +// sent their own view when the room changes +// live moves a partner's drag or answer in pairs: straight to the +// two players, without a write +// +// Timers are passed in (now, setTimeout…) so tests can run hours in milliseconds. +import crypto from 'crypto'; +import * as Rooms from './rooms.js'; +import { silentLogger, exception } from './log.js'; + +const DEFAULTS = { + roomTtlMs: 4 * 60 * 60 * 1000, // forget a class after 4 hours without a change + closedTtlMs: 30 * 60 * 1000, // pollers of a closed class are told so for this long + leaseMs: 15000, // a room's owner must renew this often… + renewMs: 5000, // …and does, this often + sweepMs: 5000, // look for rooms without an owner, and check who's here + presenceMs: 30000, // a stream on a server counts this long after its last heartbeat + heartbeatMs: 10000, + pollHoldMs: 25000, // a long poll waits this long for a change before answering anyway + sseBeatMs: 25000, // a comment line on idle streams, so proxies keep them open + coalesceMs: 60, // several changes in a row are sent once + liveMs: 80, // live moves at most this often + autoNextMs: 2500, // pause after everyone is done, so the class sees the last answers land + roundPauseMs: 5000, // tournaments: time to see your result before the next round + cpuRollMs: 1500, // pairs: the CPU's turn, slow enough to watch + cpuPlaceMs: 2200, + maxTries: 25, // (a whole class answering in the same instant, on two servers) +}; +const CPU_SPEED = { slow: 1.6, normal: 1, fast: 0.4 }; // the teacher's "CPU speed" setting + +const noRoom = () => new Rooms.RoomError('room', 'No class with that code. Check the code on the board.'); + +function createLive({ backend, log = silentLogger, replicaId = crypto.randomBytes(6).toString('hex'), now = () => Date.now(), timers = globalThis, ...options } = {}) { + const o = { ...DEFAULTS, ...options }; + const { setTimeout, clearTimeout, setInterval, clearInterval } = timers; + const local = new Map(); // code -> { streams, polls, unsubscribe, pending, live: Map(match -> timer), presenceTimer } + const owned = new Map(); // code -> Map(timer key -> { sig, timer }) + const queues = new Map(); // code -> promise: this server's changes to a room, one at a time + const touched = new Map(); // code -> when this server last kept the room alive + let stopped = false; + let ticks = []; + + // ---- reading and writing + + // A room as stored, with who's here (and, for views, live moves) laid over it + async function load(code, { withLive = false } = {}) { + const got = await backend.read(code); + if (!got) return null; + const room = JSON.parse(got.s); + room.version = got.v; + overlay(room, await backend.fields(code, 'presence'), withLive ? await backend.fields(code, 'live') : null); + return room; + } + + function overlay(room, presence, live) { + const t = now(); + for (const p of room.players) { + if (p.isCpu) continue; + p.connections = Object.entries(presence).filter(([f, until]) => f.startsWith(`${p.id}|s:`) && Number(until) > t).length; + p.seenAt = Number(presence[`${p.id}|poll`]) || 0; + } + if (!live) return; + for (const m of room.matches || []) { + const move = live[m.id] && JSON.parse(live[m.id]); + if (move && move.turn === m.turn) m.live = move; + } + } + + // What gets saved: the room without anything laid over it + function strip(room) { + for (const p of room.players) { + if (p.isCpu) continue; + p.connections = 0; + delete p.seenAt; + } + return JSON.stringify(room); + } + + async function getRoom(code) { + code = String(code || '').toUpperCase(); + const room = await load(code); + if (!room) throw noRoom(); + // Someone is still using it: keep it (at most once a minute per server) + if (now() - (touched.get(code) || 0) > 60000) { + touched.set(code, now()); + backend.touch(code, o.roomTtlMs).catch(() => {}); + } + return room; + } + + // Changes a room: fn(room) changes it (synchronously) and returns the answer. + // Changes from this server queue up; changes from other servers are retried. + function withRoom(code, fn) { + code = String(code || '').toUpperCase(); + const run = async () => { + for (let attempt = 0; attempt < o.maxTries; attempt++) { + const got = await backend.read(code); + if (!got) throw noRoom(); + const room = JSON.parse(got.s); + overlay(room, await backend.fields(code, 'presence'), null); + const answer = fn(room); + room.version = got.v + 1; + if (await backend.cas(code, got.v, strip(room), o.roomTtlMs)) return answer; + // back off, longer each time and never in step with the other writers + await new Promise((r) => setTimeout(r, Math.random() * Math.min(250, 5 * 1.5 ** attempt))); + } + throw new Rooms.RoomError('busy', 'The class is busy. Try again.'); + }; + const next = (queues.get(code) || Promise.resolve()).then(run, run); + const tail = next.catch(() => {}); + queues.set(code, tail); + tail.then(() => queues.get(code) === tail && queues.delete(code)); + return next; + } + + async function createRoom(settings) { + for (let i = 0; i < 50; i++) { + const room = Rooms.createRoom(settings); + room.version = 0; + if (await backend.create(room.code, strip(room), o.roomTtlMs)) { + await claim(room.code); + return room; + } + } + throw new Error('no free class code'); + } + + async function closeRoom(code) { + await backend.close(String(code).toUpperCase(), o.closedTtlMs); + } + + const isClosed = (code) => backend.isClosed(String(code || '').toUpperCase()); + + // Pairs: what the player on turn is doing, for their partner. Not saved in the room. + async function setLive(code, id, key, live) { + const room = await getRoom(code); + const p = Rooms.findPlayer(room, id, key); + const m = Rooms.setLive(room, p, live); + await backend.setField(room.code, 'live', m.id, JSON.stringify(m.live)); + await backend.publish(room.code, { t: 'live', match: m.id }); + } + + // ---- this server's listeners + + // This server's listeners for a room, subscribed to its messages. Await + // e.ready before reading the room, so no change slips in between. + function entry(code) { + if (!local.has(code)) { + const e = { streams: new Set(), polls: new Set(), unsubscribe: null, ready: null, pending: null, live: new Map(), presenceTimer: null }; + local.set(code, e); + e.unsubscribe = backend.subscribe(code, (msg) => onMessage(code, msg)); + e.ready = e.unsubscribe.then(() => {}); + } + return local.get(code); + } + + async function drop(code) { + const e = local.get(code); + if (!e || e.streams.size || e.polls.size || owned.has(code)) return; + local.delete(code); + clearTimeout(e.pending); + for (const t of e.live.values()) clearTimeout(t); + clearTimeout(e.presenceTimer); + (await e.unsubscribe)(); + } + + const event = (res, name, data) => res.write(`event: ${name}\ndata: ${JSON.stringify(data)}\n\n`); + + function viewFor(room, l) { + if (l.role === 'teacher') return Rooms.teacherView(room); + const p = room.players.find((x) => x.id === l.id); + return p ? Rooms.studentView(room, p) : null; + } + + function push(room, s) { + const view = viewFor(room, s); + if (view) return event(s.res, 'view', view); + event(s.res, 'removed', {}); + s.res.end(); + } + + // A poll is answered once (by a change, its timer or a shutdown, whichever is first) + function answer(room, w, body) { + clearTimeout(w.timer); + local.get(room.code)?.polls.delete(w); + if (w.done) return; + w.done = true; + if (body) return w.send(body); + const view = viewFor(room, w); + w.send(view ? { v: room.version, view } : { removed: true }); + } + + const streamsOf = (code) => (local.get(code) ? local.get(code).streams.size : 0); + + // A stream: the room's view now and after every change. res is set up by the caller. + async function openStream(code, role, playerId, res, onClose = () => {}) { + const e = entry(code); + await e.ready; + const room = await load(code, { withLive: true }); + if (!room) { + drop(code); + throw noRoom(); + } + const s = { res, role, id: playerId }; + e.streams.add(s); + if (playerId) { + await backend.setField(code, 'presence', `${playerId}|s:${replicaId}`, now() + o.presenceMs); + await backend.publish(code, { t: 'presence' }); + } + push(room, s); + const beat = setInterval(() => res.write(': ♥\n\n'), o.sseBeatMs); + res.on('close', async () => { + clearInterval(beat); + e.streams.delete(s); + onClose(); + if (playerId && ![...e.streams].some((x) => x.id === playerId)) { + await backend.delField(code, 'presence', `${playerId}|s:${replicaId}`).catch(() => {}); + await backend.publish(code, { t: 'presence' }).catch(() => {}); + } + drop(code); + }); + } + + // A long poll: answered at once if the room moved on from `version`, else at the + // next change (or after pollHoldMs). send(body) writes the answer. + async function openPoll(code, role, playerId, version, send, onClose) { + if (playerId) await backend.setField(code, 'presence', `${playerId}|poll`, now()); + const e = entry(code); + await e.ready; + const room = await load(code, { withLive: true }); + const w = { role, id: playerId, version, send, done: false, timer: null }; + if (!room || version !== room.version) { + drop(code); + if (!room) throw noRoom(); + return answer(room, w); + } + e.polls.add(w); + w.timer = setTimeout(async () => { + const latest = await load(code, { withLive: true }).catch(() => null); + if (latest) answer(latest, w); + else answer({ code }, w, { closed: true }); + drop(code); + }, o.pollHoldMs); + onClose(() => { + w.done = true; + clearTimeout(w.timer); + e.polls.delete(w); + drop(code); + }); + } + + // Changes come in as messages, from this server too + function onMessage(code, msg) { + const e = local.get(code); + if (!e) return; + if (msg.t === 'v') { + if (e.pending) return; + e.pending = setTimeout(async () => { + e.pending = null; + try { + const room = await load(code, { withLive: true }); + if (!room) return; + for (const s of e.streams) push(room, s); + // (a message about a change the poll has already seen leaves it held) + for (const w of [...e.polls]) if (room.version !== w.version) answer(room, w); + if (owned.has(code)) reconcile(room); + } catch (err) { + log.error('live.update_failed', exception(err)); + } + }, o.coalesceMs); + } else if (msg.t === 'live') { + if (e.live.has(msg.match)) return; + e.live.set( + msg.match, + setTimeout(async () => { + e.live.delete(msg.match); + const room = await load(code, { withLive: true }).catch(() => null); + const m = room && (room.matches || []).find((x) => x.id === msg.match); + if (!m) return; + for (const s of e.streams) if (s.role !== 'teacher' && m.ids.includes(s.id)) push(room, s); + }, o.liveMs) + ); + } else if (msg.t === 'presence') { + if (owned.has(code) && !e.presenceTimer) { + e.presenceTimer = setTimeout(() => { + e.presenceTimer = null; + checkPresence(code); + }, o.coalesceMs); + } + } else if (msg.t === 'closed') { + for (const s of e.streams) { + event(s.res, 'closed', {}); + s.res.end(); + } + for (const w of [...e.polls]) answer({ code }, w, { closed: true }); + e.streams.clear(); + disown(code); + backend.release(`room:${code}`, replicaId).catch(() => {}); + drop(code); + } else if (msg.t === 'owner-gone') { + claim(code); + } + } + + // ---- owning rooms: their timers run on one server + + async function claim(code) { + if (stopped) return false; + if (!(await backend.acquire(`room:${code}`, replicaId, o.leaseMs))) return false; + const fresh = !owned.has(code); + if (fresh) owned.set(code, new Map()); + await entry(code).ready; + if (fresh) { + const room = await load(code); + if (room) reconcile(room); + } + return true; + } + + function disown(code) { + for (const t of (owned.get(code) || new Map()).values()) clearTimeout(t.timer); + owned.delete(code); + } + + // The timers a room needs now, each with a signature of the state it was + // set for: a timer whose state has moved on is cancelled, and one that fires + // re-checks against the latest room before doing anything. + function wanted(room) { + const out = new Map(); + if (room.state !== 'playing') return out; + if (room.type === 'pairs') { + for (const m of room.matches) { + if (!Rooms.cpuToMove(room, m)) continue; + const delay = (m.roll ? o.cpuPlaceMs : o.cpuRollMs) * (CPU_SPEED[room.settings.cpuSpeed] || 1); + out.set(`cpu:${m.id}`, { sig: `${room.game}:${m.turn}:${m.roll ? 'place' : 'roll'}`, delay, act: cpuMove(m.id) }); + } + } + if (room.type === 'tournament' && room.roundReady && (room.autoNext || room.tournament.format === 'koth')) { + out.set('round', { sig: `${room.game}:${room.matches.map((m) => m.id).join()}`, delay: o.roundPauseMs, act: (r) => Rooms.advanceTournament(r) }); + } + if (room.type === 'class' && room.autoNext && Rooms.allDone(room)) { + out.set('auto', { sig: `${room.game}:${room.round}`, delay: o.autoNextMs, act: (r) => Rooms.nextRound(r) }); + } + return out; + } + + const cpuMove = (matchId) => (r) => { + const m = r.matches.find((x) => x.id === matchId); + if (m.roll) Rooms.cpuPlace(r, m); + else Rooms.cpuRoll(r, m); + }; + + function reconcile(room) { + const mine = owned.get(room.code); + if (!mine) return; + const want = wanted(room); + for (const [key, t] of mine) { + if (want.get(key)?.sig !== t.sig) { + clearTimeout(t.timer); + mine.delete(key); + } + } + for (const [key, w] of want) { + if (mine.has(key)) continue; + const timer = setTimeout(async () => { + mine.delete(key); + if (!owned.has(room.code)) return; + try { + await withRoom(room.code, (r) => { + const again = wanted(r).get(key); + if (again && again.sig === w.sig) again.act(r); + }); + } catch (err) { + if (!(err instanceof Rooms.RoomError)) log.error('live.timer_failed', exception(err)); + } + }, w.delay); + mine.set(key, { sig: w.sig, timer }); + } + } + + // Who's here: saved (as a signature) when it changes, so everyone's view + // updates and "everyone's done" stops waiting for someone who left + async function checkPresence(code) { + const sig = (r) => r.players.map((p) => Rooms.isHere(p)).join(); + try { + const room = await load(code); + if (!room || room.presenceSig === sig(room)) return; + await withRoom(code, (r) => { + r.presenceSig = sig(r); + }); + } catch (err) { + if (!(err instanceof Rooms.RoomError)) log.error('live.presence_failed', exception(err)); + } + } + + // ---- running + + function start() { + stopped = false; + // Keep this server's leases and stream presence fresh; take over rooms + // nobody owns; check who's here in the rooms this server owns. + ticks = [ + setInterval(async () => { + for (const code of owned.keys()) { + if (!(await backend.acquire(`room:${code}`, replicaId, o.leaseMs).catch(() => false))) disown(code); + } + }, o.renewMs), + setInterval(async () => { + for (const [code, e] of local) { + const ids = new Set([...e.streams].map((s) => s.id).filter(Boolean)); + for (const id of ids) await backend.setField(code, 'presence', `${id}|s:${replicaId}`, now() + o.presenceMs).catch(() => {}); + } + }, o.heartbeatMs), + setInterval(async () => { + try { + for (const code of await backend.active()) { + if (!owned.has(code)) { + if (!(await backend.read(code))) await backend.forget(code); + else await claim(code); + } else checkPresence(code); + } + } catch (err) { + log.warn('live.sweep_failed', exception(err)); + } + }, o.sweepMs), + ]; + for (const t of ticks) t.unref?.(); + } + + // Shutting down: hand rooms over and send browsers on to another server + async function drain() { + stopped = true; + for (const t of ticks) clearInterval(t); + for (const code of [...owned.keys()]) { + disown(code); + await backend.release(`room:${code}`, replicaId).catch(() => {}); + await backend.publish(code, { t: 'owner-gone' }).catch(() => {}); + } + for (const [code, e] of local) { + const room = await load(code, { withLive: true }).catch(() => null); + for (const s of e.streams) { + s.res.write(`retry: ${1000 + Math.floor(Math.random() * 3000)}\n\n`); // (spread the reconnects out) + s.res.end(); + if (s.id) await backend.delField(code, 'presence', `${s.id}|s:${replicaId}`).catch(() => {}); + } + for (const w of [...e.polls]) (room ? answer(room, w) : answer({ code }, w, { closed: true })); + } + } + + // One server at a time does a job (opening scheduled tournaments): true while it's this one + const lead = (name, ttlMs) => backend.acquire(`lead:${name}`, replicaId, ttlMs).catch(() => false); + + // What this server is doing, for metrics + const stats = () => { + let streams = 0; + let polls = 0; + for (const e of local.values()) { + streams += e.streams.size; + polls += e.polls.size; + } + return { streams, polls, owned: owned.size, rooms: local.size }; + }; + + return { replicaId, stats, lead, createRoom, getRoom, withRoom, closeRoom, isClosed, setLive, openStream, openPoll, streamsOf, start, drain, owns: (code) => owned.has(code) }; +} + +export { createLive, DEFAULTS }; diff --git a/apps/server/src/log.js b/apps/server/src/log.js new file mode 100644 index 0000000..afa655b --- /dev/null +++ b/apps/server/src/log.js @@ -0,0 +1,60 @@ +// Structured logs: one JSON object per line, with OpenTelemetry's field names +// (https://opentelemetry.io/docs/specs/otel/logs/data-model/), so a log stack can +// index them without parsing text. Every record is a named event with +// attributes; no free-text messages to grep through. +// +// Never log tokens, keys, codes or children's names: callers pass ids, routes +// and counts only. `redact` is a last line of defence for attribute names that +// look secret. +// +// const log = createLogger({ format: 'json' }); +// log.info('class.created', { 'user.id': teacher.id, 'class.id': c.id }); + +const LEVELS = { debug: 5, info: 9, warn: 13, error: 17, silent: Infinity }; // OTel severity numbers +const SECRET = /token|key|code|secret|password|authorization|cookie|name/i; + +function redact(attrs) { + const out = {}; + for (const [k, v] of Object.entries(attrs || {})) { + // ids, routes and counts are fine; anything named like a secret or a name is not + const allowed = /\.(id|route|method|status_code|type|kind|role|count|ms)$|^(duration_ms|exception\.(type|message|stacktrace)|http\.|url\.scheme|signal)/.test(k); + out[k] = !allowed && SECRET.test(k) ? '[redacted]' : v; + } + return out; +} + +function createLogger({ level = 'info', format = 'json', service = 'blockout', version = '', write = (line) => process.stdout.write(`${line}\n`), now = () => new Date() } = {}) { + const min = LEVELS[level] ?? LEVELS.info; + function emit(severity, event, attrs = {}) { + if (LEVELS[severity] < min) return; + const record = { + timestamp: now().toISOString(), + severity_text: severity.toUpperCase(), + severity_number: LEVELS[severity], + 'event.name': event, + 'service.name': service, + ...(version ? { 'service.version': version } : {}), + ...redact(attrs), + }; + if (format === 'json') return write(JSON.stringify(record)); + // text, for a person at a terminal: "INFO class.created user.id=… class.id=…" + const rest = Object.entries(redact(attrs)) + .filter(([k]) => k !== 'exception.stacktrace') + .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`) + .join(' '); + write(`${record.severity_text.padEnd(5)} ${event}${rest ? ` ${rest}` : ''}${attrs['exception.stacktrace'] ? `\n${attrs['exception.stacktrace']}` : ''}`); + } + return { + debug: (event, attrs) => emit('debug', event, attrs), + info: (event, attrs) => emit('info', event, attrs), + warn: (event, attrs) => emit('warn', event, attrs), + error: (event, attrs) => emit('error', event, attrs), + }; +} + +// An error as OTel exception attributes +const exception = (e) => ({ 'exception.type': (e && e.name) || 'Error', 'exception.message': String((e && e.message) || e), 'exception.stacktrace': (e && e.stack) || '' }); + +const silentLogger = createLogger({ level: 'silent' }); + +export { createLogger, exception, redact, silentLogger, LEVELS }; diff --git a/apps/server/src/metrics.js b/apps/server/src/metrics.js new file mode 100644 index 0000000..35834f0 --- /dev/null +++ b/apps/server/src/metrics.js @@ -0,0 +1,76 @@ +// Prometheus metrics in the text exposition format, built by hand (no +// dependency): counters and histograms with labels, and gauges read when +// scraped. Pure apart from what the gauges read. +// +// const m = createMetrics(); +// const requests = m.counter('http_requests_total', 'HTTP requests', ['route', 'status']); +// requests.inc({ route: '/api/me', status: 200 }); +// m.render() // the text Prometheus scrapes +// +// Label values must stay few (routes as patterns, never paths with codes in them). + +const esc = (v) => String(v).replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/"/g, '\\"'); +const labelText = (names, values) => (names.length ? `{${names.map((n, i) => `${n}="${esc(values[i])}"`).join(',')}}` : ''); + +function createMetrics({ prefix = 'blockout_' } = {}) { + const all = []; + + function counter(name, help, labels = []) { + const series = new Map(); // label values joined -> { values, n } + const m = { + inc(by = {}, n = 1) { + const values = labels.map((l) => by[l] ?? ''); + const key = values.join('\u0000'); + if (!series.has(key)) series.set(key, { values, n: 0 }); + series.get(key).n += n; + }, + render: () => [`# HELP ${prefix}${name} ${help}`, `# TYPE ${prefix}${name} counter`, ...[...series.values()].map((s) => `${prefix}${name}${labelText(labels, s.values)} ${s.n}`)], + }; + all.push(m); + return m; + } + + // Seconds, in Prometheus's usual buckets for a web request + function histogram(name, help, labels = [], buckets = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10]) { + const series = new Map(); + const m = { + observe(by = {}, value) { + const values = labels.map((l) => by[l] ?? ''); + const key = values.join('\u0000'); + if (!series.has(key)) series.set(key, { values, counts: buckets.map(() => 0), sum: 0, n: 0 }); + const s = series.get(key); + buckets.forEach((b, i) => value <= b && s.counts[i]++); + s.sum += value; + s.n++; + }, + render: () => [ + `# HELP ${prefix}${name} ${help}`, + `# TYPE ${prefix}${name} histogram`, + ...[...series.values()].flatMap((s) => [ + ...buckets.map((b, i) => `${prefix}${name}_bucket${labelText([...labels, 'le'], [...s.values, b])} ${s.counts[i]}`), + `${prefix}${name}_bucket${labelText([...labels, 'le'], [...s.values, '+Inf'])} ${s.n}`, + `${prefix}${name}_sum${labelText(labels, s.values)} ${s.sum}`, + `${prefix}${name}_count${labelText(labels, s.values)} ${s.n}`, + ]), + ], + }; + all.push(m); + return m; + } + + // read() -> a number, or [{ labels: {…}, value }] + function gauge(name, help, read, labels = []) { + all.push({ + render: () => { + const got = read(); + const rows = Array.isArray(got) ? got : [{ labels: {}, value: got }]; + return [`# HELP ${prefix}${name} ${help}`, `# TYPE ${prefix}${name} gauge`, ...rows.map((r) => `${prefix}${name}${labelText(labels, labels.map((l) => r.labels[l] ?? ''))} ${Number(r.value) || 0}`)]; + }, + }); + } + + const render = () => `${all.flatMap((m) => m.render()).join('\n')}\n`; + return { counter, histogram, gauge, render }; +} + +export { createMetrics }; diff --git a/server/moderation.js b/apps/server/src/moderation.js similarity index 91% rename from server/moderation.js rename to apps/server/src/moderation.js index e921354..b6a0b21 100644 --- a/server/moderation.js +++ b/apps/server/src/moderation.js @@ -7,10 +7,8 @@ // Then (and only then) the name is sent to OpenAI. If OpenAI can't be reached // the name is allowed, so a class never gets stuck at the door. // The teacher can still remove anyone from the lobby. -'use strict'; - -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; const OPENAI_URL = 'https://api.openai.com/v1/moderations'; const OPENAI_MODEL = 'omni-moderation-latest'; @@ -34,7 +32,7 @@ function normalize(text) { const squash = (s) => s.replace(/(.)\1+/g, '$1'); // fuuuck -> fuck -function loadList(file = path.join(__dirname, 'blocklist.txt')) { +function loadList(file = path.join(import.meta.dirname, 'blocklist.txt')) { const whole = []; const anywhere = []; for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { @@ -68,7 +66,7 @@ function listedWord(name, list = (LIST = LIST || loadList())) { // OpenAI's moderation model: true if it flags the name, false if not, // null if it couldn't answer (no key, network down, error, too slow). -async function openAiFlags(name, { apiKey, fetch = globalThis.fetch, timeoutMs = OPENAI_TIMEOUT_MS } = {}) { +async function openAiFlags(name, { apiKey, fetch = globalThis.fetch, timeoutMs = OPENAI_TIMEOUT_MS, log = null } = {}) { if (!apiKey || !fetch) return null; const abort = new AbortController(); const timer = setTimeout(() => abort.abort(), timeoutMs); @@ -83,7 +81,7 @@ async function openAiFlags(name, { apiKey, fetch = globalThis.fetch, timeoutMs = const data = await res.json(); return Boolean(data.results && data.results[0] && data.results[0].flagged); } catch (e) { - console.warn(`Name check with OpenAI skipped: ${e.message}`); + if (log) log.warn('moderation.skipped', { 'exception.type': e.name, 'exception.message': e.message }); return null; } finally { clearTimeout(timer); @@ -97,4 +95,4 @@ async function checkName(name, options = {}) { return { ok: true }; } -module.exports = { normalize, loadList, listedWord, openAiFlags, checkName, OPENAI_URL, OPENAI_MODEL }; +export { normalize, loadList, listedWord, openAiFlags, checkName, OPENAI_URL, OPENAI_MODEL }; diff --git a/apps/server/src/ratelimit.js b/apps/server/src/ratelimit.js new file mode 100644 index 0000000..5b841fa --- /dev/null +++ b/apps/server/src/ratelimit.js @@ -0,0 +1,115 @@ +// Rate limits: token buckets, one per (rule, key), where the key is a client +// address or an account. A bucket holds up to `burst` tokens and refills at +// `perMinute`; each counted request takes one. The buckets live in memory +// (one server; the clock is injected for tests) or in Redis, where every +// server shares them. Both answer the same, the Redis one asynchronously. +// +// A whole school usually reaches us from one address, so limits on addresses +// are generous, and code guessing is limited by counting misses (wrong codes), +// not tries. + +const RULES = { + // any request, per address: far above a class of 40 polling at once + request: { perMinute: 6000, burst: 6000 }, + // wrong class, room or parent codes, per address: a class of mistypers is fine, guessing 300,000+ codes isn't + miss: { perMinute: 30, burst: 30 }, + // joining a class or linking a child, per account + codeTry: { perMinute: 10, burst: 10 }, + // the practice dev sign-in, per address (a whole class signs in at once) + devSignIn: { perMinute: 60, burst: 60 }, +}; + +function createLimiter({ now = () => Date.now(), rules = RULES, maxBuckets = 100000 } = {}) { + const buckets = new Map(); // `${rule}|${key}` -> { tokens, at } + + function bucket(rule, key) { + const spec = rules[rule]; + if (!spec) throw new Error(`no rate rule ${rule}`); + const id = `${rule}|${key}`; + const t = now(); + let b = buckets.get(id); + if (!b) { + if (buckets.size >= maxBuckets) prune(t); + b = { tokens: spec.burst, at: t }; + buckets.set(id, b); + } + b.tokens = Math.min(spec.burst, b.tokens + ((t - b.at) / 60000) * spec.perMinute); + b.at = t; + return { b, spec }; + } + + // Seconds until one token is back + const wait = (b, spec) => Math.max(1, Math.ceil(((1 - b.tokens) / spec.perMinute) * 60)); + + // Is there a token left? (doesn't take it) + function check(rule, key) { + const { b, spec } = bucket(rule, key); + return b.tokens >= 1 ? { ok: true } : { ok: false, retryAfter: wait(b, spec) }; + } + + // Take a token if there is one + function take(rule, key) { + const { b, spec } = bucket(rule, key); + if (b.tokens < 1) return { ok: false, retryAfter: wait(b, spec) }; + b.tokens -= 1; + return { ok: true }; + } + + // Forget full buckets (they're the same as no bucket) + function prune(t = now()) { + for (const [id, b] of buckets) { + const spec = rules[id.slice(0, id.indexOf('|'))]; + if (b.tokens + ((t - b.at) / 60000) * spec.perMinute >= spec.burst) buckets.delete(id); + } + } + + return { check, take, prune, size: () => buckets.size }; +} + +// The same buckets in Redis, one hash per bucket that expires once it would be +// full again. check takes 0 tokens, take takes 1. +const BUCKET = ` +local now = tonumber(ARGV[1]) +local per = tonumber(ARGV[2]) +local burst = tonumber(ARGV[3]) +local cost = tonumber(ARGV[4]) +local b = redis.call('HMGET', KEYS[1], 'tokens', 'at') +local tokens = tonumber(b[1]) or burst +local at = tonumber(b[2]) or now +tokens = math.min(burst, tokens + (now - at) / 60000 * per) +local ok = tokens >= 1 +if ok then tokens = tokens - cost end +redis.call('HSET', KEYS[1], 'tokens', tostring(tokens), 'at', now) +redis.call('PEXPIRE', KEYS[1], math.ceil((burst - tokens) / per * 60000) + 1000) +if ok then return 0 end +return math.max(1, math.ceil((1 - tokens) / per * 60))`; + +function createRedisLimiter(client, { prefix = '', now = () => Date.now(), rules = RULES } = {}) { + async function bucket(rule, key, cost) { + const spec = rules[rule]; + if (!spec) throw new Error(`no rate rule ${rule}`); + const wait = await client.eval(BUCKET, { keys: [`${prefix}rate:${rule}|${key}`], arguments: [now(), spec.perMinute, spec.burst, cost].map(String) }); + return wait === 0 ? { ok: true } : { ok: false, retryAfter: wait }; + } + return { + check: (rule, key) => bucket(rule, key, 0), + take: (rule, key) => bucket(rule, key, 1), + prune: () => {}, // (buckets expire by themselves) + }; +} + +// The client's address. Behind `trustProxy` proxies (a gateway, a tunnel), it's the +// address the nearest of them saw: that many entries from the end of +// X-Forwarded-For. Anything further left could be made up by the client. +function clientIp(req, trustProxy = 0) { + const socket = (req.socket && req.socket.remoteAddress) || ''; + if (!trustProxy) return socket; + const hops = String(req.headers['x-forwarded-for'] || '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + if (!hops.length) return socket; + return hops[Math.max(0, hops.length - trustProxy)]; +} + +export { RULES, createLimiter, createRedisLimiter, clientIp }; diff --git a/apps/server/src/roombackend/memory.js b/apps/server/src/roombackend/memory.js new file mode 100644 index 0000000..22f6b0d --- /dev/null +++ b/apps/server/src/roombackend/memory.js @@ -0,0 +1,122 @@ +// Live classes kept in this process: the same interface as the Redis backend, +// for tests and for a single server without Redis. Several runtimes (live.js) +// sharing one memory backend behave like several servers sharing one Redis. +// +// Rooms are kept as JSON text, as Redis keeps them, so anything that wouldn't +// survive being saved (a function, a Map) shows up in tests. +// +// The interface (every method async): +// create(code, json, ttlMs) a new room; false if the code is in use or was just closed +// read(code) { v, s } (version, JSON) or null +// cas(code, v, json, ttlMs) replace if still at version v (then v + 1, and tell everyone); false if not +// touch(code, ttlMs) keep an idle room a while longer +// close(code, closedTtlMs) remove it, remember it was closed, tell everyone +// isClosed(code), active(), forget(code) +// setField(code, kind, field, value), delField(code, kind, field), fields(code, kind) +// small per-room maps: 'presence' (who's connected where) and 'live' (moves in progress) +// publish(code, message), subscribe(code, fn) -> unsubscribe +// acquire(name, owner, ttlMs), release(name, owner) leases: one owner at a time +// ping(), quit() + +function createMemoryBackend({ now = () => Date.now() } = {}) { + const rooms = new Map(); // code -> { v, s, expiresAt } + const closed = new Map(); // code -> expiresAt + const maps = new Map(); // `${code}|${kind}` -> Map + const leases = new Map(); // name -> { owner, expiresAt } + const subscribers = new Map(); // code -> Set of fn + + const alive = (code) => { + const r = rooms.get(code); + if (r && r.expiresAt <= now()) { + rooms.delete(code); + for (const kind of ['presence', 'live']) maps.delete(`${code}|${kind}`); + return null; + } + return r || null; + }; + // Messages arrive later, as they would over the network + const publish = (code, message) => { + const text = JSON.stringify(message); + for (const fn of subscribers.get(code) || []) setImmediate(() => fn(JSON.parse(text))); + }; + const map = (code, kind) => { + const key = `${code}|${kind}`; + if (!maps.has(key)) maps.set(key, new Map()); + return maps.get(key); + }; + + return { + kind: 'memory', + async create(code, json, ttlMs) { + if (alive(code) || (closed.has(code) && closed.get(code) > now())) return false; + rooms.set(code, { v: 0, s: json, expiresAt: now() + ttlMs }); + return true; + }, + async read(code) { + const r = alive(code); + return r ? { v: r.v, s: r.s } : null; + }, + async cas(code, v, json, ttlMs) { + const r = alive(code); + if (!r || r.v !== v) return false; + Object.assign(r, { v: v + 1, s: json, expiresAt: now() + ttlMs }); + publish(code, { t: 'v', v: v + 1 }); + return true; + }, + async touch(code, ttlMs) { + const r = alive(code); + if (r) r.expiresAt = now() + ttlMs; + }, + async close(code, closedTtlMs) { + rooms.delete(code); + for (const kind of ['presence', 'live']) maps.delete(`${code}|${kind}`); + closed.set(code, now() + closedTtlMs); + publish(code, { t: 'closed' }); + }, + async isClosed(code) { + return closed.has(code) && closed.get(code) > now(); + }, + async active() { + return [...rooms.keys()].filter((code) => alive(code)); + }, + async forget(code) { + if (!alive(code)) rooms.delete(code); + }, + async setField(code, kind, field, value) { + map(code, kind).set(field, String(value)); + }, + async delField(code, kind, field) { + map(code, kind).delete(field); + }, + async fields(code, kind) { + return Object.fromEntries(map(code, kind)); + }, + async publish(code, message) { + publish(code, message); + }, + async subscribe(code, fn) { + if (!subscribers.has(code)) subscribers.set(code, new Set()); + subscribers.get(code).add(fn); + return async () => { + subscribers.get(code)?.delete(fn); + if (!subscribers.get(code)?.size) subscribers.delete(code); + }; + }, + // Taken if free (or expired), or renewed if it's already this owner's + async acquire(name, owner, ttlMs) { + const l = leases.get(name); + if (l && l.owner !== owner && l.expiresAt > now()) return false; + leases.set(name, { owner, expiresAt: now() + ttlMs }); + return true; + }, + async release(name, owner) { + if (leases.get(name)?.owner === owner) leases.delete(name); + }, + async ping() { + return true; + }, + async quit() {}, + }; +} + +export { createMemoryBackend }; diff --git a/apps/server/src/roombackend/redis.js b/apps/server/src/roombackend/redis.js new file mode 100644 index 0000000..c632d19 --- /dev/null +++ b/apps/server/src/roombackend/redis.js @@ -0,0 +1,131 @@ +// Live classes kept in Redis (we run Valkey, which speaks the same protocol), +// shared by every server: the same interface as memory.js, where it's described. +// +// Keys, for a class code C: +// room:{C} hash: v (version), s (the room as JSON) +// room:{C}:presence hash: who has a stream open on which server, who polled when +// room:{C}:live hash: a pair's move in progress, by match +// closed:{C} set for a while after the class closes +// rooms:active set of the codes in use (for servers looking for rooms without an owner) +// lease:NAME the owner of a room, or of a job +// and a channel per class, room:{C}, for "it changed" messages. +// +// Changes that must happen together (write only if still at version v, then +// tell everyone) are Lua scripts, which Redis runs one at a time. +import { createClient } from 'redis'; + +const CREATE = ` +if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end +redis.call('HSET', KEYS[1], 'v', 0, 's', ARGV[1]) +redis.call('PEXPIRE', KEYS[1], ARGV[2]) +redis.call('SADD', KEYS[3], ARGV[3]) +return 1`; + +const CAS = ` +if redis.call('HGET', KEYS[1], 'v') ~= ARGV[1] then return 0 end +local v = tonumber(ARGV[1]) + 1 +redis.call('HSET', KEYS[1], 'v', v, 's', ARGV[2]) +for i = 1, 3 do redis.call('PEXPIRE', KEYS[i], ARGV[3]) end +redis.call('SADD', KEYS[4], ARGV[4]) +redis.call('PUBLISH', ARGV[5], '{"t":"v","v":' .. v .. '}') +return 1`; + +const CLOSE = ` +redis.call('DEL', KEYS[1], KEYS[2], KEYS[3]) +redis.call('SET', KEYS[4], '1', 'PX', ARGV[1]) +redis.call('SREM', KEYS[5], ARGV[2]) +redis.call('PUBLISH', ARGV[3], '{"t":"closed"}') +return 1`; + +// Taken if free, renewed if it's already this owner's +const ACQUIRE = ` +local owner = redis.call('GET', KEYS[1]) +if not owner then redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2]) return 1 end +if owner == ARGV[1] then redis.call('PEXPIRE', KEYS[1], ARGV[2]) return 1 end +return 0`; + +const RELEASE = ` +if redis.call('GET', KEYS[1]) == ARGV[1] then redis.call('DEL', KEYS[1]) end +return 1`; + +// prefix: keeps several Blockouts (or test runs) apart in one Redis +async function createRedisBackend(url, { prefix = '', fieldsTtlMs = 4 * 60 * 60 * 1000, log = null } = {}) { + const ACTIVE = `${prefix}rooms:active`; + const k = (code) => ({ + room: `${prefix}room:{${code}}`, + presence: `${prefix}room:{${code}}:presence`, + live: `${prefix}room:{${code}}:live`, + closed: `${prefix}closed:{${code}}`, + channel: `${prefix}room:{${code}}`, + }); + const client = createClient({ url }); + // Messages need a connection of their own (a subscribed connection does nothing else) + const sub = client.duplicate(); + for (const c of [client, sub]) c.on('error', (e) => log && log.warn('redis.error', { 'exception.type': e.name, 'exception.message': e.message })); + await client.connect(); + await sub.connect(); + const run = (script, keys, args) => client.eval(script, { keys, arguments: args.map(String) }); + + return { + kind: 'redis', + client, // (shared with the rate limits) + async create(code, json, ttlMs) { + const key = k(code); + return (await run(CREATE, [key.room, key.closed, ACTIVE], [json, ttlMs, code])) === 1; + }, + async read(code) { + const [v, s] = await client.hmGet(k(code).room, ['v', 's']); + return s === null || s === undefined ? null : { v: Number(v), s }; + }, + async cas(code, v, json, ttlMs) { + const key = k(code); + return (await run(CAS, [key.room, key.presence, key.live, ACTIVE], [v, json, ttlMs, code, key.channel])) === 1; + }, + async touch(code, ttlMs) { + const key = k(code); + await Promise.all([key.room, key.presence, key.live].map((x) => client.pExpire(x, ttlMs))); + }, + async close(code, closedTtlMs) { + const key = k(code); + await run(CLOSE, [key.room, key.presence, key.live, key.closed, ACTIVE], [closedTtlMs, code, key.channel]); + }, + isClosed: async (code) => (await client.exists(k(code).closed)) === 1, + active: () => client.sMembers(ACTIVE), + async forget(code) { + if (!(await client.exists(k(code).room))) await client.sRem(ACTIVE, code); + }, + async setField(code, kind, field, value) { + const key = k(code)[kind]; + await client.hSet(key, field, String(value)); + await client.pExpire(key, fieldsTtlMs); + }, + async delField(code, kind, field) { + await client.hDel(k(code)[kind], field); + }, + fields: (code, kind) => client.hGetAll(k(code)[kind]), + async publish(code, message) { + await client.publish(k(code).channel, JSON.stringify(message)); + }, + async subscribe(code, fn) { + const listener = (text) => { + try { + fn(JSON.parse(text)); + } catch (e) { + log && log.warn('redis.bad_message', { 'exception.message': e.message }); + } + }; + await sub.subscribe(k(code).channel, listener); + return () => sub.unsubscribe(k(code).channel, listener); + }, + acquire: async (name, owner, ttlMs) => (await run(ACQUIRE, [`${prefix}lease:${name}`], [owner, ttlMs])) === 1, + async release(name, owner) { + await run(RELEASE, [`${prefix}lease:${name}`], [owner]); + }, + ping: async () => (await client.ping()) === 'PONG', + async quit() { + await Promise.allSettled([sub.close(), client.close()]); + }, + }; +} + +export { createRedisBackend }; diff --git a/server/rooms.js b/apps/server/src/rooms.js similarity index 85% rename from server/rooms.js rename to apps/server/src/rooms.js index 87a3d62..fa13a9b 100644 --- a/server/rooms.js +++ b/apps/server/src/rooms.js @@ -9,12 +9,11 @@ // Pure game logic over plain objects (no networking), so it can be tested on its own. // The server keeps each student's board so it can check where rectangles go and // work out the scores itself; the leaderboard can't be faked from a browser. -'use strict'; - -const crypto = require('crypto'); -const Core = require('../js/core.js'); -const Progress = require('../js/progress.js'); -const Tournament = require('./tournament.js'); +import crypto from 'crypto'; +import * as Core from '@blockout/engine'; +import * as Progress from '@blockout/progress'; +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Tournament from './tournament.js'; const SIZES = [6, 8, 10, 12, 16, 20, 24]; const DIFFICULTIES = ['easy', 'medium', 'hard']; @@ -36,8 +35,11 @@ class RoomError extends Error { } const token = () => crypto.randomBytes(12).toString('hex'); +// Like Math.random, but from the operating system's secure source: class codes +// must not be predictable from earlier ones +const secureRandom = () => crypto.randomInt(2 ** 48 - 1) / (2 ** 48 - 1); // (randomInt allows a range below 2^48) -function newCode(taken, rng = Math.random) { +function newCode(taken, rng = secureRandom) { for (;;) { let code = ''; for (let i = 0; i < CODE_LENGTH; i++) code += CODE_LETTERS[Math.floor(rng() * CODE_LETTERS.length)]; @@ -46,14 +48,13 @@ function newCode(taken, rng = Math.random) { } // Pacing, set by the teacher for everyone in the class (no shop unlocks needed): -// placeMode: 'choice' (each student's own setting) | 'draw' | 'click' | 'auto' +// placeMode: 'choice' (each student's own setting) | 'draw' | 'auto' // answerTime: seconds to answer, 0 = no timer // autoRoll (pairs): roll by itself when your turn starts -// fitRolls (pairs): only deal rolls that fit — 'end' (near the end), 'always', 'never' // cpuSpeed (pairs, CPU opponent): 'slow' | 'normal' | 'fast' -const PLACE_MODES = ['choice', 'draw', 'click', 'auto']; +// (Pairs only ever roll what fits, as every game does now; 'click' placing is gone.) +const PLACE_MODES = ['choice', 'draw', 'auto']; const ANSWER_TIMES = [0, 30, 20, 10]; -const FIT_MODES = ['end', 'always', 'never']; const CPU_SPEEDS = ['slow', 'normal', 'fast']; const pick = (value, allowed, fallback) => (allowed.includes(value) ? value : fallback); // Pairs and tournaments: end a match after this many turns in all (0 = when the board is full) @@ -69,7 +70,6 @@ function cleanSettings(s = {}) { placeMode: pick(s.placeMode, PLACE_MODES, 'choice'), answerTime: pick(Number(s.answerTime), ANSWER_TIMES, 0), autoRoll: s.autoRoll === true, - fitRolls: pick(s.fitRolls, FIT_MODES, 'end'), cpuSpeed: pick(s.cpuSpeed, CPU_SPEEDS, 'normal'), format: pick(s.format, Tournament.FORMATS, 'knockout'), matchTurns: pick(Number(s.matchTurns), MATCH_TURNS, 0), @@ -78,7 +78,7 @@ function cleanSettings(s = {}) { }; } -function createRoom(settings = {}, { taken = new Set(), rng = Math.random, now = Date.now() } = {}) { +function createRoom(settings = {}, { taken = new Set(), rng = secureRandom, now = Date.now() } = {}) { // Tournaments are many short games: 6 turns each by default if (settings.type === 'tournament' && settings.matchTurns === undefined) settings = { ...settings, matchTurns: 12 }; return { @@ -115,6 +115,20 @@ function cleanName(name) { } // The students (not the teacher or the CPU when they play in pairs) +// A player's Wardrobe look, from ids the page sends: an avatar emoji and a title +// name, or nothing. Only real Wardrobe ids count (never free text), so nothing +// odd can show up on a classmate's screen. +function cleanLook(look = {}) { + const avatar = Cosmetics.category('avatar').options.find((o) => o.id === look.avatar && o.id !== 'none'); + const title = Cosmetics.TITLES.find((t) => t.id === look.title && t.id !== 'none'); + return { avatar: avatar ? avatar.preview : '', title: title ? title.name : '' }; +} +const lookOf = (p) => { + if (p.isCpu) return { avatar: '🤖', title: '' }; + if (p.isTeacher) return { avatar: (p.look && p.look.avatar) || '🍎', title: (p.look && p.look.title) || '' }; + return p.look || { avatar: '', title: '' }; +}; + const students = (room) => room.players.filter((p) => !p.isTeacher && !p.isCpu); // Students in the game that's running (not those who joined after it started) const playing = (room) => students(room).filter((p) => !p.benched); @@ -140,14 +154,14 @@ function freshScore(player, size) { return player; } -function join(room, name) { +function join(room, name, look) { const clean = cleanName(name); if (RESERVED_NAMES.includes(clean.toLowerCase())) throw new RoomError('name', 'Please use your own first name.'); if (students(room).length >= MAX_PLAYERS) throw new RoomError('full', 'This class is full.'); if (room.players.some((p) => p.name.toLowerCase() === clean.toLowerCase())) { throw new RoomError('taken', `Someone called ${clean} is already in. Add your last initial, like “${clean} B”.`); } - const player = freshScore({ id: token().slice(0, 8), key: token(), name: clean, connections: 0 }, room.settings.size); + const player = freshScore({ id: token().slice(0, 8), key: token(), name: clean, look: cleanLook(look), connections: 0 }, room.settings.size); // Joining after the game started: you're in the class, and play the next game if (room.state === 'playing') player.benched = true; room.players.push(player); @@ -190,13 +204,13 @@ function start(room, rng = Math.random) { p.benched = false; } const sides = Core.DIFFICULTY_SIDES[room.settings.difficulty]; - room.bag = Core.createDice(room.settings.size, rng, sides); - nextRound(room); + room.bag = Core.diceState(room.settings.size, sides); // (plain data: rolled with diceFromState) + nextRound(room, rng); } // Roll for everyone. Anyone who didn't answer the last roll has missed it; anyone // whose board has no room for this one passes straight away. -function nextRound(room) { +function nextRound(room, rng = Math.random) { if (room.state !== 'playing') throw new RoomError('state', 'The game isn’t running.'); for (const p of playing(room)) { if (room.round > 0 && !p.done) { @@ -206,7 +220,8 @@ function nextRound(room) { } if (room.round >= room.settings.rounds) return end(room); room.round++; - room.roll = [room.bag.roll(), room.bag.roll()]; + const bag = Core.diceFromState(room.bag, rng); + room.roll = [bag.roll(), bag.roll()]; for (const p of playing(room)) { p.result = null; p.done = false; @@ -350,10 +365,13 @@ function teacherPlayer(room) { } // The teacher joins as a player (to play the odd one out). -function joinTeacher(room) { +function joinTeacher(room, look) { const existing = teacherPlayer(room); - if (existing) return existing; - const t = freshScore({ id: token().slice(0, 8), key: token(), name: 'Teacher', isTeacher: true, connections: 0 }, room.settings.size); + if (existing) { + if (look) existing.look = cleanLook(look); + return existing; + } + const t = freshScore({ id: token().slice(0, 8), key: token(), name: 'Teacher', isTeacher: true, look: cleanLook(look), connections: 0 }, room.settings.size); room.players.push(t); return t; } @@ -420,7 +438,7 @@ function newMatch(room, pair, rng, extra = {}) { ids: pair, names: Object.fromEntries(pair.map((pid) => [pid, room.players.find((p) => p.id === pid).name])), // kept if someone leaves board: Core.createBoard(room.settings.size), - bag: Core.createDice(room.settings.size, rng, Core.DIFFICULTY_SIDES[room.settings.difficulty]), + bag: Core.diceState(room.settings.size, Core.DIFFICULTY_SIDES[room.settings.difficulty]), current: 0, // index into ids: whose turn it is turn: 1, // bumps after every turn roll: null, // the current player's roll, once they've rolled @@ -513,7 +531,7 @@ function myTurn(room, player) { function pairRoll(room, player, rng = Math.random) { const m = myTurn(room, player); if (m.roll) return { roll: m.roll, passed: false }; - const roll = Core.rollForBoard(m.board, m.bag, rng, room.settings.fitRolls); + const roll = Core.rollForBoard(m.board, Core.diceFromState(m.bag, rng), rng); if (!canFit(m.board, roll)) { player.passes++; m.passes++; @@ -542,6 +560,24 @@ function submitPair(room, player, r) { return player; } +// Pairs: what the player on turn is doing, for their partner to watch live: +// dragging a rectangle ('place', rect or null) or answering ('answer', rect). +// Checked loosely (it's only shown, never scored); the real move comes with submit. +function setLive(room, player, live = {}) { + const m = myTurn(room, player); + if (!m.roll) throw new RoomError('stale', 'Roll first.'); + const phase = live.phase === 'answer' ? 'answer' : 'place'; + let rect = null; + if (live.rect) { + const { x, y, w, h } = live.rect; + const n = m.board.size; + const ok = [x, y, w, h].every(Number.isInteger) && x >= 0 && y >= 0 && w > 0 && h > 0 && x + w <= n && y + h <= n && w * h <= n * n; + if (ok) rect = { x, y, w, h }; + } + m.live = { by: player.id, turn: m.turn, phase, rect }; + return m; +} + // The CPU's turn, in two steps so everyone can watch: roll, then place. function cpuRoll(room, m, rng = Math.random) { return pairRoll(room, room.players.find((p) => p.id === m.ids[m.current]), rng); @@ -561,6 +597,7 @@ const cpuToMove = (room, m) => !m.over && room.state === 'playing' && m.ids[m.cu function nextTurn(room, m) { m.roll = null; + m.live = null; m.turn++; m.current = 1 - m.current; const cap = room.settings.matchTurns; @@ -634,6 +671,7 @@ function leaderboard(room) { return { id: p.id, name: p.name, + ...lookOf(p), rank, score: p.score, squares: p.squares, @@ -674,6 +712,7 @@ function report(room) { const PUBLIC = (p) => ({ id: p.id, name: p.name, + ...lookOf(p), score: p.score, squares: p.squares, bonus: p.bonus, @@ -695,6 +734,8 @@ function matchView(room, m) { winner: m.winner, left: m.left, lastPass: m.lastPass, + // what the player whose turn it is is doing right now (see setLive) + live: m.live && m.live.turn === m.turn ? { by: m.live.by, phase: m.live.phase, rect: m.live.rect } : null, filled: 1 - Core.emptyCount(m.board) / (m.board.size * m.board.size), players: players.map(PUBLIC), rects: m.board.rects.map(({ x, y, w, h, a, b, player }) => ({ x, y, w, h, a, b, owner: player })), @@ -703,13 +744,16 @@ function matchView(room, m) { function pairsTeacherView(room) { const order = syncOrder(room); - const name = (id) => (room.players.find((p) => p.id === id) || {}).name; + const who = (id) => { + const p = room.players.find((x) => x.id === id); + return { id, name: p ? p.name : undefined, ...(p ? lookOf(p) : {}) }; + }; const t = teacherPlayer(room); return { pairOptions: room.pairOptions, playKey: room.playKey, // lobby preview (shuffle mode shuffles when the game starts) - pairs: pairsFrom(order).map(([a, b]) => [{ id: a, name: name(a) }, b ? { id: b, name: name(b) } : null]), + pairs: pairsFrom(order).map(([a, b]) => [who(a), b ? who(b) : null]), odd: oddOpponent(room), teacher: t ? { id: t.id, connected: connected(t), match: t.match } : null, matches: room.matches.map((m) => { @@ -726,7 +770,11 @@ function tournamentView(room) { const t = room.tournament; if (!t) return null; const name = (id) => (room.players.find((p) => p.id === id) || {}).name || room.lastNames?.[id] || '?'; - const table = Tournament.standings(t).map((x) => ({ ...x, name: name(x.id) })); + const lookFor = (id) => { + const p = room.players.find((x) => x.id === id); + return p ? lookOf(p) : { avatar: '', title: '' }; + }; + const table = Tournament.standings(t).map((x) => ({ ...x, name: name(x.id), ...lookFor(x.id) })); const current = t.rounds[t.rounds.length - 1]; return { format: t.format, @@ -773,12 +821,32 @@ function tournamentYou(room, player) { return { status, place: row.place, of: t.seeds.length, wins: st.wins, losses: st.losses, bestStreak: st.bestStreak, upsets: st.upsets, line, champion: t.championId ? (room.players.find((p) => p.id === t.championId) || {}).name : null }; } +// Whole class: everyone's board, as compact [x, y, w, h] rectangles. +const boardOf = (p) => p.board.rects.map(({ x, y, w, h }) => [x, y, w, h]); + +// Up to `max` classmates for a student's side panel: the ones closest to them +// on the leaderboard (rank neighbours), best first. +function classmatesFor(room, player, board, max = 8) { + const others = board.filter((r) => r.id !== player.id); + const mine = board.findIndex((r) => r.id === player.id); + const near = others + .map((r) => ({ r, d: Math.abs(board.indexOf(r) - (mine < 0 ? 0 : mine)) })) + .sort((a, b) => a.d - b.d) + .slice(0, max) + .map(({ r }) => r) + .sort((a, b) => board.indexOf(a) - board.indexOf(b)); + return near.map((r) => { + const p = room.players.find((x) => x.id === r.id); + return { id: r.id, name: r.name, avatar: r.avatar, title: r.title, rank: r.rank, score: r.score, done: r.done, rects: boardOf(p) }; + }); +} + function teacherView(room) { const board = leaderboard(room); const here = playing(room).filter(connected); return { // joined after the game started: they play the next one - waiting: students(room).filter((p) => p.benched).map((p) => ({ id: p.id, name: p.name, connected: connected(p) })), + waiting: students(room).filter((p) => p.benched).map((p) => ({ id: p.id, name: p.name, ...lookOf(p), connected: connected(p) })), code: room.code, type: room.type, ...(room.type !== 'class' ? pairsTeacherView(room) : {}), @@ -790,6 +858,8 @@ function teacherView(room) { round: room.round, roll: room.roll, players: board, + // whole class: every student's board, for the mini-boards on the projector + boards: room.type === 'class' ? Object.fromEntries(playing(room).map((p) => [p.id, boardOf(p)])) : null, done: here.filter((p) => p.done).length, here: here.length, report: room.state === 'ended' ? report(room) : null, @@ -830,12 +900,18 @@ function studentView(room, player) { rects: player.board.rects.map(({ x, y, w, h, a, b }) => ({ x, y, w, h, a, b })), }, of: board.length, + // whole class: classmates near you on the leaderboard, with their boards + classmates: room.type === 'class' && room.state !== 'lobby' ? classmatesFor(room, player, board) : null, // at the end everyone sees the top five - top: room.state === 'ended' ? board.slice(0, 5).map(({ name, rank, score }) => ({ name, rank, score })) : null, + top: room.state === 'ended' ? board.slice(0, 5).map(({ name, avatar, title, rank, score }) => ({ name, avatar, title, rank, score })) : null, }; } -module.exports = { +const isHere = (p) => connected(p); + +export { + cleanLook, + setLive, SIZES, DIFFICULTIES, ROUNDS, @@ -874,7 +950,7 @@ module.exports = { backToLobby, leaderboard, report, - isHere: (p) => connected(p), + isHere, teacherView, studentView, }; diff --git a/apps/server/src/store/index.js b/apps/server/src/store/index.js new file mode 100644 index 0000000..15b6721 --- /dev/null +++ b/apps/server/src/store/index.js @@ -0,0 +1,17 @@ +// The accounts store: MongoDB on a server, in memory for tests and for a quick +// run without a database. Both have the same async interface (see memory.js, +// the simplest), checked by test/store.contract.js. (sqlite.js only reads old +// databases, for scripts/sqlite-to-mongo.js.) +// BLOCKOUT_STORE mongo | memory +import { openMemoryStore } from './memory.js'; + +async function openStore({ kind = 'memory', url } = {}) { + if (kind === 'memory') return openMemoryStore(); + if (kind === 'mongo') { + const { openMongoStore } = await import('./mongo.js'); + return openMongoStore(url); + } + throw new Error(`no store called ${kind}`); +} + +export { openStore }; diff --git a/apps/server/src/store/memory.js b/apps/server/src/store/memory.js new file mode 100644 index 0000000..c40e1a4 --- /dev/null +++ b/apps/server/src/store/memory.js @@ -0,0 +1,258 @@ +// Accounts storage in memory: the same interface as the MongoDB store, for +// tests and for a server run without a database (nothing survives a restart). +// Everything goes in and out as a copy, as it would through a database, so a +// caller can't change stored data by accident. +import { newId, DEFAULT_SCHOOLS, nameOf, byName } from './shared.js'; + +const copy = (v) => (v === undefined || v === null ? null : structuredClone(v)); +const byTime = (key) => (a, b) => a[key] - b[key]; + +function openMemoryStore() { + const meta = new Map(); + const schools = new Map(); // id -> school + const users = new Map(); // id -> user (without `name`, which is derived) + const progress = new Map(); // userId -> { data, updatedAt } + const classes = new Map(); + const members = []; // { classId, userId, joinedAt } + const links = []; // { parentId, childId, createdAt } + const assignments = new Map(); + const schedules = new Map(); + const invites = new Map(); + for (const s of DEFAULT_SCHOOLS) schools.set(s.id, { ...s }); + + const user = (u) => (u ? { ...copy(u), name: nameOf(u) } : null); + const find = (map, test) => [...map.values()].find(test) || null; + + return { + kind: 'memory', + async migrate() {}, + async close() {}, + async ping() { + return true; + }, + + // ---- meta + getMeta: async (key) => (meta.has(key) ? meta.get(key) : null), + setMeta: async (key, value) => void meta.set(key, value), + + // ---- schools + schoolByDomain: async (domain) => copy(find(schools, (s) => s.domain === String(domain || '').toLowerCase())), + school: async (id) => copy(schools.get(id)), + schools: async () => [...schools.values()].map(copy).sort((a, b) => a.name.localeCompare(b.name)), + async addSchool(s) { + const domain = s.domain.toLowerCase(); + if (schools.has(s.id) || find(schools, (x) => x.domain === domain)) return; + schools.set(s.id, { id: s.id, name: s.name, domain }); + }, + + // ---- users + userBySub: async (sub) => user(find(users, (u) => u.sub === sub)), + user: async (id) => user(users.get(id)), + // Creates the user (with u.role, else as a student), or updates their name + // and school when they sign in again. The role is never changed here. + async upsertUser(u, now = Date.now()) { + const fields = { firstName: u.firstName, lastInitial: u.lastInitial || '', domain: u.domain || null, schoolId: u.schoolId || null, lastSeen: now }; + const existing = find(users, (x) => x.sub === u.sub); + if (existing) { + Object.assign(existing, fields); + return user(existing); + } + const created = { id: newId(), sub: u.sub, role: u.role || 'student', ...fields, invitedBy: null, createdAt: now }; + users.set(created.id, created); + return user(created); + }, + async setRole(id, role, { invitedBy } = {}) { + const u = users.get(id); + if (!u) return; + u.role = role; + if (invitedBy !== undefined) u.invitedBy = invitedBy; + }, + // For admins: newest first, by first name (any case) and role; { users, total } + async listUsers({ q = '', role = null, limit = 50, offset = 0 } = {}) { + const prefix = String(q).toLowerCase(); + const list = [...users.values()] + .filter((u) => (!role || u.role === role) && u.firstName.toLowerCase().startsWith(prefix)) + .sort((a, b) => b.lastSeen - a.lastSeen); + return { users: list.slice(offset, offset + limit).map(user), total: list.length }; + }, + // Accounts nobody has signed in to since `before` (oldest first), for the retention job + staleUsers: async (before, limit = 100) => + [...users.values()] + .filter((u) => u.lastSeen < before) + .sort((a, b) => a.lastSeen - b.lastSeen) + .slice(0, limit) + .map((u) => u.id), + // Everything that belongs to a user goes with them: their save, memberships, + // links, the classes they teach (with those classes' rosters, homework and + // tournaments), the homework and tournaments they set, and their invites. + async deleteUser(id) { + const taught = [...classes.values()].filter((c) => c.teacherId === id).map((c) => c.id); + for (const [cid] of classes) if (taught.includes(cid)) classes.delete(cid); + for (let i = members.length - 1; i >= 0; i--) if (members[i].userId === id || taught.includes(members[i].classId)) members.splice(i, 1); + for (let i = links.length - 1; i >= 0; i--) if (links[i].parentId === id || links[i].childId === id) links.splice(i, 1); + for (const [aid, a] of assignments) if (a.createdBy === id || a.studentId === id || taught.includes(a.classId)) assignments.delete(aid); + for (const [sid, sc] of schedules) if (sc.teacherId === id || taught.includes(sc.classId)) schedules.delete(sid); + for (const [iid, inv] of invites) if (inv.createdBy === id || inv.childId === id) invites.delete(iid); + progress.delete(id); + users.delete(id); + }, + async touchUser(id, now = Date.now()) { + if (users.has(id)) users.get(id).lastSeen = now; + }, + usersWithRole: async (role) => [...users.values()].filter((u) => u.role === role).map(user).sort(byName), + + // ---- progress + getProgress: async (userId) => copy(progress.get(userId)), + putProgress: async (userId, data, now = Date.now()) => void progress.set(userId, { data: copy(data), updatedAt: now }), + + // ---- classes and rosters + async createClass(c, now = Date.now()) { + if (find(classes, (x) => x.code === c.code)) throw new Error('class code taken'); + const created = { id: newId(), teacherId: c.teacherId, schoolId: c.schoolId || null, name: c.name, code: c.code, createdAt: now }; + classes.set(created.id, created); + return copy(created); + }, + classById: async (id) => copy(classes.get(id)), + classByCode: async (code) => copy(find(classes, (c) => c.code === code)), + classesOfTeacher: async (teacherId) => [...classes.values()].filter((c) => c.teacherId === teacherId).sort(byTime('createdAt')).map(copy), + async classesOfStudent(userId) { + const ids = new Set(members.filter((m) => m.userId === userId).map((m) => m.classId)); + return [...classes.values()].filter((c) => ids.has(c.id)).sort(byTime('createdAt')).map(copy); + }, + async renameClass(id, name) { + if (classes.has(id)) classes.get(id).name = name; + }, + async addMember(classId, userId, now = Date.now()) { + if (!members.some((m) => m.classId === classId && m.userId === userId)) members.push({ classId, userId, joinedAt: now }); + }, + async removeMember(classId, userId) { + const i = members.findIndex((m) => m.classId === classId && m.userId === userId); + if (i >= 0) members.splice(i, 1); + }, + members: async (classId) => + members + .filter((m) => m.classId === classId) + .map((m) => user(users.get(m.userId))) + .filter(Boolean) + .sort(byName), + isMember: async (classId, userId) => members.some((m) => m.classId === classId && m.userId === userId), + + // ---- parents + async linkParent(parentId, childId, now = Date.now()) { + if (!links.some((l) => l.parentId === parentId && l.childId === childId)) links.push({ parentId, childId, createdAt: now }); + }, + async unlinkParent(parentId, childId) { + const i = links.findIndex((l) => l.parentId === parentId && l.childId === childId); + if (i >= 0) links.splice(i, 1); + }, + childrenOf: async (parentId) => + links + .filter((l) => l.parentId === parentId) + .map((l) => user(users.get(l.childId))) + .filter(Boolean) + .sort((a, b) => a.firstName.localeCompare(b.firstName)), + isParentOf: async (parentId, childId) => links.some((l) => l.parentId === parentId && l.childId === childId), + parentsOf: async (childId) => + links + .filter((l) => l.childId === childId) + .map((l) => user(users.get(l.parentId))) + .filter(Boolean) + .sort(byName), + + // ---- invites (one-time codes, kept as hashes) + async createInvite(i, now = Date.now()) { + if (find(invites, (x) => x.codeHash === i.codeHash)) throw new Error('invite code taken'); + const created = { id: newId(), kind: i.kind, codeHash: i.codeHash, createdBy: i.createdBy, childId: i.childId || null, schoolId: i.schoolId || null, expiresAt: i.expiresAt, usedBy: null, usedAt: null, createdAt: now }; + invites.set(created.id, created); + return copy(created); + }, + invite: async (id) => copy(invites.get(id)), + inviteByHash: async (codeHash) => copy(find(invites, (x) => x.codeHash === codeHash)), + // Uses an invite if it's still good: the invite, or null (unknown, used or expired) + async redeemInvite(codeHash, userId, now = Date.now()) { + const i = find(invites, (x) => x.codeHash === codeHash); + if (!i || i.usedAt !== null || i.expiresAt <= now) return null; + Object.assign(i, { usedBy: userId, usedAt: now }); + return copy(i); + }, + invitesBy: async (createdBy) => [...invites.values()].filter((i) => i.createdBy === createdBy).sort((a, b) => b.createdAt - a.createdAt).map(copy), + allInvites: async ({ kind = null } = {}) => [...invites.values()].filter((i) => !kind || i.kind === kind).sort((a, b) => b.createdAt - a.createdAt).map(copy), + deleteInvite: async (id) => void invites.delete(id), + // Removes someone's unused invites of a kind (for one child, if given) + async revokeInvites({ createdBy, kind, childId }) { + for (const [id, i] of invites) if (i.createdBy === createdBy && i.kind === kind && i.usedAt === null && (childId === undefined || i.childId === childId)) invites.delete(id); + }, + countInvites: async ({ createdBy, kind, since = 0, unused = false }) => + [...invites.values()].filter((i) => i.createdBy === createdBy && i.kind === kind && i.createdAt >= since && (!unused || i.usedAt === null)).length, + + // ---- homework + async createAssignment(a, now = Date.now()) { + const created = { id: newId(), createdBy: a.createdBy, classId: a.classId || null, studentId: a.studentId || null, goal: copy(a.goal), due: a.due || null, createdAt: now }; + assignments.set(created.id, created); + return copy(created); + }, + assignment: async (id) => copy(assignments.get(id)), + deleteAssignment: async (id) => void assignments.delete(id), + assignmentsForClass: async (classId) => [...assignments.values()].filter((a) => a.classId === classId).sort(byTime('createdAt')).map(copy), + // Everything a student has: their classes' homework plus homework set just for them + async assignmentsForStudent(userId) { + const ids = new Set(members.filter((m) => m.userId === userId).map((m) => m.classId)); + return [...assignments.values()].filter((a) => a.studentId === userId || ids.has(a.classId)).sort(byTime('createdAt')).map(copy); + }, + + // ---- scheduled tournaments + async createSchedule(s, now = Date.now()) { + const created = { + id: newId(), + teacherId: s.teacherId, + scope: s.scope, + classId: s.classId || null, + schoolId: s.schoolId || null, + title: s.title, + startsAt: s.startsAt, + settings: copy(s.settings || {}), + roomCode: null, + teacherKey: null, + openedAt: null, + createdAt: now, + }; + schedules.set(created.id, created); + return copy(created); + }, + schedule: async (id) => copy(schedules.get(id)), + deleteSchedule: async (id) => void schedules.delete(id), + schedulesOfTeacher: async (teacherId) => [...schedules.values()].filter((s) => s.teacherId === teacherId).sort(byTime('startsAt')).map(copy), + dueSchedules: async (now) => [...schedules.values()].filter((s) => s.openedAt === null && s.startsAt <= now).map(copy), + // Only once: false if it was already opened (by another server) + async markOpened(id, roomCode, teacherKey, now = Date.now()) { + const s = schedules.get(id); + if (!s || s.openedAt !== null) return false; + Object.assign(s, { roomCode, teacherKey, openedAt: now }); + return true; + }, + // Open tournaments a student can join: their classes', or their school's + async openSchedulesForStudent(u) { + const ids = new Set(members.filter((m) => m.userId === u.id).map((m) => m.classId)); + return [...schedules.values()] + .filter((s) => s.roomCode !== null && (ids.has(s.classId) || (s.scope === 'school' && s.schoolId === (u.schoolId || '')))) + .sort(byTime('startsAt')) + .map(copy); + }, + + // Everything stored, for checks (privacy tests, migrations) + exportAll: async () => ({ + meta: Object.fromEntries(meta), + schools: [...schools.values()].map(copy), + users: [...users.values()].map(copy), + progress: [...progress].map(([userId, p]) => ({ userId, ...copy(p) })), + classes: [...classes.values()].map(copy), + classMembers: members.map(copy), + parentLinks: links.map(copy), + assignments: [...assignments.values()].map(copy), + schedules: [...schedules.values()].map(copy), + invites: [...invites.values()].map(copy), + }), + }; +} + +export { openMemoryStore }; diff --git a/apps/server/src/store/migrations.js b/apps/server/src/store/migrations.js new file mode 100644 index 0000000..56d5296 --- /dev/null +++ b/apps/server/src/store/migrations.js @@ -0,0 +1,81 @@ +// MongoDB schema steps, in order. Each runs once per database (the version is +// kept in meta._id 'schema'); a step must be safe to run again if a server +// stops halfway. Add new steps at the end; never change one that has run. +import { DEFAULT_SCHOOLS } from './shared.js'; + +const MIGRATIONS = [ + { + version: 1, + name: 'indexes and the first school', + async up(db) { + await db.collection('users').createIndexes([ + { key: { sub: 1 }, unique: true }, + // (only codes that exist must be unique) + { key: { parentCode: 1 }, unique: true, partialFilterExpression: { parentCode: { $type: 'string' } } }, + { key: { role: 1, firstName: 1 } }, + { key: { schoolId: 1 } }, + ]); + await db.collection('schools').createIndex({ domain: 1 }, { unique: true }); + await db.collection('classes').createIndexes([{ key: { code: 1 }, unique: true }, { key: { teacherId: 1 } }]); + await db.collection('classMembers').createIndexes([{ key: { classId: 1, userId: 1 }, unique: true }, { key: { userId: 1 } }]); + await db.collection('parentLinks').createIndexes([{ key: { parentId: 1, childId: 1 }, unique: true }, { key: { childId: 1 } }]); + await db.collection('assignments').createIndexes([{ key: { classId: 1 } }, { key: { studentId: 1 } }, { key: { createdBy: 1 } }]); + await db.collection('schedules').createIndexes([{ key: { openedAt: 1, startsAt: 1 } }, { key: { teacherId: 1 } }, { key: { classId: 1 } }]); + for (const s of DEFAULT_SCHOOLS) { + await db.collection('schools').updateOne({ _id: s.id }, { $setOnInsert: { name: s.name, domain: s.domain } }, { upsert: true }); + } + }, + }, + { + version: 2, + name: 'invites replace per-student parent codes', + async up(db) { + // One-time invites (hashed codes); used and expired ones go a week after they expire + await db.collection('invites').createIndexes([ + { key: { codeHash: 1 }, unique: true }, + { key: { createdBy: 1 } }, + { key: { childId: 1 } }, + { key: { expiresAt: 1 }, expireAfterSeconds: 7 * 24 * 3600 }, + ]); + // Permanent parent codes are retired: grown-ups get invites now + await db.collection('users').updateMany({ parentCode: { $exists: true } }, { $unset: { parentCode: '' } }); + if ((await db.collection('users').indexes()).some((ix) => ix.name === 'parentCode_1')) await db.collection('users').dropIndex('parentCode_1'); + await db.collection('users').updateMany({ invitedBy: { $exists: false } }, { $set: { invitedBy: null } }); + await db.collection('users').createIndex({ lastSeen: -1 }); + }, + }, +]; + +const LOCK_MS = 60 * 1000; + +// Runs the steps this database hasn't had yet. Several servers may start at +// once: one takes the lock, the others wait for it to finish. +async function migrate(db, { now = () => Date.now(), steps = MIGRATIONS, owner = Math.random().toString(36).slice(2) } = {}) { + const meta = db.collection('meta'); + await meta.updateOne({ _id: 'schema' }, { $setOnInsert: { version: 0, lockedUntil: 0 } }, { upsert: true }); + const latest = steps.length ? steps[steps.length - 1].version : 0; + for (let waited = 0; ; waited++) { + const doc = await meta.findOne({ _id: 'schema' }); + if (doc.version >= latest) return doc.version; + const lock = await meta.findOneAndUpdate( + { _id: 'schema', lockedUntil: { $lt: now() } }, + { $set: { lockedUntil: now() + LOCK_MS, lockedBy: owner } }, + { returnDocument: 'after' } + ); + if (lock) { + try { + for (const step of steps.filter((s) => s.version > lock.version)) { + await step.up(db); + await meta.updateOne({ _id: 'schema' }, { $set: { version: step.version } }); + } + } finally { + await meta.updateOne({ _id: 'schema', lockedBy: owner }, { $set: { lockedUntil: 0 }, $unset: { lockedBy: '' } }); + } + continue; + } + if (waited > LOCK_MS / 500) throw new Error('database migration is taking too long (is another server stuck?)'); + await new Promise((resolve) => setTimeout(resolve, 500)); + } +} + +export { MIGRATIONS, migrate }; diff --git a/apps/server/src/store/mongo.js b/apps/server/src/store/mongo.js new file mode 100644 index 0000000..611b6eb --- /dev/null +++ b/apps/server/src/store/mongo.js @@ -0,0 +1,242 @@ +// Accounts storage in MongoDB: the store for servers (several can share it). +// Collections hold plain documents with string ids in _id; see migrations.js +// for the indexes. Same interface as memory.js. +// +// Privacy: a user document holds a first name, a last initial, a role, the +// email *domain* and the sign-in provider's opaque subject id. Never the email +// address itself. +import { MongoClient } from 'mongodb'; +import { newId, nameOf, byName } from './shared.js'; +import { migrate } from './migrations.js'; + +// { _id, ...rest } -> { id, ...rest } +const out = (doc) => { + if (!doc) return null; + const rest = withoutId(doc); + return { id: doc._id, ...rest }; +}; +function withoutId(doc) { + const rest = { ...doc }; + delete rest._id; + return rest; +} +const userOut = (doc) => (doc ? { ...out(doc), name: nameOf(doc) } : null); +const noId = { projection: { _id: 0 } }; +// (expiresAt is a Date in the database, for its expiry index; a number everywhere else) +const inviteOut = (doc) => (doc ? { ...out(doc), expiresAt: +doc.expiresAt } : null); +const isDuplicate = (e) => e && e.code === 11000; + +async function openMongoStore(url, { dbName } = {}) { + const client = new MongoClient(url, { appName: 'blockout', serverSelectionTimeoutMS: 5000 }); + await client.connect(); + const db = client.db(dbName || undefined); // (the database named in the URL, else "test") + const col = (name) => db.collection(name); + const [meta, schools, users, progress, classes, classMembers, parentLinks, assignments, schedules, invites] = [ + 'meta', + 'schools', + 'users', + 'progress', + 'classes', + 'classMembers', + 'parentLinks', + 'assignments', + 'schedules', + 'invites', + ].map(col); + + const classIdsOf = async (userId) => (await classMembers.find({ userId }, noId).toArray()).map((m) => m.classId); + const usersByIds = async (ids) => (ids.length ? (await users.find({ _id: { $in: ids } }).toArray()).map(userOut) : []); + + return { + kind: 'mongo', + db, + migrate: () => migrate(db), + close: () => client.close(), + ping: async () => (await db.command({ ping: 1 })).ok === 1, + + // ---- meta (the schema version also lives here, under 'schema') + getMeta: async (key) => ((await meta.findOne({ _id: key })) || {}).value ?? null, + setMeta: async (key, value) => void (await meta.updateOne({ _id: key }, { $set: { value } }, { upsert: true })), + + // ---- schools + schoolByDomain: async (domain) => out(await schools.findOne({ domain: String(domain || '').toLowerCase() })), + school: async (id) => out(await schools.findOne({ _id: id })), + schools: async () => (await schools.find().toArray()).map(out).sort((a, b) => a.name.localeCompare(b.name)), + async addSchool(s) { + try { + await schools.insertOne({ _id: s.id, name: s.name, domain: s.domain.toLowerCase() }); + } catch (e) { + if (!isDuplicate(e)) throw e; // (id or domain taken: left as it is) + } + }, + + // ---- users + userBySub: async (sub) => userOut(await users.findOne({ sub })), + user: async (id) => userOut(await users.findOne({ _id: id })), + // Creates the user (with u.role, else as a student), or updates their name + // and school when they sign in again. The role is never changed here. + async upsertUser(u, now = Date.now()) { + const fields = { firstName: u.firstName, lastInitial: u.lastInitial || '', domain: u.domain || null, schoolId: u.schoolId || null, lastSeen: now }; + const write = () => + users.findOneAndUpdate( + { sub: u.sub }, + { $set: fields, $setOnInsert: { _id: newId(), role: u.role || 'student', invitedBy: null, createdAt: now } }, + { upsert: true, returnDocument: 'after' } + ); + try { + return userOut(await write()); + } catch (e) { + if (!isDuplicate(e)) throw e; + return userOut(await write()); // two first sign-ins at once: the other one made it + } + }, + touchUser: async (id, now = Date.now()) => void (await users.updateOne({ _id: id }, { $set: { lastSeen: now } })), + setRole: async (id, role, { invitedBy } = {}) => + void (await users.updateOne({ _id: id }, { $set: { role, ...(invitedBy !== undefined ? { invitedBy } : {}) } })), + // For admins: newest first, by first name (any case) and role; { users, total } + async listUsers({ q = '', role = null, limit = 50, offset = 0 } = {}) { + const escaped = String(q).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const filter = { ...(role ? { role } : {}), ...(q ? { firstName: { $regex: `^${escaped}`, $options: 'i' } } : {}) }; + const [list, total] = await Promise.all([users.find(filter).sort({ lastSeen: -1 }).skip(offset).limit(limit).toArray(), users.countDocuments(filter)]); + return { users: list.map(userOut), total }; + }, + staleUsers: async (before, limit = 100) => + (await users.find({ lastSeen: { $lt: before } }, { projection: { _id: 1 } }).sort({ lastSeen: 1 }).limit(limit).toArray()).map((u) => u._id), + // Everything that belongs to a user goes with them (see memory.js) + async deleteUser(id) { + const taught = (await classes.find({ teacherId: id }, { projection: { _id: 1 } }).toArray()).map((c) => c._id); + await classMembers.deleteMany({ $or: [{ userId: id }, { classId: { $in: taught } }] }); + await parentLinks.deleteMany({ $or: [{ parentId: id }, { childId: id }] }); + await assignments.deleteMany({ $or: [{ createdBy: id }, { studentId: id }, { classId: { $in: taught } }] }); + await schedules.deleteMany({ $or: [{ teacherId: id }, { classId: { $in: taught } }] }); + await invites.deleteMany({ $or: [{ createdBy: id }, { childId: id }] }); + await classes.deleteMany({ _id: { $in: taught } }); + await progress.deleteOne({ _id: id }); + await users.deleteOne({ _id: id }); + }, + usersWithRole: async (role) => (await users.find({ role }).toArray()).map(userOut).sort(byName), + + // ---- progress (as JSON text: a save's keys are the game's, not ours to restrict) + async getProgress(userId) { + const doc = await progress.findOne({ _id: userId }); + return doc ? { data: JSON.parse(doc.json), updatedAt: doc.updatedAt } : null; + }, + putProgress: async (userId, data, now = Date.now()) => + void (await progress.updateOne({ _id: userId }, { $set: { json: JSON.stringify(data), updatedAt: now } }, { upsert: true })), + + // ---- classes and rosters + async createClass(c, now = Date.now()) { + const doc = { _id: newId(), teacherId: c.teacherId, schoolId: c.schoolId || null, name: c.name, code: c.code, createdAt: now }; + await classes.insertOne(doc); + return out(doc); + }, + classById: async (id) => out(await classes.findOne({ _id: id })), + classByCode: async (code) => out(await classes.findOne({ code })), + classesOfTeacher: async (teacherId) => (await classes.find({ teacherId }).sort({ createdAt: 1 }).toArray()).map(out), + classesOfStudent: async (userId) => (await classes.find({ _id: { $in: await classIdsOf(userId) } }).sort({ createdAt: 1 }).toArray()).map(out), + renameClass: async (id, name) => void (await classes.updateOne({ _id: id }, { $set: { name } })), + addMember: async (classId, userId, now = Date.now()) => + void (await classMembers.updateOne({ classId, userId }, { $setOnInsert: { joinedAt: now } }, { upsert: true })), + removeMember: async (classId, userId) => void (await classMembers.deleteOne({ classId, userId })), + members: async (classId) => (await usersByIds((await classMembers.find({ classId }, noId).toArray()).map((m) => m.userId))).sort(byName), + isMember: async (classId, userId) => Boolean(await classMembers.findOne({ classId, userId })), + + // ---- parents + linkParent: async (parentId, childId, now = Date.now()) => + void (await parentLinks.updateOne({ parentId, childId }, { $setOnInsert: { createdAt: now } }, { upsert: true })), + unlinkParent: async (parentId, childId) => void (await parentLinks.deleteOne({ parentId, childId })), + childrenOf: async (parentId) => + (await usersByIds((await parentLinks.find({ parentId }, noId).toArray()).map((l) => l.childId))).sort((a, b) => a.firstName.localeCompare(b.firstName)), + isParentOf: async (parentId, childId) => Boolean(await parentLinks.findOne({ parentId, childId })), + parentsOf: async (childId) => (await usersByIds((await parentLinks.find({ childId }, noId).toArray()).map((l) => l.parentId))).sort(byName), + + // ---- invites (one-time codes, kept as hashes) + async createInvite(i, now = Date.now()) { + const doc = { _id: newId(), kind: i.kind, codeHash: i.codeHash, createdBy: i.createdBy, childId: i.childId || null, schoolId: i.schoolId || null, expiresAt: i.expiresAt, usedBy: null, usedAt: null, createdAt: now }; + await invites.insertOne({ ...doc, expiresAt: new Date(doc.expiresAt) }); // (a Date, for the expiry index) + return out(doc); + }, + invite: async (id) => inviteOut(await invites.findOne({ _id: id })), + inviteByHash: async (codeHash) => inviteOut(await invites.findOne({ codeHash })), + // Uses an invite if it's still good (one request wins): the invite, or null + redeemInvite: async (codeHash, userId, now = Date.now()) => + inviteOut( + await invites.findOneAndUpdate({ codeHash, usedAt: null, expiresAt: { $gt: new Date(now) } }, { $set: { usedBy: userId, usedAt: now } }, { returnDocument: 'after' }) + ), + invitesBy: async (createdBy) => (await invites.find({ createdBy }).sort({ createdAt: -1 }).toArray()).map(inviteOut), + allInvites: async ({ kind = null } = {}) => (await invites.find(kind ? { kind } : {}).sort({ createdAt: -1 }).toArray()).map(inviteOut), + deleteInvite: async (id) => void (await invites.deleteOne({ _id: id })), + revokeInvites: async ({ createdBy, kind, childId }) => + void (await invites.deleteMany({ createdBy, kind, usedAt: null, ...(childId !== undefined ? { childId } : {}) })), + countInvites: async ({ createdBy, kind, since = 0, unused = false }) => + invites.countDocuments({ createdBy, kind, createdAt: { $gte: since }, ...(unused ? { usedAt: null } : {}) }), + + // ---- homework + async createAssignment(a, now = Date.now()) { + const doc = { _id: newId(), createdBy: a.createdBy, classId: a.classId || null, studentId: a.studentId || null, goal: a.goal, due: a.due || null, createdAt: now }; + await assignments.insertOne(doc); + return out(doc); + }, + assignment: async (id) => out(await assignments.findOne({ _id: id })), + deleteAssignment: async (id) => void (await assignments.deleteOne({ _id: id })), + assignmentsForClass: async (classId) => (await assignments.find({ classId }).sort({ createdAt: 1 }).toArray()).map(out), + // Everything a student has: their classes' homework plus homework set just for them + assignmentsForStudent: async (userId) => + (await assignments.find({ $or: [{ studentId: userId }, { classId: { $in: await classIdsOf(userId) } }] }).sort({ createdAt: 1 }).toArray()).map(out), + + // ---- scheduled tournaments + async createSchedule(s, now = Date.now()) { + const doc = { + _id: newId(), + teacherId: s.teacherId, + scope: s.scope, + classId: s.classId || null, + schoolId: s.schoolId || null, + title: s.title, + startsAt: s.startsAt, + settings: s.settings || {}, + roomCode: null, + teacherKey: null, + openedAt: null, + createdAt: now, + }; + await schedules.insertOne(doc); + return out(doc); + }, + schedule: async (id) => out(await schedules.findOne({ _id: id })), + deleteSchedule: async (id) => void (await schedules.deleteOne({ _id: id })), + schedulesOfTeacher: async (teacherId) => (await schedules.find({ teacherId }).sort({ startsAt: 1 }).toArray()).map(out), + dueSchedules: async (now) => (await schedules.find({ openedAt: null, startsAt: { $lte: now } }).toArray()).map(out), + // Only once: false if it was already opened (by another server) + markOpened: async (id, roomCode, teacherKey, now = Date.now()) => + (await schedules.updateOne({ _id: id, openedAt: null }, { $set: { roomCode, teacherKey, openedAt: now } })).modifiedCount === 1, + // Open tournaments a student can join: their classes', or their school's + openSchedulesForStudent: async (u) => + ( + await schedules + .find({ roomCode: { $ne: null }, $or: [{ classId: { $in: await classIdsOf(u.id) } }, { scope: 'school', schoolId: u.schoolId || '' }] }) + .sort({ startsAt: 1 }) + .toArray() + ).map(out), + + // Everything stored, for checks (privacy tests, migrations) + async exportAll() { + const all = async (c, map = out) => (await c.find().toArray()).map(map); + const strip = withoutId; + return { + meta: Object.fromEntries((await meta.find({ _id: { $ne: 'schema' } }).toArray()).map((m) => [m._id, m.value])), + schools: await all(schools), + users: await all(users), + progress: (await progress.find().toArray()).map((p) => ({ userId: p._id, data: JSON.parse(p.json), updatedAt: p.updatedAt })), + classes: await all(classes), + classMembers: await all(classMembers, strip), + parentLinks: await all(parentLinks, strip), + assignments: await all(assignments), + schedules: await all(schedules), + invites: await all(invites, inviteOut), + }; + }, + }; +} + +export { openMongoStore }; diff --git a/apps/server/src/store/shared.js b/apps/server/src/store/shared.js new file mode 100644 index 0000000..be86e3c --- /dev/null +++ b/apps/server/src/store/shared.js @@ -0,0 +1,15 @@ +// What every accounts store shares: ids, the first school, how a name is shown. +import crypto from 'crypto'; + +const newId = () => crypto.randomBytes(8).toString('hex'); + +// The first school. More can be added with addSchool(). +const DEFAULT_SCHOOLS = [{ id: 'cadence-park', name: 'Cadence Park', domain: 'iusd.org' }]; + +// "Maya K." (or just "Maya") +const nameOf = (u) => (u.lastInitial ? `${u.firstName} ${u.lastInitial}.` : u.firstName); + +// Rosters are listed by first name, then last initial +const byName = (a, b) => a.firstName.localeCompare(b.firstName) || a.lastInitial.localeCompare(b.lastInitial); + +export { newId, DEFAULT_SCHOOLS, nameOf, byName }; diff --git a/apps/server/src/store/sqlite.js b/apps/server/src/store/sqlite.js new file mode 100644 index 0000000..6e5fbff --- /dev/null +++ b/apps/server/src/store/sqlite.js @@ -0,0 +1,269 @@ +// Blockout accounts storage in sqlite, through Node's built-in node:sqlite: the +// store before MongoDB, kept to read old databases (and as a reference the +// other stores are tested against). +// +// Privacy: a user row holds a first name, a last initial, a role, the email +// *domain* (to match a school, e.g. iusd.org) and the sign-in provider's opaque +// subject id. Never the email address itself. +import fs from 'fs'; +import path from 'path'; +import { DatabaseSync } from 'node:sqlite'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS schools (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT UNIQUE NOT NULL); +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + sub TEXT UNIQUE NOT NULL, + role TEXT NOT NULL, + first_name TEXT NOT NULL, + last_initial TEXT NOT NULL DEFAULT '', + domain TEXT, + school_id TEXT REFERENCES schools(id), + parent_code TEXT UNIQUE, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS progress (user_id TEXT PRIMARY KEY REFERENCES users(id), data TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS classes ( + id TEXT PRIMARY KEY, + teacher_id TEXT NOT NULL REFERENCES users(id), + school_id TEXT REFERENCES schools(id), + name TEXT NOT NULL, + code TEXT UNIQUE NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS class_members ( + class_id TEXT NOT NULL REFERENCES classes(id), + user_id TEXT NOT NULL REFERENCES users(id), + joined_at INTEGER NOT NULL, + PRIMARY KEY (class_id, user_id) +); +CREATE TABLE IF NOT EXISTS parent_links ( + parent_id TEXT NOT NULL REFERENCES users(id), + child_id TEXT NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (parent_id, child_id) +); +CREATE TABLE IF NOT EXISTS assignments ( + id TEXT PRIMARY KEY, + created_by TEXT NOT NULL REFERENCES users(id), + class_id TEXT REFERENCES classes(id), + student_id TEXT REFERENCES users(id), + goal TEXT NOT NULL, + due TEXT, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + teacher_id TEXT NOT NULL REFERENCES users(id), + scope TEXT NOT NULL, + class_id TEXT REFERENCES classes(id), + school_id TEXT REFERENCES schools(id), + title TEXT NOT NULL, + starts_at INTEGER NOT NULL, + settings TEXT NOT NULL, + room_code TEXT, + teacher_key TEXT, + opened_at INTEGER, + created_at INTEGER NOT NULL +); +`; + +import { newId, DEFAULT_SCHOOLS } from './shared.js'; + +// file: a path, or ':memory:' (tests). Defaults to data/blockout.db at the repo's root. +function openSqliteStore(file = process.env.BLOCKOUT_DB || path.join(import.meta.dirname, '..', '..', '..', '..', 'data', 'blockout.db')) { + if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec('PRAGMA foreign_keys = ON;'); + db.exec(SCHEMA); + const insertSchool = db.prepare('INSERT OR IGNORE INTO schools (id, name, domain) VALUES (?, ?, ?)'); + for (const s of DEFAULT_SCHOOLS) insertSchool.run(s.id, s.name, s.domain); + + const one = (sql, ...args) => db.prepare(sql).get(...args) || null; + const all = (sql, ...args) => db.prepare(sql).all(...args); + const run = (sql, ...args) => db.prepare(sql).run(...args); + const plain = (row) => (row ? { ...row } : null); // node:sqlite rows have a null prototype + + const userFromRow = (r) => + r && { + id: r.id, + sub: r.sub, + role: r.role, + firstName: r.first_name, + lastInitial: r.last_initial, + name: r.last_initial ? `${r.first_name} ${r.last_initial}.` : r.first_name, + domain: r.domain, + schoolId: r.school_id, + parentCode: r.parent_code, + createdAt: r.created_at, + lastSeen: r.last_seen, + }; + const classFromRow = (r) => r && { id: r.id, teacherId: r.teacher_id, schoolId: r.school_id, name: r.name, code: r.code, createdAt: r.created_at }; + const assignmentFromRow = (r) => + r && { id: r.id, createdBy: r.created_by, classId: r.class_id, studentId: r.student_id, goal: JSON.parse(r.goal), due: r.due, createdAt: r.created_at }; + const scheduleFromRow = (r) => + r && { + id: r.id, + teacherId: r.teacher_id, + scope: r.scope, + classId: r.class_id, + schoolId: r.school_id, + title: r.title, + startsAt: r.starts_at, + settings: JSON.parse(r.settings), + roomCode: r.room_code, + teacherKey: r.teacher_key, + openedAt: r.opened_at, + createdAt: r.created_at, + }; + + const table = (name) => all(`SELECT * FROM ${name}`).map(plain); + + // node:sqlite answers at once; the other stores answer later. Every method + // returns a promise here too, so callers (and errors) behave the same. + const asyncMethods = (store) => Object.fromEntries(Object.entries(store).map(([k, v]) => [k, typeof v === 'function' ? async (...args) => v(...args) : v])); + + return asyncMethods({ + kind: 'sqlite', + db, + migrate: () => {}, + close: () => db.close(), + ping: () => Boolean(one('SELECT 1 AS ok')), + + // ---- meta (e.g. the dev sign-in secret, so tokens survive a restart) + getMeta: (key) => (one('SELECT value FROM meta WHERE key = ?', key) || {}).value || null, + setMeta: (key, value) => run('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', key, value), + + // ---- schools + schoolByDomain: (domain) => plain(one('SELECT * FROM schools WHERE domain = ?', String(domain || '').toLowerCase())), + school: (id) => plain(one('SELECT * FROM schools WHERE id = ?', id)), + schools: () => all('SELECT * FROM schools ORDER BY name').map(plain), + addSchool: (s) => run('INSERT OR IGNORE INTO schools (id, name, domain) VALUES (?, ?, ?)', s.id, s.name, s.domain.toLowerCase()), + + // ---- users + userBySub: (sub) => userFromRow(one('SELECT * FROM users WHERE sub = ?', sub)), + user: (id) => userFromRow(one('SELECT * FROM users WHERE id = ?', id)), + userByParentCode: (code) => userFromRow(one('SELECT * FROM users WHERE parent_code = ?', code)), + // Creates the user, or updates name/role/school when they sign in again. + upsertUser(u, now = Date.now()) { + const existing = one('SELECT id FROM users WHERE sub = ?', u.sub); + if (existing) { + run( + 'UPDATE users SET role = ?, first_name = ?, last_initial = ?, domain = ?, school_id = ?, last_seen = ? WHERE id = ?', + u.role, u.firstName, u.lastInitial || '', u.domain || null, u.schoolId || null, now, existing.id + ); + return userFromRow(one('SELECT * FROM users WHERE id = ?', existing.id)); + } + const id = newId(); + run( + 'INSERT INTO users (id, sub, role, first_name, last_initial, domain, school_id, created_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + id, u.sub, u.role, u.firstName, u.lastInitial || '', u.domain || null, u.schoolId || null, now, now + ); + return userFromRow(one('SELECT * FROM users WHERE id = ?', id)); + }, + touchUser: (id, now = Date.now()) => run('UPDATE users SET last_seen = ? WHERE id = ?', now, id), + setParentCode: (id, code) => run('UPDATE users SET parent_code = ? WHERE id = ?', code, id), + usersWithRole: (role) => all('SELECT * FROM users WHERE role = ? ORDER BY first_name', role).map(userFromRow), + + // ---- progress (the game's save, as JSON) + getProgress(userId) { + const r = one('SELECT data, updated_at FROM progress WHERE user_id = ?', userId); + return r ? { data: JSON.parse(r.data), updatedAt: r.updated_at } : null; + }, + putProgress: (userId, data, now = Date.now()) => + run('INSERT INTO progress (user_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at', userId, JSON.stringify(data), now), + + // ---- classes and rosters + createClass(c, now = Date.now()) { + const id = newId(); + run('INSERT INTO classes (id, teacher_id, school_id, name, code, created_at) VALUES (?, ?, ?, ?, ?, ?)', id, c.teacherId, c.schoolId || null, c.name, c.code, now); + return classFromRow(one('SELECT * FROM classes WHERE id = ?', id)); + }, + classById: (id) => classFromRow(one('SELECT * FROM classes WHERE id = ?', id)), + classByCode: (code) => classFromRow(one('SELECT * FROM classes WHERE code = ?', code)), + classesOfTeacher: (teacherId) => all('SELECT * FROM classes WHERE teacher_id = ? ORDER BY created_at', teacherId).map(classFromRow), + classesOfStudent: (userId) => + all('SELECT c.* FROM classes c JOIN class_members m ON m.class_id = c.id WHERE m.user_id = ? ORDER BY c.created_at', userId).map(classFromRow), + renameClass: (id, name) => run('UPDATE classes SET name = ? WHERE id = ?', name, id), + addMember: (classId, userId, now = Date.now()) => run('INSERT OR IGNORE INTO class_members (class_id, user_id, joined_at) VALUES (?, ?, ?)', classId, userId, now), + removeMember: (classId, userId) => run('DELETE FROM class_members WHERE class_id = ? AND user_id = ?', classId, userId), + members: (classId) => + all('SELECT u.* FROM users u JOIN class_members m ON m.user_id = u.id WHERE m.class_id = ? ORDER BY u.first_name, u.last_initial', classId).map(userFromRow), + isMember: (classId, userId) => Boolean(one('SELECT 1 AS x FROM class_members WHERE class_id = ? AND user_id = ?', classId, userId)), + + // ---- parents + linkParent: (parentId, childId, now = Date.now()) => run('INSERT OR IGNORE INTO parent_links (parent_id, child_id, created_at) VALUES (?, ?, ?)', parentId, childId, now), + unlinkParent: (parentId, childId) => run('DELETE FROM parent_links WHERE parent_id = ? AND child_id = ?', parentId, childId), + childrenOf: (parentId) => + all('SELECT u.* FROM users u JOIN parent_links l ON l.child_id = u.id WHERE l.parent_id = ? ORDER BY u.first_name', parentId).map(userFromRow), + isParentOf: (parentId, childId) => Boolean(one('SELECT 1 AS x FROM parent_links WHERE parent_id = ? AND child_id = ?', parentId, childId)), + + // ---- homework + createAssignment(a, now = Date.now()) { + const id = newId(); + run( + 'INSERT INTO assignments (id, created_by, class_id, student_id, goal, due, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + id, a.createdBy, a.classId || null, a.studentId || null, JSON.stringify(a.goal), a.due || null, now + ); + return assignmentFromRow(one('SELECT * FROM assignments WHERE id = ?', id)); + }, + assignment: (id) => assignmentFromRow(one('SELECT * FROM assignments WHERE id = ?', id)), + deleteAssignment: (id) => run('DELETE FROM assignments WHERE id = ?', id), + assignmentsForClass: (classId) => all('SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at', classId).map(assignmentFromRow), + // Everything a student has: their classes' homework plus homework set just for them + assignmentsForStudent: (userId) => + all( + `SELECT a.* FROM assignments a + WHERE a.student_id = ? OR a.class_id IN (SELECT class_id FROM class_members WHERE user_id = ?) + ORDER BY a.created_at`, + userId, userId + ).map(assignmentFromRow), + + // ---- scheduled tournaments + createSchedule(s, now = Date.now()) { + const id = newId(); + run( + 'INSERT INTO schedules (id, teacher_id, scope, class_id, school_id, title, starts_at, settings, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + id, s.teacherId, s.scope, s.classId || null, s.schoolId || null, s.title, s.startsAt, JSON.stringify(s.settings || {}), now + ); + return scheduleFromRow(one('SELECT * FROM schedules WHERE id = ?', id)); + }, + schedule: (id) => scheduleFromRow(one('SELECT * FROM schedules WHERE id = ?', id)), + deleteSchedule: (id) => run('DELETE FROM schedules WHERE id = ?', id), + schedulesOfTeacher: (teacherId) => all('SELECT * FROM schedules WHERE teacher_id = ? ORDER BY starts_at', teacherId).map(scheduleFromRow), + dueSchedules: (now) => all('SELECT * FROM schedules WHERE opened_at IS NULL AND starts_at <= ?', now).map(scheduleFromRow), + // Only once: false if it was already opened + markOpened: (id, roomCode, teacherKey, now = Date.now()) => + run('UPDATE schedules SET room_code = ?, teacher_key = ?, opened_at = ? WHERE id = ? AND opened_at IS NULL', roomCode, teacherKey, now, id).changes === 1, + // Open tournaments a student can join: their classes', or their school's + openSchedulesForStudent: (user) => + all( + `SELECT * FROM schedules WHERE room_code IS NOT NULL AND ( + class_id IN (SELECT class_id FROM class_members WHERE user_id = ?) + OR (scope = 'school' AND school_id = ?) + ) ORDER BY starts_at`, + user.id, user.schoolId || '' + ).map(scheduleFromRow), + + // Everything stored, in the other stores' shape (for checks and for moving to MongoDB) + exportAll: () => ({ + meta: Object.fromEntries(table('meta').map((r) => [r.key, r.value])), + schools: table('schools'), + users: table('users').map((r) => { + const u = userFromRow(r); + delete u.name; // (derived, not stored) + return u; + }), + progress: table('progress').map((r) => ({ userId: r.user_id, data: JSON.parse(r.data), updatedAt: r.updated_at })), + classes: table('classes').map(classFromRow), + classMembers: table('class_members').map((r) => ({ classId: r.class_id, userId: r.user_id, joinedAt: r.joined_at })), + parentLinks: table('parent_links').map((r) => ({ parentId: r.parent_id, childId: r.child_id, createdAt: r.created_at })), + assignments: table('assignments').map(assignmentFromRow), + schedules: table('schedules').map(scheduleFromRow), + }), + }); +} + +export { openSqliteStore }; diff --git a/server/tournament.js b/apps/server/src/tournament.js similarity index 99% rename from server/tournament.js rename to apps/server/src/tournament.js index 278c301..5d0d8e9 100644 --- a/server/tournament.js +++ b/apps/server/src/tournament.js @@ -14,8 +14,6 @@ // roundrobin everyone plays everyone in groups of up to 6; group winners then play a knockout // swiss a set number of rounds; players with the same record meet, no rematches // koth king of the hill: the winner stays on, challengers queue up; longest streak wins -'use strict'; - const FORMATS = ['knockout', 'double', 'roundrobin', 'swiss', 'koth']; const GROUP_MAX = 6; const HILL_MAX = 8; @@ -380,4 +378,4 @@ function standings(s) { }); } -module.exports = { FORMATS, bracketOrder, create, nextMatches, roundDone, record, standings, stop, isOver, champion }; +export { FORMATS, bracketOrder, create, nextMatches, roundDone, record, standings, stop, isOver, champion }; diff --git a/apps/server/test/accounts.test.js b/apps/server/test/accounts.test.js new file mode 100644 index 0000000..f048e89 --- /dev/null +++ b/apps/server/test/accounts.test.js @@ -0,0 +1,460 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import crypto from 'crypto'; +import { openMemoryStore } from '../src/store/memory.js'; +import * as Auth from '../src/auth.js'; +import { createLogger } from '../src/log.js'; +import { createAccounts } from '../src/accounts.js'; +import * as P from '@blockout/progress'; + +function setup() { + let clock = Date.UTC(2026, 8, 23, 17, 0, 0); + const store = openMemoryStore(); + const auth = Auth.devProvider({ secret: 'test-secret', now: () => clock }); + const accounts = createAccounts({ store, auth, now: () => clock }); + const signIn = async (person) => { + const { token } = await accounts.devSignIn(person); + return accounts.authenticate(token); + }; + return { store, auth, accounts, signIn, tick: (ms) => (clock += ms), now: () => clock }; +} + +test('dev sign-in: tokens check out, the same person gets the same account, bad tokens fail', async () => { + const { accounts, auth, signIn } = setup(); + const a = await signIn({ firstName: 'Maya', lastInitial: 'k', role: 'student', email: 'maya.k@iusd.org' }); + const b = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya.k@iusd.org' }); + assert.equal(a.id, b.id); + assert.equal(a.name, 'Maya K.'); + assert.equal(a.schoolId, 'cadence-park'); // @iusd.org is Cadence Park + assert.equal(a.domain, 'iusd.org'); // the domain, never the address + const token = auth.issue({ firstName: 'Sam', role: 'parent' }); + const forged = token.slice(0, -4) + (token.endsWith('AAAA') ? 'BBBB' : 'AAAA'); + await assert.rejects(accounts.authenticate(forged), /isn’t valid/); + await assert.rejects(accounts.devSignIn({ firstName: ' ', role: 'student' }), /first name/); +}); + +test('privacy: only first name, last initial, role and email domain are stored', async () => { + const { store, signIn } = setup(); + await signIn({ firstName: 'Olivia Grace', lastInitial: 'Martinez', role: 'student', email: 'olivia.martinez@iusd.org' }); + const rows = (await store.exportAll()).users; + const text = JSON.stringify(await store.exportAll()); + assert.ok(!text.includes('olivia.martinez'), 'no email address'); + assert.ok(!text.includes('Martinez'), 'no last name'); + assert.equal(rows[0].firstName, 'Olivia'); + assert.equal(rows[0].lastInitial, 'M'); + assert.deepEqual(Object.keys(rows[0]).sort(), ['createdAt', 'domain', 'firstName', 'id', 'invitedBy', 'lastInitial', 'lastSeen', 'role', 'schoolId', 'sub']); +}); + +test('keycloak provider: checks RS256 signature, issuer and client; roles are Blockout client roles', async () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', use: 'sig', alg: 'RS256' }; + const issuer = 'https://id.example.org/realms/blockout'; + const kc = Auth.keycloakProvider({ issuer, clientId: 'blockout', fetchJson: async () => ({ keys: [jwk] }), now: () => 1_800_000_000_000 }); + const make = (payload, key = privateKey) => { + const head = Auth.b64url(JSON.stringify({ alg: 'RS256', kid: 'k1', typ: 'JWT' })); + const body = Auth.b64url(JSON.stringify(payload)); + return `${head}.${body}.${Auth.b64url(crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), key))}`; + }; + const good = { + iss: issuer, azp: 'blockout', sub: 'abc', given_name: 'Ana', family_name: 'Lopez', email: 'ana@iusd.org', email_verified: true, + realm_access: { roles: ['admin'] }, resource_access: { blockout: { roles: ['teacher'] }, other: { roles: ['admin'] } }, exp: 1_900_000_000, + }; + const claims = await kc.verify(make(good)); + // the realm is shared: only Blockout's own client roles count, not realm roles or other apps' + assert.deepEqual(claims.roles, ['teacher']); + assert.deepEqual((await kc.verify(make({ ...good, resource_access: undefined }))).roles, []); + // the phone app's tokens (client blockout-mobile, audience blockout) carry the same roles + assert.deepEqual((await kc.verify(make({ ...good, azp: 'blockout-mobile', aud: ['blockout'], resource_access: { blockout: { roles: ['admin'] } } }))).roles, ['admin']); + await assert.rejects(kc.verify(make({ ...good, iss: 'https://evil.example.org' })), /somewhere else/); + await assert.rejects(kc.verify(make({ ...good, azp: 'other' })), /another app/); + await assert.rejects(kc.verify(make({ ...good, exp: 1_000 })), /expired/); + const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + await assert.rejects(kc.verify(make(good, other)), /isn’t valid/); + // Roles are Blockout's: a token's realm roles don't pick one, only `admin` counts + const schoolByDomain = (d) => (d === 'iusd.org' ? { id: 'cadence-park' } : null); + const id = Auth.identity(claims, { schoolByDomain }); + assert.deepEqual([id.role, id.isAdmin, id.schoolId, id.domain], [undefined, false, 'cadence-park', 'iusd.org']); + assert.equal(Auth.identity({ ...claims, roles: ['admin'] }, { schoolByDomain }).isAdmin, true); + // anyone can register any address: an unverified one counts as none, so it can't pick a school + for (const email_verified of [false, undefined, 'true']) { + const unverified = Auth.identity({ ...claims, email_verified }, { schoolByDomain }); + assert.deepEqual([unverified.schoolId, unverified.domain], [null, null]); + } +}); + +test('classes: teacher creates, any @iusd.org student joins with the code, others cannot', async () => { + const { accounts, signIn } = setup(); + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + assert.equal(teacher.schoolId, 'cadence-park'); + const c = await accounts.createClass(teacher, { name: 'Room 12' }); + assert.match(c.code, /^[A-Z2-9]{6}$/); + const kid = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + const outsider = await signIn({ firstName: 'Bo', role: 'student', email: 'bo@gmail.com' }); + assert.equal((await accounts.joinClass(kid, { code: c.code.toLowerCase() })).class.name, 'Room 12'); + await assert.rejects(accounts.joinClass(outsider, { code: c.code }), /@iusd\.org/); + await assert.rejects(accounts.joinClass(kid, { code: 'NOPE22' }), /No class/); + await assert.rejects(accounts.createClass(kid, { name: 'x' }), /Only teachers/); + const other = await signIn({ firstName: 'Mr', lastInitial: 'T', role: 'teacher', schoolId: 'cadence-park' }); + await assert.rejects(accounts.classDetail(other, c.id), /isn’t one of yours/); + const detail = await accounts.classDetail(teacher, c.id); + assert.equal(detail.students.length, 1); + assert.equal(detail.students[0].name, 'Maya K.'); + assert.ok(!('parentCode' in detail.students[0]), 'no permanent parent codes any more'); +}); + +test('progress sync: saves round-trip, cheats stay on the device, stats come from the save', async () => { + const { accounts, signIn } = setup(); + const kid = await signIn({ firstName: 'Maya', role: 'student', email: 'maya@iusd.org' }); + assert.equal((await accounts.getProgress(kid)).progress, null); + const s = P.createProgress(); + s.wallet = 50; + P.cheatOn(s, 'fivemoreminutesmom'); + s.facts.you = { name: 'You', facts: { '6 × 7': { asked: 4, firstTry: 1, wrong: 3, timeouts: 0, said: [], times: [] } } }; + await accounts.putProgress(kid, s); + const back = (await accounts.getProgress(kid)).progress; + assert.equal(back.wallet, 50); + assert.deepEqual(back.cheats, {}); + // IDDQD's free unlocks never reach the account (only what every save has: Practice and ×1) + P.cheatOn(s, 'iddqd'); + await accounts.putProgress(kid, s); + assert.deepEqual((await accounts.getProgress(kid)).progress.unlocks, ['practice', 'table1']); + assert.deepEqual(P.factSummary(back).needsPractice, ['6 × 7']); +}); + +test('merging a device into an account keeps the most of everything', () => { + const device = P.createProgress(); + device.wallet = 30; + device.unlocks = ['practice']; + device.facts.you = { name: 'You', facts: { '3 × 4': { asked: 5, firstTry: 5, wrong: 0, timeouts: 0, said: [], times: [] } } }; + P.noteActivity(device, { kind: 'game', won: true, size: 6, difficulty: 'easy' }, 1000); + const account = P.createProgress(); + account.wallet = 80; + account.unlocks = ['board8']; + account.achievements = { first_game: 500 }; + account.facts.you = { name: 'You', facts: { '3 × 4': { asked: 2, firstTry: 1, wrong: 1, timeouts: 0, said: [], times: [] }, '5 × 5': { asked: 1, firstTry: 1, wrong: 0, timeouts: 0, said: [], times: [] } } }; + P.noteActivity(account, { kind: 'practice', finished: true, difficulty: 'easy' }, 2000); + const m = P.mergeProgress(device, account); + assert.equal(m.wallet, 80); + assert.deepEqual(m.unlocks.sort(), ['board8', 'practice', 'table1']); // (×1 comes with Practice) + assert.equal(m.achievements.first_game, 500); + assert.equal(m.facts.you.facts['3 × 4'].asked, 5); + assert.ok(m.facts.you.facts['5 × 5']); + assert.deepEqual(m.activity.map((e) => e.kind), ['game', 'practice']); +}); + +test('homework: teacher sets class homework, parent sets extra; progress detected from the save', async () => { + const { accounts, signIn, tick, now } = setup(); + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + const c = await accounts.createClass(teacher, { name: 'Room 12' }); + const kid = await signIn({ firstName: 'Maya', role: 'student', email: 'maya@iusd.org' }); + await accounts.joinClass(kid, { code: c.code }); + const parent = await signIn({ firstName: 'Ana', role: 'parent', email: 'ana@gmail.com' }); + await assert.rejects(accounts.createAssignment(parent, { studentId: kid.id, goal: { type: 'games', games: 1 } }), /Link your child/); + const code = (await accounts.parentInvite(kid)).code; + assert.equal((await accounts.linkChild(parent, { code: code.toLowerCase() })).child.name, 'Maya'); + await accounts.createAssignment(teacher, { classId: c.id, goal: { type: 'practice', rounds: 2, difficulty: 'easy' }, due: '2026-09-25' }); + await accounts.createAssignment(parent, { studentId: kid.id, goal: { type: 'master', table: 7 } }); + await assert.rejects(accounts.createAssignment(teacher, { classId: c.id, goal: { type: 'fly' } }), /Pick what/); + tick(1000); + const s = P.createProgress(); + P.noteActivity(s, { kind: 'practice', finished: true, difficulty: 'easy' }, now()); + await accounts.putProgress(kid, s); + let hw = (await accounts.myHomework(kid)).homework; + assert.equal(hw.length, 2); + const practice = hw.find((h) => h.goal.type === 'practice'); + assert.deepEqual([practice.have, practice.need, practice.status, practice.from], [1, 2, 'open', 'teacher']); + assert.equal(hw.find((h) => h.goal.type === 'master').from, 'parent'); + // the parent sees everything, the teacher sees class progress + const seen = await accounts.childDetail(parent, kid.id); + assert.equal(seen.assignments.length, 2); + const stranger = await signIn({ firstName: 'Zed', role: 'parent' }); + await assert.rejects(accounts.childDetail(stranger, kid.id), /can’t see/); + const detail = await accounts.classDetail(teacher, c.id); + assert.deepEqual([detail.assignments.length, detail.assignments[0].done, detail.assignments[0].of], [1, 0, 1]); + // only the setter can remove homework + const extra = hw.find((h) => h.from === 'parent'); + await assert.rejects(accounts.deleteAssignment(teacher, extra.id), /Only whoever/); + await accounts.deleteAssignment(parent, extra.id); + hw = (await accounts.myHomework(kid)).homework; + assert.equal(hw.length, 1); +}); + +test('homework goals: master, practice by difficulty, win by board, games; late after due', () => { + const s = P.createProgress(); + s.facts.you = { name: 'You', facts: {} }; + for (let n = 1; n <= 10; n++) { + s.facts.you.facts[P.factKey(7, n)] = { asked: 3, firstTry: 3, wrong: 0, timeouts: 0, said: [], times: [] }; + s.facts.you.facts[P.factKey(n, 7)] = { asked: 3, firstTry: 3, wrong: 0, timeouts: 0, said: [], times: [] }; + } + assert.equal(P.goalProgress(s, { type: 'master', table: 7 }).done, true); + assert.deepEqual(P.goalProgress(s, { type: 'master', table: 8 }), { have: 2, need: 19, done: false }); // 8 × 7 and 7 × 8 + P.noteActivity(s, { kind: 'practice', finished: true, difficulty: 'medium' }, 100); + P.noteActivity(s, { kind: 'practice', finished: false, difficulty: 'medium' }, 200); + P.noteActivity(s, { kind: 'game', won: true, size: 8, difficulty: 'easy' }, 300); + assert.equal(P.goalProgress(s, { type: 'practice', rounds: 1, difficulty: 'medium' }).done, true); + assert.equal(P.goalProgress(s, { type: 'practice', rounds: 1, difficulty: 'hard' }).done, false); + assert.equal(P.goalProgress(s, { type: 'practice', rounds: 1, difficulty: 'medium' }, 150).done, false); // set after it + assert.equal(P.goalProgress(s, { type: 'win', size: 8, difficulty: 'any' }).done, true); + assert.equal(P.goalProgress(s, { type: 'win', size: 10, difficulty: 'any' }).done, false); + assert.equal(P.goalProgress(s, { type: 'games', games: 2 }).have, 1); + const late = P.homeworkStatus(s, { goal: { type: 'games', games: 2 }, due: '2026-09-20', createdAt: 0 }, '2026-09-23'); + assert.equal(late.status, 'late'); + assert.equal(late.label, 'Finish 2 games'); + assert.equal(P.cleanGoal({ type: 'win', size: 99 }), null); +}); + +test('scheduled tournaments open on time; students of the class (or school) see them', async () => { + const { accounts, signIn, tick, now } = setup(); + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + const c = await accounts.createClass(teacher, { name: 'Room 12' }); + const kid = await signIn({ firstName: 'Maya', role: 'student', email: 'maya@iusd.org' }); + const other = await signIn({ firstName: 'Leo', role: 'student', email: 'leo@iusd.org' }); + await accounts.joinClass(kid, { code: c.code }); + await assert.rejects(accounts.createSchedule(teacher, { classId: c.id, startsAt: now() - 3600e3 }), /hasn’t passed/); + const { schedule } = await accounts.createSchedule(teacher, { classId: c.id, startsAt: now() + 60e3, settings: { format: 'swiss', size: 8 } }); + await accounts.createSchedule(teacher, { scope: 'school', title: 'Cadence Park Cup', startsAt: now() + 120e3 }); + let made = 0; + const createRoom = (settings) => { + assert.equal(settings.type, 'tournament'); + made++; + return { code: `ROOM${made}`, teacherKey: `key${made}` }; + }; + assert.deepEqual((await accounts.openDue(createRoom)), []); + await assert.rejects(accounts.hostSchedule(teacher, schedule.id), /hasn’t started/); + tick(61e3); + assert.equal((await accounts.openDue(createRoom)).length, 1); + assert.deepEqual((await accounts.hostSchedule(teacher, schedule.id)), { code: 'ROOM1', teacherKey: 'key1' }); + assert.deepEqual((await accounts.live(kid)).live.map((l) => l.code), ['ROOM1']); + assert.deepEqual((await accounts.live(other)).live, []); // not in the class + tick(60e3); + await accounts.openDue(createRoom); + assert.deepEqual((await accounts.live(other)).live.map((l) => l.title), ['Cadence Park Cup']); // the whole school + assert.equal((await accounts.openDue(createRoom)).length, 0); // each opens once +}); + +test('audit log: account changes are logged by id, never by name or code', async () => { + const lines = []; + const store = openMemoryStore(); + const auth = Auth.devProvider({ secret: 'test-secret' }); + const accounts = createAccounts({ store, auth, log: createLogger({ write: (l) => lines.push(JSON.parse(l)) }) }); + const signIn = async (person) => accounts.authenticate((await accounts.devSignIn(person)).token); + const teacher = await signIn({ role: 'teacher', firstName: 'Ms', lastInitial: 'R', schoolId: 'cadence-park' }); + const kid = await signIn({ role: 'student', firstName: 'Maya', lastInitial: 'K', email: 'maya@iusd.org' }); + const c = await accounts.createClass(teacher, { name: 'Room 12' }); + await accounts.joinClass(kid, { code: c.code }); + await accounts.removeStudent(teacher, c.id, kid.id); + assert.deepEqual( + lines.map((l) => [l['event.name'], l['user.role']]), + [['class.created', 'teacher'], ['class.joined', 'student'], ['class.student_removed', 'teacher']] + ); + assert.equal(lines[2]['student.id'], kid.id); + const text = JSON.stringify(lines); + assert.ok(!text.includes('Maya') && !text.includes('Room 12') && !text.includes(c.code)); +}); + +test('sign-in: the account is written when the token changes, otherwise once a minute', async () => { + const { store, signIn, tick } = setup(); + const writes = []; + const upsert = store.upsertUser; + store.upsertUser = (u, at) => (writes.push(at), upsert(u, at)); + const a = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + tick(30 * 1000); + const b = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + assert.equal(b.id, a.id); + assert.equal(writes.length, 1, 'no write within the minute'); + tick(31 * 1000); + await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + assert.equal(writes.length, 2); + assert.equal((await store.user(a.id)).lastSeen, writes[1]); +}); + +// ---------------------------------------------------------------- roles and invites + +// A Keycloak-style sign-in (no role in the token; admin only from the realm role) +function keycloakSetup() { + const base = setup(); + const kcAuth = { + name: 'keycloak', + verify: async (token) => JSON.parse(token), + }; + const accounts = createAccounts({ store: base.store, auth: kcAuth, now: base.now }); + let n = 0; + // (Google sign-in: verified addresses unless emailVerified says otherwise) + const signIn = (person = {}) => accounts.authenticate(JSON.stringify({ sub: person.sub || `kc:${n++}`, given_name: person.firstName || 'Sam', family_name: 'X', email: person.email || 'sam@gmail.com', email_verified: person.emailVerified ?? true, roles: person.roles || [] })); + return { ...base, accounts, signIn }; +} + +test('roles: anyone who signs in is a student; the token only makes admins', async () => { + const { signIn, store } = keycloakSetup(); + const a = await signIn({ sub: 'kc:a', roles: ['teacher', 'parent'] }); + assert.deepEqual([a.role, a.isAdmin], ['student', false]); + const admin = await signIn({ sub: 'kc:boss', roles: ['admin'] }); + assert.deepEqual([admin.role, admin.isAdmin], ['student', true]); + assert.ok(!('isAdmin' in (await store.user(admin.id))), 'admin is never stored'); + await store.setRole(a.id, 'teacher'); + assert.equal((await signIn({ sub: 'kc:a', roles: [] })).role, 'teacher', 'the role Blockout keeps wins over the token'); +}); + +test('self-registered: an unverified school address gets no school, so no school class', async () => { + const { accounts, signIn } = keycloakSetup(); + const teacher = await signIn({ sub: 'kc:t', email: 'ms.r@iusd.org' }); + await accounts.setUserRole(await signIn({ sub: 'kc:boss', roles: ['admin'] }), teacher.id, { role: 'teacher' }); + const c = await accounts.createClass(await signIn({ sub: 'kc:t', email: 'ms.r@iusd.org' }), { name: 'Room 12' }); + const google = await signIn({ sub: 'kc:maya', email: 'maya@iusd.org' }); + const pretender = await signIn({ sub: 'kc:pretend', email: 'maya2@iusd.org', emailVerified: false }); + assert.deepEqual([google.schoolId, pretender.schoolId, pretender.domain], ['cadence-park', null, null]); + assert.equal((await accounts.joinClass(google, { code: c.code })).class.name, 'Room 12'); + await assert.rejects(accounts.joinClass(pretender, { code: c.code }), /@iusd\.org/); +}); + +test('invites: an admin makes a teacher, who invites a colleague; codes work once and expire', async () => { + const { accounts, signIn, tick } = keycloakSetup(); + const admin = await signIn({ sub: 'kc:boss', roles: ['admin'] }); + const first = await signIn({ sub: 'kc:t1', firstName: 'Ana' }); + const { code, expiresAt } = await accounts.teacherInvite(admin, { schoolId: 'cadence-park' }); + assert.match(code, /^[A-Z2-9]{8}$/); + const after = await accounts.redeemInvite(first, { code: code.toLowerCase() }); + assert.equal(after.user.role, 'teacher'); + await assert.rejects(accounts.redeemInvite(await signIn({ sub: 'kc:x' }), { code }), /doesn’t work/, 'used once'); + // the new teacher brings in a colleague + const teacher = { ...(await signIn({ sub: 'kc:t1' })) }; + const theirs = await accounts.teacherInvite(teacher, {}); + const colleague = await signIn({ sub: 'kc:t2', firstName: 'Bo' }); + assert.equal((await accounts.redeemInvite(colleague, { code: theirs.code })).user.role, 'teacher'); + assert.equal((await signIn({ sub: 'kc:t2' })).invitedBy, teacher.id); + assert.ok((await accounts.createClass(await signIn({ sub: 'kc:t2' }), { name: 'Room 9' })).code); + // codes expire + const late = await accounts.teacherInvite(admin, {}); + tick(expiresAt); + await assert.rejects(accounts.redeemInvite(await signIn({ sub: 'kc:y' }), { code: late.code }), /doesn’t work/); + // students can't make teacher codes + await assert.rejects(accounts.teacherInvite(await signIn({ sub: 'kc:z' }), {}), /Only teachers and admins/); +}); + +test('invites: a teacher has a limit on open teacher codes, and can take them back', async () => { + const { accounts, signIn, store } = keycloakSetup(); + const t = await signIn({ sub: 'kc:t' }); + await store.setRole(t.id, 'teacher'); + const teacher = await signIn({ sub: 'kc:t' }); + const codes = []; + for (let i = 0; i < 10; i++) codes.push((await accounts.teacherInvite(teacher, {})).code); + await assert.rejects(accounts.teacherInvite(teacher, {}), /lots of teacher codes/); + const { invites } = await accounts.myTeacherInvites(teacher); + assert.equal(invites.length, 10); + const listed = JSON.stringify(invites); + assert.ok(codes.every((c) => !listed.includes(c)), 'the codes themselves are never shown again'); + await accounts.deleteMyInvite(teacher, invites[0].id); + assert.ok(await accounts.teacherInvite(teacher, {})); + await assert.rejects(accounts.deleteMyInvite(await signIn({ sub: 'kc:other' }), invites[1].id), /isn’t one of your codes/); +}); + +test('invites: a child (or their teacher) makes a grown-up code; the grown-up becomes a parent and is linked', async () => { + const { accounts, signIn, store } = keycloakSetup(); + const t = await signIn({ sub: 'kc:t', email: 'r@iusd.org' }); + await store.setRole(t.id, 'teacher'); + const teacher = await signIn({ sub: 'kc:t', email: 'r@iusd.org' }); + const c = await accounts.createClass(teacher, { name: 'Room 12' }); + const kid = await signIn({ sub: 'kc:kid', firstName: 'Maya', email: 'maya@iusd.org' }); + await accounts.joinClass(kid, { code: c.code }); + // the child's own code + const mine = await accounts.parentInvite(kid); + await assert.rejects(accounts.redeemInvite(kid, { code: mine.code }), /give it to them/); + const mom = await signIn({ sub: 'kc:mom', firstName: 'Ana' }); + const linked = await accounts.redeemInvite(mom, { code: mine.code }); + assert.deepEqual([linked.user.role, linked.child.name], ['parent', 'Maya X.']); + // the teacher's code for the same child, for a second grown-up who is a parent already + const fromTeacher = await accounts.parentInvite(teacher, kid.id); + const dad = await signIn({ sub: 'kc:dad', firstName: 'Leo' }); + await store.setRole(dad.id, 'parent'); + await accounts.linkChild(await signIn({ sub: 'kc:dad', firstName: 'Leo' }), { code: fromTeacher.code }); + assert.deepEqual((await accounts.studentParents(teacher, kid.id)).parents.map((p) => p.name).sort(), ['Ana X.', 'Leo X.']); + // a new code replaces the maker's last unused one + const one = await accounts.parentInvite(teacher, kid.id); + await accounts.parentInvite(teacher, kid.id); + await assert.rejects(accounts.redeemInvite(await signIn({ sub: 'kc:gran' }), { code: one.code }), /doesn’t work/); + // the teacher removes a wrong grown-up + await accounts.removeStudentParent(teacher, kid.id, (await signIn({ sub: 'kc:dad', firstName: 'Leo' })).id); + assert.deepEqual((await accounts.studentParents(teacher, kid.id)).parents.map((p) => p.name), ['Ana X.']); + // only for students they can see + const stranger = await signIn({ sub: 'kc:s2' }); + await assert.rejects(accounts.parentInvite(teacher, stranger.id), /can’t make a code/); +}); + +test('invites: accounts already in use keep their role', async () => { + const { accounts, signIn, store } = keycloakSetup(); + const admin = await signIn({ sub: 'kc:boss', roles: ['admin'] }); + const t = await signIn({ sub: 'kc:t', email: 'r@iusd.org' }); + await store.setRole(t.id, 'teacher'); + const c = await accounts.createClass(await signIn({ sub: 'kc:t', email: 'r@iusd.org' }), { name: 'Room 12' }); + const kid = await signIn({ sub: 'kc:kid', email: 'k@iusd.org' }); + await accounts.joinClass(kid, { code: c.code }); + const teach = await accounts.teacherInvite(admin, {}); + await assert.rejects(accounts.redeemInvite(kid, { code: teach.code }), /another account to teach/); + const other = await signIn({ sub: 'kc:other' }); + const grownUp = await accounts.parentInvite(other); + await assert.rejects(accounts.redeemInvite(kid, { code: grownUp.code }), /your own account/); + await assert.rejects(accounts.redeemInvite(await signIn({ sub: 'kc:t', email: 'r@iusd.org' }), { code: grownUp.code }), /separate account/); + // a failed try doesn't use the code up + assert.equal((await accounts.redeemInvite(await signIn({ sub: 'kc:fresh' }), { code: teach.code })).user.role, 'teacher'); +}); + +test('admins: find accounts, change roles (not a teacher with classes), and delete accounts', async () => { + const { accounts, signIn, store } = keycloakSetup(); + const admin = await signIn({ sub: 'kc:boss', roles: ['admin'], firstName: 'Root' }); + const maya = await signIn({ sub: 'kc:maya', firstName: 'Maya', email: 'maya@iusd.org' }); + await signIn({ sub: 'kc:max', firstName: 'Max' }); + const found = await accounts.listUsers(admin, { q: 'ma' }); + assert.deepEqual(found.users.map((u) => u.name).sort(), ['Max X.', 'Maya X.']); + assert.deepEqual(Object.keys(found.users[0]).sort(), ['createdAt', 'domain', 'id', 'invitedBy', 'lastSeen', 'name', 'role', 'schoolId']); + await assert.rejects(accounts.listUsers(maya, {}), /Only admins/); + // promote, then can't demote while teaching + await accounts.setUserRole(admin, maya.id, { role: 'teacher' }); + const c = await accounts.createClass(await signIn({ sub: 'kc:maya', firstName: 'Maya', email: 'maya@iusd.org' }), { name: 'Room 1' }); + await assert.rejects(accounts.setUserRole(admin, maya.id, { role: 'parent' }), /still has classes/); + await assert.rejects(accounts.deleteMe(await signIn({ sub: 'kc:maya', firstName: 'Maya', email: 'maya@iusd.org' })), /Delete your classes/); + // schools + const { school } = await accounts.addSchool(admin, { name: 'Oak Hill', domain: 'Oak.edu' }); + assert.deepEqual([school.id, school.domain], ['oak-hill', 'oak.edu']); + await assert.rejects(accounts.addSchool(admin, { name: 'Again', domain: 'oak.edu' }), /already has that domain/); + // delete: everything goes + await accounts.adminDeleteUser(admin, maya.id); + assert.equal(await store.user(maya.id), null); + assert.equal(await store.classById(c.id), null); +}); + +test('deleting: a grown-up removes their child\'s account; teachers and grown-ups remove their own', async () => { + const { accounts, signIn, store } = keycloakSetup(); + const kid = await signIn({ sub: 'kc:kid' }); + const mom = await signIn({ sub: 'kc:mom' }); + await accounts.redeemInvite(mom, { code: (await accounts.parentInvite(kid)).code }); + const parent = await signIn({ sub: 'kc:mom' }); + await assert.rejects(accounts.deleteStudent(await signIn({ sub: 'kc:stranger' }), kid.id), /Only parents and admins/); + await accounts.deleteStudent(parent, kid.id); + assert.equal(await store.user(kid.id), null); + await assert.rejects(accounts.deleteMe(await signIn({ sub: 'kc:someone' })), /Only teachers and parents/, 'students ask a grown-up'); + await accounts.deleteMe(parent); + assert.equal(await store.user(mom.id), null); +}); + +test('your data: download everything kept about you (or your child); unused accounts expire', async () => { + const { accounts, signIn, store, tick } = keycloakSetup(); + const kid = await signIn({ sub: 'kc:kid', firstName: 'Maya', email: 'maya@iusd.org' }); + await accounts.putProgress(kid, { coins: 7 }); + const mom = await signIn({ sub: 'kc:mom', firstName: 'Ana' }); + await accounts.redeemInvite(mom, { code: (await accounts.parentInvite(kid)).code }); + const mine = await accounts.exportMe(kid); + assert.deepEqual([mine.account.firstName, mine.account.emailDomain, mine.progress.data.coins, mine.grownUps.map((g) => g.name)], ['Maya', 'iusd.org', 7, ['Ana X.']]); + assert.ok(!JSON.stringify(mine).includes('maya@'), 'never an email address (none is kept)'); + const parent = await signIn({ sub: 'kc:mom', firstName: 'Ana' }); + assert.equal((await accounts.exportStudent(parent, kid.id)).account.id, kid.id); + await assert.rejects(accounts.exportStudent(await signIn({ sub: 'kc:x' }), kid.id), /Only parents and admins|can’t see/); + // a year and a month without signing in: gone + assert.equal(await accounts.deleteStale(395), 0); + tick(396 * 24 * 3600 * 1000); + await signIn({ sub: 'kc:mom', firstName: 'Ana' }); // (the grown-up came back; the child didn't) + assert.equal(await accounts.deleteStale(395), 2, 'the child, and the stranger who tried to look'); + assert.equal(await store.user(kid.id), null); + assert.ok(await store.user(mom.id)); + assert.equal(await accounts.deleteStale(0), 0, '0: keep forever'); +}); diff --git a/apps/server/test/config.test.js b/apps/server/test/config.test.js new file mode 100644 index 0000000..f0afa02 --- /dev/null +++ b/apps/server/test/config.test.js @@ -0,0 +1,41 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { loadConfig, ConfigError } from '../src/config.js'; + +const KEYCLOAK = { BLOCKOUT_AUTH: 'keycloak', KEYCLOAK_ISSUER: 'https://id.example.org/realms/blockout', KEYCLOAK_CLIENT_ID: 'blockout' }; + +test('config: development defaults', () => { + const c = loadConfig({}, []); + assert.equal(c.production, false); + assert.equal(c.port, 8080); + assert.equal(c.publicUrl, ''); + assert.equal(c.auth.kind, 'dev'); + assert.equal(c.trustProxy, 0); +}); + +test('config: port from the command line beats PORT; public URL gets one trailing slash', () => { + const c = loadConfig({ PORT: '9000', PUBLIC_URL: 'https://x.trycloudflare.com///' }, ['8135']); + assert.equal(c.port, 8135); + assert.equal(c.publicUrl, 'https://x.trycloudflare.com/'); + assert.equal(c.https, true); + assert.equal(loadConfig({}, ['--public-url', 'http://10.0.0.5:8080']).publicUrl, 'http://10.0.0.5:8080/'); +}); + +test('config: production refuses dev sign-in and plain HTTP, listing every problem', () => { + assert.throws( + () => loadConfig({ NODE_ENV: 'production' }, []), + (e) => e instanceof ConfigError && e.problems.length === 4 && /dev sign-in/.test(e.message) && /https/.test(e.message) && /MongoDB/.test(e.message) && /REDIS/.test(e.message) + ); + const c = loadConfig({ NODE_ENV: 'production', PUBLIC_URL: 'https://blockout.example.org', TRUST_PROXY: '1', BLOCKOUT_MONGO_URL: 'mongodb://db/blockout', BLOCKOUT_REDIS_URL: 'redis://cache:6379', ...KEYCLOAK }, []); + assert.equal(c.production, true); + assert.equal(c.auth.issuer, KEYCLOAK.KEYCLOAK_ISSUER); + assert.equal(c.trustProxy, 1); + assert.deepEqual(c.store, { kind: 'mongo', url: 'mongodb://db/blockout' }); + assert.deepEqual(c.rooms, { kind: 'redis', url: 'redis://cache:6379', prefix: '' }); +}); + +test('config: bad values are caught at start-up', () => { + for (const env of [{ BLOCKOUT_STORE: 'sqlite' }, { BLOCKOUT_ROOMS: 'redis' }, { BLOCKOUT_ROOMS: 'disk' }, { BLOCKOUT_STORE: 'mongo' }, { BLOCKOUT_STORE: 'excel' }, { BLOCKOUT_AUTH: 'magic' }, { BLOCKOUT_AUTH: 'keycloak' }, { TRUST_PROXY: 'yes' }, { PUBLIC_URL: 'blockout.org' }, { PORT: '99999' }]) { + assert.throws(() => loadConfig(env, []), ConfigError, JSON.stringify(env)); + } +}); diff --git a/apps/server/test/drain.test.js b/apps/server/test/drain.test.js new file mode 100644 index 0000000..d8cfc25 --- /dev/null +++ b/apps/server/test/drain.test.js @@ -0,0 +1,32 @@ +// Shutting down (SIGTERM): not ready any more, streams and held polls are +// answered so browsers move on, and the server stops. (Its own file: it closes the server.) +process.env.BLOCKOUT_STORE = 'memory'; +process.env.BLOCKOUT_ROOMS = 'memory'; // (hermetic: no shared rate limits or rooms) +process.env.LOG_LEVEL = 'silent'; +process.env.BLOCKOUT_AUTH_SECRET = 'test-secret'; +import test from 'node:test'; +import assert from 'node:assert'; +import http from 'node:http'; + +const { server, drain } = await import('../src/index.js'); + +test('drain: streams end, held polls are answered, ready turns 503, the server closes', async () => { + await new Promise((resolve) => server.listen(0, resolve)); + const base = `http://localhost:${server.address().port}`; + const token = (await (await fetch(`${base}/api/auth/dev`, { method: 'POST', body: JSON.stringify({ firstName: 'Ms', lastInitial: 'D', role: 'teacher', schoolId: 'cadence-park' }) })).json()).token; + const room = await (await fetch(`${base}/api/rooms`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: '{}' })).json(); + + const agent = new http.Agent({ maxSockets: Infinity }); + const stream = await new Promise((resolve) => http.get(`${base}/api/rooms/${room.code}/events?teacher=${room.teacherKey}`, { agent }, resolve)); + const ended = new Promise((resolve) => stream.on('end', resolve).resume()); + const view = await (await fetch(`${base}/api/rooms/${room.code}/poll?teacher=${room.teacherKey}&v=-1`)).json(); + const held = fetch(`${base}/api/rooms/${room.code}/poll?teacher=${room.teacherKey}&v=${view.v}`).then((res) => res.json()); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const done = drain({ timeoutMs: 2000 }); + await ended; + assert.equal((await held).v, view.v, 'the held poll is answered right away'); + await done; + assert.equal(server.listening, false); + agent.destroy(); +}); diff --git a/apps/server/test/live.test.js b/apps/server/test/live.test.js new file mode 100644 index 0000000..97a3610 --- /dev/null +++ b/apps/server/test/live.test.js @@ -0,0 +1,312 @@ +// Live classes across servers (live.js): two runtimes sharing one backend act +// like two servers sharing one Redis. Timings are shrunk to milliseconds. The +// whole suite runs on the memory backend, and on Redis (Valkey) when +// BLOCKOUT_REDIS_URL is set, each server with connections of its own. +import test from 'node:test'; +import assert from 'node:assert'; +import { EventEmitter } from 'node:events'; +import * as Core from '@blockout/engine'; +import * as Rooms from '../src/rooms.js'; +import { createLive } from '../src/live.js'; +import { createMemoryBackend } from '../src/roombackend/memory.js'; +import { createRedisBackend } from '../src/roombackend/redis.js'; + +const FAST = { leaseMs: 120, renewMs: 40, sweepMs: 40, presenceMs: 150, heartbeatMs: 40, pollHoldMs: 300, coalesceMs: 5, liveMs: 5, autoNextMs: 30, roundPauseMs: 30, cpuRollMs: 10, cpuPlaceMs: 10 }; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); +async function until(check, ms = 2000) { + for (let t = 0; t < ms; t += 10) { + if (await check()) return true; + await wait(10); + } + return false; +} + +// Timers that stop firing when the "server" crashes (no clean hand-over) +function crashable() { + let dead = false; + const made = new Set(); + const track = (t) => (made.add(t), t); + const guard = (fn) => (...a) => !dead && fn(...a); + return { + timers: { + setTimeout: (fn, ms) => track(setTimeout(guard(fn), ms)), + clearTimeout, + setInterval: (fn, ms) => track(setInterval(guard(fn), ms)), + clearInterval, + }, + crash: () => { + dead = true; + for (const t of made) clearTimeout(t); + }, + }; +} + +// An HTTP response as an event stream sees it +function fakeRes() { + const res = new EventEmitter(); + res.chunks = []; + res.ended = false; + res.write = (c) => res.chunks.push(c); + res.end = () => (res.ended = true); + res.events = () => + res.chunks + .filter((c) => c.startsWith('event:')) + .map((c) => ({ name: c.split('\n')[0].slice(7), data: JSON.parse(c.split('\n')[1].slice(6)) })); + res.views = () => res.events().filter((e) => e.name === 'view').map((e) => e.data); + return res; +} + +// Every server a test starts is stopped after it, even when the test fails +const running = []; +test.afterEach(() => Promise.all(running.splice(0).map((stop) => stop()))); + +// Waits until the room stops changing (who's-here updates settle) +async function settled(live, code) { + let v = -1; + for (let i = 0; i < 100; i++) { + const now = (await live.getRoom(code)).version; + if (now === v) return v; + v = now; + await wait(40); + } + return v; +} + +// n backends for n servers: one shared memory backend, or n Redis connections +// under a prefix of the test's own +async function serversWith(makeBackends, n = 2) { + const backends = await makeBackends(n); + const all = backends.map((backend, i) => { + const c = crashable(); + const live = createLive({ backend, replicaId: `s${i}`, timers: c.timers, ...FAST }); + live.start(); + return Object.assign(live, { crash: c.crash }); + }); + const stop = () => { + all.forEach((l) => l.crash()); + return Promise.all(backends.map((b) => b.quit())); + }; + running.push(stop); + return { backend: backends[0], all, stop }; +} + +const placeFor = (room, p) => { + const [move] = Core.validPlacements(p.board, ...room.roll); + return move ? { round: room.round, kind: 'placed', rect: { x: move.x, y: move.y, w: move.w, h: move.h }, firstTry: true, ms: 3000 } : { round: room.round, kind: 'timeout' }; +}; + +// (small helpers over rooms.js) +const rJoin = (r, name) => { + const p = Rooms.join(r, name); + return { id: p.id, key: p.key }; +}; +const rStart = (r) => Rooms.start(r); +const rSubmit = (r, who) => { + const p = Rooms.findPlayer(r, who.id, who.key); + Rooms.submit(r, p, placeFor(r, p)); +}; + +function suite(name, backends) { + const servers = (n) => serversWith(backends, n); + test(`live (${name}): answers sent to two servers at once are all counted`, async () => { + const { all, stop } = await servers(); + const [a, b] = all; + const room = await a.createRoom({ type: 'class' }); + const players = []; + for (let i = 0; i < 30; i++) players.push(await (i % 2 ? b : a).withRoom(room.code, (r) => rJoin(r, `Kid${i}`))); + await a.withRoom(room.code, (r) => rStart(r)); + const before = (await a.getRoom(room.code)).version; + await Promise.all(players.map((p, i) => (i % 2 ? b : a).withRoom(room.code, (r) => rSubmit(r, p)))); + const after = await b.getRoom(room.code); + assert.ok(after.version >= before + 30); // (plus any "who's here" updates) + assert.ok(after.players.every((p) => p.done), 'every answer landed'); + stop(); + }); + + test(`live (${name}): a teacher on one server sees what students do on the other`, async () => { + const { all, stop } = await servers(); + const [a, b] = all; + const room = await a.createRoom({ type: 'class' }); + const teacher = fakeRes(); + await a.openStream(room.code, 'teacher', null, teacher); + await b.withRoom(room.code, (r) => rJoin(r, 'Maya')); + assert.ok(await until(() => teacher.views().some((v) => JSON.stringify(v).includes('Maya')))); + // a student's stream on b, and a long poll on a, both hear about the next change + const who = await b.withRoom(room.code, (r) => rJoin(r, 'Ben')); + const student = fakeRes(); + await b.openStream(room.code, 'student', who.id, student); + const v = (await a.getRoom(room.code)).version; + let polled = null; + await a.openPoll(room.code, 'student', who.id, v, (body) => (polled = body), () => {}); + await a.withRoom(room.code, (r) => rStart(r)); + assert.ok(await until(() => polled && polled.v > v), 'the held poll was answered'); + assert.ok(await until(() => student.views().some((x) => x.state === 'playing' || JSON.stringify(x).includes('playing')))); + stop(); + }); + + test(`live (${name}): a held poll waits for a newer version, not news of one it has`, async () => { + const { backend, all } = await servers(); + const [a] = all; + const room = await a.createRoom({ type: 'class' }); + const who = await a.withRoom(room.code, (r) => rJoin(r, 'Ben')); + await a.openPoll(room.code, 'student', who.id, -1, () => {}, () => {}); // (Ben is here: who's-here settles) + const v = await settled(a, room.code); + let polled = null; + await a.openPoll(room.code, 'student', who.id, v, (body) => (polled = body), () => {}); + // a late message about a change the poll has already seen + await backend.publish(room.code, { t: 'v', v }); + await wait(FAST.coalesceMs * 10); + assert.strictEqual(polled, null, 'still held'); + await a.withRoom(room.code, (r) => rStart(r)); + assert.ok(await until(() => polled && polled.v > v), 'answered with the new version'); + }); + + test(`live (${name}): when the owning server dies, another takes over its timers`, async () => { + const { all, stop } = await servers(); + const [a, b] = all; + const room = await a.createRoom({ type: 'class' }); + assert.ok(a.owns(room.code)); + const kid = await a.withRoom(room.code, (r) => rJoin(r, 'Ann')); + await a.withRoom(room.code, (r) => { + r.autoNext = true; + rStart(r); + }); + a.crash(); // no hand-over: its lease just runs out + await b.openPoll(room.code, 'student', kid.id, -1, () => {}, () => {}); // (Ann is here: she polls) + await b.withRoom(room.code, (r) => rSubmit(r, kid)); + // everyone is done: the new owner starts round 2 by itself + assert.ok(await until(async () => (await b.getRoom(room.code)).round === 2), 'round 2 started'); + assert.ok(b.owns(room.code)); + stop(); + }); + + test(`live (${name}): a timer that fires on two servers moves the room on only once`, async () => { + const { all, stop } = await servers(3); + const room = await all[0].createRoom({ type: 'class' }); + const kid = await all[0].withRoom(room.code, (r) => rJoin(r, 'Ann')); + await all[0].withRoom(room.code, (r) => { + r.autoNext = true; + r.settings.rounds = 10; + rStart(r); + }); + await all[1].openPoll(room.code, 'student', kid.id, -1, () => {}, () => {}); // (Ann is here: she polls) + await all[1].withRoom(room.code, (r) => rSubmit(r, kid)); + await wait(FAST.autoNextMs * 6); + assert.equal((await all[2].getRoom(room.code)).round, 2, 'not 3 or 4'); + stop(); + }); + + test(`live (${name}): pairs against the CPU play on by themselves`, async () => { + const { all, stop } = await servers(); + const room = await all[0].createRoom({ type: 'pairs' }); + await all[1].withRoom(room.code, (r) => rJoin(r, 'Ann')); + await all[1].withRoom(room.code, (r) => { + Rooms.setPairOptions(r, { odd: 'cpu' }); + rStart(r); + }); + // the CPU (and nobody else) takes turns until the human has to move + const cpuMoved = await until(async () => { + const r = await all[1].getRoom(room.code); + return r.matches[0].turn > 1 || r.matches[0].roll; + }); + assert.ok(cpuMoved || (await all[1].getRoom(room.code)).matches[0].ids[0] !== 'cpu'); + stop(); + }); + + test(`live (${name}): partners see live moves without the room changing version`, async () => { + const { all, stop } = await servers(); + const [a, b] = all; + const room = await a.createRoom({ type: 'pairs' }); + const ann = await a.withRoom(room.code, (r) => rJoin(r, 'Ann')); + const bo = await a.withRoom(room.code, (r) => rJoin(r, 'Bo')); + const cy = await a.withRoom(room.code, (r) => rJoin(r, 'Cy')); + const di = await a.withRoom(room.code, (r) => rJoin(r, 'Di')); + await a.withRoom(room.code, (r) => rStart(r)); + const r0 = await a.getRoom(room.code); + const m = r0.matches[0]; + const all4 = [ann, bo, cy, di]; + const mover = all4.find((p) => p.id === m.ids[m.current]); + const partner = all4.find((p) => p.id === m.ids.find((id) => id !== mover.id)); + const other = all4.find((p) => !m.ids.includes(p.id)); + await a.withRoom(room.code, (r) => Rooms.pairRoll(r, Rooms.findPlayer(r, mover.id, mover.key))); + const partnerRes = fakeRes(); + const otherRes = fakeRes(); + await b.openStream(room.code, 'student', partner.id, partnerRes); + await b.openStream(room.code, 'student', other.id, otherRes); + const v = await settled(a, room.code); + const otherViews = otherRes.views().length; + await a.setLive(room.code, mover.id, mover.key, { phase: 'place', rect: { x: 0, y: 0, w: 1, h: 1 } }); + assert.ok(await until(() => partnerRes.views().some((x) => JSON.stringify(x).includes('"rect":{"x":0,"y":0,"w":1,"h":1}')))); + await wait(30); + assert.equal((await a.getRoom(room.code)).version, v, 'no version bump'); + assert.equal(otherRes.views().length, otherViews, 'only the match hears it'); + stop(); + }); + + test(`live (${name}): a student whose server died stops counting as here`, async () => { + const { all, stop } = await servers(); + const [a, b] = all; + const room = await b.createRoom({ type: 'class' }); + const kid = await b.withRoom(room.code, (r) => rJoin(r, 'Ann')); + await a.openStream(room.code, 'student', kid.id, fakeRes()); + assert.ok(await until(async () => (await b.getRoom(room.code)).players[0].connections === 1)); + a.crash(); + assert.ok(await until(async () => (await b.getRoom(room.code)).players[0].connections === 0)); + stop(); + }); + + test(`live (${name}): closing tells every server, and a closed code is not handed out again for a while`, async () => { + const { backend, all, stop } = await servers(); + const [a, b] = all; + const room = await a.createRoom({ type: 'class' }); + const res = fakeRes(); + await b.openStream(room.code, 'teacher', null, res); + await a.closeRoom(room.code); + assert.ok(await until(() => res.ended && res.events().some((e) => e.name === 'closed'))); + assert.equal(await b.isClosed(room.code), true); + await assert.rejects(b.getRoom(room.code), { code: 'room' }); + assert.equal(await backend.create(room.code, '{}', 1000), false); + stop(); + }); + + test(`live (${name}): one server at a time leads a job`, async () => { + const { all, stop } = await servers(3); + const results = await Promise.all(all.map((l) => l.lead('schedules', 1000))); + assert.deepEqual(results.filter(Boolean).length, 1); + stop(); + }); + + test(`live (${name}): shutting down sends streams on and hands rooms over`, async () => { + const { all, stop } = await servers(); + const [a, b] = all; + const room = await a.createRoom({ type: 'class' }); + const res = fakeRes(); + await a.openStream(room.code, 'teacher', null, res); + await a.drain(); + assert.ok(res.ended); + assert.ok(res.chunks.some((c) => /^retry: \d+/.test(c))); + assert.ok(await until(() => b.owns(room.code)), 'b took the room over'); + assert.equal((await b.getRoom(room.code)).code, room.code); + stop(); + }); + + test(`live (${name}): rooms are saved as JSON and survive a JSON round trip (the memory backend checks)`, async () => { + const { all, stop } = await servers(1); + const room = await all[0].createRoom({ type: 'tournament' }); + for (const n of ['A', 'B', 'C', 'D']) await all[0].withRoom(room.code, (r) => rJoin(r, n)); + await all[0].withRoom(room.code, (r) => rStart(r)); + const r = await all[0].getRoom(room.code); + assert.equal(r.state, 'playing'); + assert.ok(r.matches.length >= 1); + stop(); + }); +} + +suite('memory', async (n) => Array(n).fill(createMemoryBackend())); +const REDIS_URL = process.env.BLOCKOUT_REDIS_URL; +if (REDIS_URL) { + suite('redis', async (n) => { + const prefix = `test:${process.pid}:${Math.random().toString(36).slice(2)}:`; + return Promise.all(Array.from({ length: n }, () => createRedisBackend(REDIS_URL, { prefix }))); + }); +} diff --git a/apps/server/test/log.test.js b/apps/server/test/log.test.js new file mode 100644 index 0000000..4049919 --- /dev/null +++ b/apps/server/test/log.test.js @@ -0,0 +1,51 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { createLogger, exception, redact } from '../src/log.js'; + +const capture = (options) => { + const lines = []; + const log = createLogger({ write: (l) => lines.push(l), now: () => new Date('2026-10-15T12:00:00Z'), ...options }); + return { log, lines }; +}; + +test('log: one JSON object per line, with OpenTelemetry field names', () => { + const { log, lines } = capture({ version: '0.3.0' }); + log.info('class.created', { 'user.id': 'u1', 'class.id': 'c1' }); + assert.deepEqual(JSON.parse(lines[0]), { + timestamp: '2026-10-15T12:00:00.000Z', + severity_text: 'INFO', + severity_number: 9, + 'event.name': 'class.created', + 'service.name': 'blockout', + 'service.version': '0.3.0', + 'user.id': 'u1', + 'class.id': 'c1', + }); +}); + +test('log: below the level is dropped; silent drops everything', () => { + const warn = capture({ level: 'warn' }); + warn.log.info('a'); + warn.log.error('b'); + assert.deepEqual(warn.lines.map((l) => JSON.parse(l)['event.name']), ['b']); + const silent = capture({ level: 'silent' }); + silent.log.error('c'); + assert.equal(silent.lines.length, 0); +}); + +test('log: secrets and names are redacted, ids and routes are kept', () => { + assert.deepEqual(redact({ 'user.id': 'u1', 'http.route': '/api/rooms/:code/join', token: 't', 'room.code': 'ABCD', firstName: 'Maya', 'teacher.key': 'k' }), { + 'user.id': 'u1', + 'http.route': '/api/rooms/:code/join', + token: '[redacted]', + 'room.code': '[redacted]', + firstName: '[redacted]', + 'teacher.key': '[redacted]', + }); +}); + +test('log: text format for a terminal; exceptions carry type and stack', () => { + const { log, lines } = capture({ format: 'text' }); + log.error('server.error', exception(new TypeError('boom'))); + assert.match(lines[0], /^ERROR server\.error exception\.type=TypeError exception\.message=boom\n.*TypeError: boom/s); +}); diff --git a/apps/server/test/metrics.test.js b/apps/server/test/metrics.test.js new file mode 100644 index 0000000..a378d43 --- /dev/null +++ b/apps/server/test/metrics.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { createMetrics } from '../src/metrics.js'; + +test('metrics: counters, histograms and gauges in Prometheus text format', () => { + const m = createMetrics(); + const requests = m.counter('http_requests_total', 'HTTP requests', ['route', 'status']); + requests.inc({ route: '/api/me', status: 200 }); + requests.inc({ route: '/api/me', status: 200 }); + requests.inc({ route: '/api/rooms/:code/join', status: 404 }); + const time = m.histogram('http_request_duration_seconds', 'Time to answer', ['route'], [0.1, 1]); + time.observe({ route: '/api/me' }, 0.05); + time.observe({ route: '/api/me' }, 0.5); + m.gauge('streams', 'Open event streams', () => 3); + m.gauge('rooms', 'Rooms', () => [{ labels: { owned: 'yes' }, value: 2 }], ['owned']); + const text = m.render(); + assert.match(text, /# TYPE blockout_http_requests_total counter/); + assert.match(text, /blockout_http_requests_total\{route="\/api\/me",status="200"\} 2/); + assert.match(text, /blockout_http_requests_total\{route="\/api\/rooms\/:code\/join",status="404"\} 1/); + assert.match(text, /blockout_http_request_duration_seconds_bucket\{route="\/api\/me",le="0.1"\} 1/); + assert.match(text, /blockout_http_request_duration_seconds_bucket\{route="\/api\/me",le="1"\} 2/); + assert.match(text, /blockout_http_request_duration_seconds_bucket\{route="\/api\/me",le="\+Inf"\} 2/); + assert.match(text, /blockout_http_request_duration_seconds_count\{route="\/api\/me"\} 2/); + assert.match(text, /blockout_streams 3/); + assert.match(text, /blockout_rooms\{owned="yes"\} 2/); + assert.ok(text.endsWith('\n')); +}); + +test('metrics: label values are escaped', () => { + const m = createMetrics(); + m.counter('x_total', 'x', ['a']).inc({ a: 'say "hi"\\\n' }); + assert.match(m.render(), /x_total\{a="say \\"hi\\"\\\\\\n"\} 1/); +}); diff --git a/test/moderation.test.js b/apps/server/test/moderation.test.js similarity index 95% rename from test/moderation.test.js rename to apps/server/test/moderation.test.js index c37d039..654bb97 100644 --- a/test/moderation.test.js +++ b/apps/server/test/moderation.test.js @@ -1,6 +1,6 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const M = require('../server/moderation.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as M from '../src/moderation.js'; const blocked = (name) => M.listedWord(name) !== null; diff --git a/apps/server/test/ratelimit.test.js b/apps/server/test/ratelimit.test.js new file mode 100644 index 0000000..91171da --- /dev/null +++ b/apps/server/test/ratelimit.test.js @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { createLimiter, clientIp } from '../src/ratelimit.js'; + +const setup = () => { + let t = 0; + const limiter = createLimiter({ now: () => t, rules: { r: { perMinute: 6, burst: 3 } } }); + return { limiter, tick: (ms) => (t += ms) }; +}; + +test('rate limit: a burst, then one token every 60/perMinute seconds', () => { + const { limiter, tick } = setup(); + for (let i = 0; i < 3; i++) assert.ok(limiter.take('r', 'a').ok); + const no = limiter.take('r', 'a'); + assert.equal(no.ok, false); + assert.equal(no.retryAfter, 10); + assert.ok(limiter.take('r', 'b').ok, 'each key has its own bucket'); + tick(10000); + assert.ok(limiter.take('r', 'a').ok); + assert.equal(limiter.take('r', 'a').ok, false); + tick(10 * 60000); + for (let i = 0; i < 3; i++) assert.ok(limiter.take('r', 'a').ok, 'refills only up to the burst'); + assert.equal(limiter.take('r', 'a').ok, false); +}); + +test('rate limit: check looks without taking; full buckets are pruned', () => { + const { limiter, tick } = setup(); + assert.ok(limiter.check('r', 'a').ok); + assert.ok(limiter.check('r', 'a').ok); + for (let i = 0; i < 3; i++) limiter.take('r', 'a'); + assert.equal(limiter.check('r', 'a').ok, false); + limiter.take('r', 'b'); + tick(60000); + limiter.prune(); + assert.equal(limiter.size(), 0); + assert.throws(() => limiter.take('nope', 'a')); +}); + +test('client address: the socket, or the one the nearest trusted proxy saw', () => { + const req = (xff) => ({ socket: { remoteAddress: '10.0.0.9' }, headers: xff ? { 'x-forwarded-for': xff } : {} }); + assert.equal(clientIp(req('1.2.3.4')), '10.0.0.9', 'no trusted proxies: X-Forwarded-For is ignored'); + assert.equal(clientIp(req('1.2.3.4'), 1), '1.2.3.4'); + assert.equal(clientIp(req('6.6.6.6, 1.2.3.4'), 1), '1.2.3.4', 'a made-up entry from the client is skipped'); + assert.equal(clientIp(req('6.6.6.6, 1.2.3.4, 10.1.1.1'), 2), '1.2.3.4'); + assert.equal(clientIp(req(''), 1), '10.0.0.9'); +}); + +// The Redis buckets answer like the memory ones (when Redis is here) +if (process.env.BLOCKOUT_REDIS_URL) { + const { createClient } = await import('redis'); + const { createRedisLimiter } = await import('../src/ratelimit.js'); + test('rate limit (redis): the same buckets, shared by every server', async () => { + const client = await createClient({ url: process.env.BLOCKOUT_REDIS_URL }).connect(); + let t = 1_000_000; + const prefix = `test:${process.pid}:${Math.random().toString(36).slice(2)}:`; + const rules = { r: { perMinute: 6, burst: 3 } }; + const [one, two] = [createRedisLimiter(client, { prefix, now: () => t, rules }), createRedisLimiter(client, { prefix, now: () => t, rules })]; + try { + assert.ok((await one.check('r', 'a')).ok); + for (let i = 0; i < 3; i++) assert.ok((await (i % 2 ? one : two).take('r', 'a')).ok); + assert.deepEqual(await one.take('r', 'a'), { ok: false, retryAfter: 10 }); + assert.equal((await two.check('r', 'a')).ok, false, 'the other server sees the same bucket'); + assert.ok((await two.take('r', 'b')).ok); + t += 10000; + assert.ok((await two.take('r', 'a')).ok); + assert.equal((await one.take('r', 'a')).ok, false); + } finally { + await client.close(); + } + }); +} diff --git a/test/classroom.test.js b/apps/server/test/rooms.test.js similarity index 75% rename from test/classroom.test.js rename to apps/server/test/rooms.test.js index 70969c6..1a7eeea 100644 --- a/test/classroom.test.js +++ b/apps/server/test/rooms.test.js @@ -1,7 +1,7 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const Rooms = require('../server/rooms.js'); -const Core = require('../js/core.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as Rooms from '../src/rooms.js'; +import * as Core from '@blockout/engine'; // A class with players who are all "connected". function classOf(names, settings = {}) { @@ -26,18 +26,18 @@ test('codes are four easy-to-read letters; settings are cleaned', () => { placeMode: 'choice', answerTime: 0, autoRoll: false, - fitRolls: 'end', cpuSpeed: 'normal', format: 'knockout', matchTurns: 0, swissRounds: 3, kothMatches: 10, }); - Rooms.setSettings(room, { placeMode: 'auto', answerTime: 20, autoRoll: true, fitRolls: 'always', cpuSpeed: 'fast' }); + Rooms.setSettings(room, { placeMode: 'auto', answerTime: 20, autoRoll: true, fitRolls: 'never', cpuSpeed: 'fast' }); + assert.equal(room.settings.fitRolls, undefined, 'gone: pairs only roll what fits'); assert.equal(room.settings.placeMode, 'auto'); assert.equal(room.settings.answerTime, 20); assert.equal(room.settings.autoRoll, true); - Rooms.setSettings(room, { answerTime: 7, placeMode: 'teleport' }); // not allowed: back to the defaults + Rooms.setSettings(room, { answerTime: 7, placeMode: 'click' }); // not allowed (any more): back to the defaults assert.equal(room.settings.answerTime, 0); assert.equal(room.settings.placeMode, 'choice'); assert.equal(room.settings.difficulty, 'hard'); // untouched @@ -382,3 +382,93 @@ test('tournaments: knocked-out students, byes and walkovers', () => { const winnerView = Rooms.studentView(room, room.players.find((p) => p.id === final.winner)).tournament; assert.equal(winnerView.status, 'champion'); }); + +// ---------------------------------------------------------------- looks and live play + +test('looks: a Wardrobe avatar and title travel with a player; anything else is dropped', () => { + const room = Rooms.createRoom({ type: 'class' }); + const ann = Rooms.join(room, 'Ann', { avatar: 'fox', title: 'none' }); + const bo = Rooms.join(room, 'Bo', { avatar: '<img src=x>', title: 'hacker' }); + assert.deepEqual(ann.look, { avatar: '🦊', title: '' }); + assert.deepEqual(bo.look, { avatar: '', title: '' }, 'only real Wardrobe ids'); + Rooms.start(room); + const row = Rooms.teacherView(room).players.find((p) => p.id === ann.id); + assert.equal(row.avatar, '🦊'); + const t = Rooms.joinTeacher(room, { avatar: 'owl' }); + assert.equal(Rooms.cleanLook({ avatar: 'owl' }).avatar, t.look.avatar); +}); + +test('pairs: your partner sees your live move (dragging, answering) until the turn moves on', () => { + const { room, players } = pairsOf(['Ann', 'Bo'], { matching: 'keep' }); + Rooms.start(room); + const m = Rooms.matchOf(room, players[0]); + const onTurn = room.players.find((p) => p.id === m.ids[m.current]); + const partner = room.players.find((p) => p.id === m.ids[1 - m.current]); + assert.throws(() => Rooms.setLive(room, partner, { phase: 'place', rect: { x: 0, y: 0, w: 2, h: 2 } }), /turn/i, 'only the player on turn'); + assert.throws(() => Rooms.setLive(room, onTurn, { phase: 'place' }), /Roll first/); + Rooms.pairRoll(room, onTurn, () => 0.5); + Rooms.setLive(room, onTurn, { phase: 'place', rect: { x: 1, y: 2, w: 3, h: 2 } }); + let seen = Rooms.studentView(room, partner).match.live; + assert.deepEqual(seen, { by: onTurn.id, phase: 'place', rect: { x: 1, y: 2, w: 3, h: 2 } }); + Rooms.setLive(room, onTurn, { phase: 'place', rect: { x: -1, y: 0, w: 99, h: 1 } }); + assert.equal(Rooms.studentView(room, partner).match.live.rect, null, 'off the board: not shown'); + Rooms.setLive(room, onTurn, { phase: 'answer', rect: { x: 0, y: 0, w: 1, h: 1 } }); + assert.equal(Rooms.studentView(room, partner).match.live.phase, 'answer'); + // the match view carries both players' looks + assert.ok('avatar' in Rooms.studentView(room, partner).match.players[0]); + // once the turn is over, nothing is live + const [a, b] = m.roll; + let spot = null; + for (let y = 0; y < m.board.size && !spot; y++) for (let x = 0; x < m.board.size && !spot; x++) if (Core.isValidPlacement(m.board, x, y, a, b)) spot = { x, y, w: a, h: b }; + Rooms.submit(room, onTurn, { round: m.turn, kind: 'placed', rect: spot, firstTry: true, ms: 1000 }); + seen = Rooms.studentView(room, partner).match.live; + assert.equal(seen, null); +}); + +test('whole class: students see up to 8 classmates near them, with their boards; the teacher sees every board', () => { + const room = Rooms.createRoom({ type: 'class' }); + const kids = Array.from({ length: 12 }, (_, i) => Object.assign(Rooms.join(room, `Kid${String.fromCharCode(65 + i)}`), { connections: 1 })); + Rooms.start(room); + const [a, b] = room.roll; + Rooms.submit(room, kids[0], { round: room.round, kind: 'placed', rect: { x: 0, y: 0, w: a, h: b }, firstTry: true, ms: 900 }); + const view = Rooms.studentView(room, kids[5]); + assert.equal(view.classmates.length, 8); + assert.ok(!view.classmates.some((c) => c.id === kids[5].id), 'not yourself'); + const t = Rooms.teacherView(room); + assert.equal(Object.keys(t.boards).length, 12); + assert.deepEqual(t.boards[kids[0].id], [[0, 0, a, b]]); + const lead = Rooms.studentView(room, kids[1]).classmates.find((c) => c.id === kids[0].id); + assert.deepEqual(lead && lead.rects, [[0, 0, a, b]], 'the leader is near the top'); +}); + +// ---------------------------------------------------------------- saving + +// A live room is saved as JSON (to be picked up by another server): it must be +// plain data, and a copy read back must play on as if nothing happened. +const roundTrip = (room) => JSON.parse(JSON.stringify(room)); + +test('saving: a whole-class game survives a JSON round trip mid-game and plays to the end', () => { + let { room } = classOf(['Ann', 'Bo']); + Rooms.start(room); + for (const p of room.players) Rooms.submit(room, p, placeFor(room, p)); + room = roundTrip(room); + assert.deepEqual(roundTrip(room), room, 'nothing is lost in JSON'); + for (let i = 0; i < 40 && room.state === 'playing'; i++) { + Rooms.nextRound(room); + if (room.state !== 'playing') break; + for (const p of room.players) if (!p.done) Rooms.submit(room, p, Core.validPlacements(p.board, ...room.roll).length ? placeFor(room, p) : { round: room.round, kind: 'timeout' }); + room = roundTrip(room); + } + assert.equal(room.state, 'ended'); +}); + +test('saving: pairs survive a JSON round trip every turn', () => { + let { room } = pairsOf(['Ann', 'Bo']); + Rooms.start(room); + for (let i = 0; i < 400 && room.state === 'playing'; i++) { + room = roundTrip(room); + playTurn(room, room.matches[0]); + } + assert.equal(room.state, 'ended'); + assert.equal(room.matches[0].over, true); +}); diff --git a/apps/server/test/server.test.js b/apps/server/test/server.test.js new file mode 100644 index 0000000..d4d9993 --- /dev/null +++ b/apps/server/test/server.test.js @@ -0,0 +1,163 @@ +// The HTTP API end to end: a real server on a spare port with an in-memory database. +process.env.BLOCKOUT_STORE = 'memory'; +process.env.BLOCKOUT_ROOMS = 'memory'; // (hermetic: no shared rate limits or rooms) +process.env.LOG_LEVEL = 'silent'; +process.env.BLOCKOUT_AUTH_SECRET = 'test-secret'; +import test from 'node:test'; +import assert from 'node:assert'; +import * as P from '@blockout/progress'; + +const { server, store } = await import('../src/index.js'); // (after the environment is set) + +let base; +test.before(() => new Promise((resolve) => server.listen(0, () => ((base = `http://localhost:${server.address().port}`), resolve())))); +test.after(async () => { + server.close(); + await store.close(); // (a database connection would keep the test running) +}); + +async function call(method, path, body, token) { + const res = await fetch(base + path, { + method, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, data: await res.json() }; +} +const signIn = async (person) => (await call('POST', '/api/auth/dev', person)).data.token; + +test('HTTP: sign in, class roster, hosting needs a teacher, signed-in students join under their account name', async () => { + const config = await call('GET', '/api/auth/config'); + assert.equal(config.data.provider, 'dev'); + assert.deepEqual(config.data.schools.map((s) => s.name), ['Cadence Park']); + + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + const kid = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + assert.equal((await call('GET', '/api/me', null, kid)).data.user.name, 'Maya K.'); + assert.equal((await call('GET', '/api/me')).status, 401); + assert.equal((await call('GET', '/api/me', null, 'nonsense')).status, 401); + + const c = (await call('POST', '/api/classes', { name: 'Room 12' }, teacher)).data; + assert.equal((await call('POST', '/api/me/classes', { code: c.code }, kid)).status, 200); + assert.equal((await call('POST', '/api/classes', { name: 'Nope' }, kid)).status, 403); + const detail = (await call('GET', `/api/classes/${c.id}`, null, teacher)).data; + assert.deepEqual(detail.students.map((s) => s.name), ['Maya K.']); + + // guests and students can't host; teachers can + assert.equal((await call('POST', '/api/rooms', { settings: {} })).status, 401); + assert.equal((await call('POST', '/api/rooms', { settings: {} }, kid)).status, 401); + const room = (await call('POST', '/api/rooms', { settings: {} }, teacher)).data; + assert.match(room.code, /^[A-Z]+$/); + // a signed-in student joins as their account's first name, whatever they type + const joined = (await call('POST', `/api/rooms/${room.code}/join`, { name: 'Whatever' }, kid)).data; + assert.equal(joined.name, 'Maya'); + // guests still join with a name + assert.equal((await call('POST', `/api/rooms/${room.code}/join`, { name: 'Leo' })).data.name, 'Leo'); + + // progress sync + const save = P.createProgress(); + save.wallet = 42; + assert.equal((await call('PUT', '/api/me/progress', { progress: save }, kid)).status, 200); + assert.equal((await call('GET', '/api/me/progress', null, kid)).data.progress.wallet, 42); +}); + +test('HTTP: errors are RFC 9457 problem details (and keep error/message for the games)', async () => { + const res = await fetch(`${base}/api/me`); + assert.equal(res.status, 401); + assert.match(res.headers.get('content-type'), /^application\/problem\+json/); + assert.equal(res.headers.get('x-content-type-options'), 'nosniff'); + assert.equal(res.headers.get('permissions-policy'), 'camera=(), microphone=(), geolocation=()'); + assert.equal(res.headers.get('strict-transport-security'), null); // (only over https) + assert.match(res.headers.get('content-security-policy'), /script-src 'self';.*frame-ancestors 'none'/); + const body = await res.json(); + assert.deepEqual(body, { type: 'about:blank', title: 'Unauthorized', status: 401, detail: 'Please sign in first.', error: 'signin', message: 'Please sign in first.' }); + const missing = await fetch(`${base}/api/nope`); + assert.equal((await missing.json()).status, 404); +}); + +test('HTTP: malformed addresses and cut-off bodies get an answer, and the server keeps going', async () => { + for (const path of ['/%E0', '/addition/%E0%A4%A', '/api/classes/%E0']) { + const res = await fetch(base + path); + assert.equal(res.status, 400, path); + assert.equal((await res.json()).error, 'bad-request'); + } + + // A body that stops halfway: the client goes away before sending it all + const net = await import('node:net'); + await new Promise((resolve) => { + const sock = net.connect(server.address().port, 'localhost', () => { + sock.write('POST /api/rooms HTTP/1.1\r\nHost: x\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{"ty'); + setTimeout(() => (sock.destroy(), resolve()), 50); + }); + }); + assert.equal((await call('GET', '/api/health')).status, 200); +}); + +test('HTTP: a class holds at most 200 live streams', async () => { + const http = await import('node:http'); + const teacher = await signIn({ firstName: 'Mx', lastInitial: 'S', role: 'teacher', schoolId: 'cadence-park' }); + const room = (await call('POST', '/api/rooms', { type: 'class' }, teacher)).data; + const agent = new http.Agent({ maxSockets: Infinity }); + const open = () => + new Promise((resolve) => http.get(`${base}/api/rooms/${room.code}/events?teacher=${room.teacherKey}`, { agent }, (res) => resolve(res))); + const streams = await Promise.all(Array.from({ length: 200 }, open)); + assert.ok(streams.every((res) => res.statusCode === 200)); + const extra = await open(); + assert.equal(extra.statusCode, 429); + extra.resume(); + for (const res of streams) res.destroy(); + agent.destroy(); +}); + +test('HTTP: invites make teachers and grown-ups; admins manage accounts', async () => { + const admin = await signIn({ firstName: 'Root', role: 'student', admin: true }); + const me = (await call('GET', '/api/me', null, admin)).data.user; + assert.deepEqual([me.role, me.isAdmin], ['student', true]); + const invite = (await call('POST', '/api/admin/invites', {}, admin)).data; + const newTeacher = await signIn({ firstName: 'Nia', role: 'student' }); + const redeemed = await call('POST', '/api/invites/redeem', { code: invite.code }, newTeacher); + assert.equal(redeemed.data.user.role, 'teacher'); + assert.equal((await call('POST', '/api/invites/redeem', { code: invite.code }, newTeacher)).status, 404); + // a student's grown-up code + const kid = await signIn({ firstName: 'Ivy', role: 'student' }); + const grownUp = (await call('POST', '/api/me/parent-invites', null, kid)).data; + const mom = await signIn({ firstName: 'Ada', role: 'student' }); + assert.equal((await call('POST', '/api/children', { code: grownUp.code }, mom)).data.user.role, 'parent'); + // admins only + assert.equal((await call('GET', '/api/admin/users', null, kid)).status, 403); + const users = (await call('GET', '/api/admin/users?q=iv', null, admin)).data; + assert.deepEqual(users.users.map((u) => u.name), ['Ivy']); + assert.equal((await call('DELETE', `/api/admin/users/${users.users[0].id}`, null, admin)).status, 200); + assert.equal((await call('GET', '/api/me', null, kid)).data.user.name, 'Ivy', '(signing in again makes a new, empty account)'); + assert.equal((await call('GET', '/api/me/parent-code', null, kid)).status, 404, 'permanent parent codes are gone'); +}); + +test('HTTP: one health endpoint per Kubernetes probe', async () => { + for (const [path, status] of [['/health/startup', 'started'], ['/health/live', 'live'], ['/health/ready', 'ready']]) { + const res = await fetch(base + path); + assert.equal(res.status, 200, path); + assert.equal((await res.json()).status, status); + assert.equal(res.headers.get('cache-control'), 'no-store'); + } +}); + +// (last: it uses up this address's wrong-code allowance) +test('HTTP: guessing codes gets 429 after a run of wrong ones; streams with a wrong key look like no class', async () => { + const teacher = await signIn({ firstName: 'Mr', lastInitial: 'G', role: 'teacher', schoolId: 'cadence-park' }); + const room = (await call('POST', '/api/rooms', { type: 'class' }, teacher)).data; + assert.equal((await call('GET', `/api/rooms/${room.code}/poll?teacher=nope`)).status, 404); + assert.equal((await call('GET', `/api/rooms/${room.code}/poll?id=x&key=y`)).data.removed, true); + + let status; + let tries = 0; + do { + const res = await fetch(`${base}/api/rooms/QQQ${String.fromCharCode(65 + (tries % 26))}/info`); + status = res.status; + tries++; + if (status === 429) assert.ok(Number(res.headers.get('retry-after')) >= 1); + } while (status === 404 && tries < 100); + assert.equal(status, 429); + assert.ok(tries > 20 && tries < 40, `blocked after ${tries} tries`); + // a real class can't be looked up from here either until the minute is up + assert.equal((await call('GET', `/api/rooms/${room.code}/info`)).status, 429); +}); diff --git a/apps/server/test/store.contract.js b/apps/server/test/store.contract.js new file mode 100644 index 0000000..687c3eb --- /dev/null +++ b/apps/server/test/store.contract.js @@ -0,0 +1,248 @@ +// What every accounts store must do, whatever keeps the data (memory, sqlite, +// MongoDB). runStoreContract(name, open) adds the tests; open() returns a +// fresh, empty store (and may be async). +import test from 'node:test'; +import assert from 'node:assert'; + +const maya = { sub: 'kc:maya', role: 'student', firstName: 'Maya', lastInitial: 'K', domain: 'iusd.org', schoolId: 'cadence-park' }; +const ben = { sub: 'kc:ben', role: 'student', firstName: 'Ben', lastInitial: 'A', domain: 'gmail.com', schoolId: null }; +const msR = { sub: 'kc:r', role: 'teacher', firstName: 'Ms', lastInitial: 'R', domain: 'iusd.org', schoolId: 'cadence-park' }; +const mom = { sub: 'kc:mom', role: 'parent', firstName: 'Ana', lastInitial: '', domain: 'gmail.com', schoolId: null }; + +function runStoreContract(name, open) { + const withStore = (title, fn) => + test(`store (${name}): ${title}`, async () => { + const store = await open(); + await store.migrate(); + try { + await fn(store); + } finally { + await store.close(); + } + }); + + withStore('meta values and schools', async (store) => { + assert.equal(await store.getMeta('x'), null); + await store.setMeta('x', 'one'); + await store.setMeta('x', 'two'); + assert.equal(await store.getMeta('x'), 'two'); + assert.ok(await store.ping()); + assert.deepEqual(await store.schoolByDomain('IUSD.org'), { id: 'cadence-park', name: 'Cadence Park', domain: 'iusd.org' }); + await store.addSchool({ id: 'oak', name: 'Oak Hill', domain: 'Oak.EDU' }); + await store.addSchool({ id: 'oak2', name: 'Again', domain: 'oak.edu' }); // (domain taken: ignored) + assert.deepEqual((await store.schools()).map((s) => s.id), ['cadence-park', 'oak']); + assert.equal((await store.school('oak')).domain, 'oak.edu'); + assert.equal(await store.school('nope'), null); + }); + + withStore('users: created once per subject, updated on sign-in, found by id, subject and parent code', async (store) => { + const a = await store.upsertUser(maya, 1000); + assert.equal(a.name, 'Maya K.'); + assert.equal(a.createdAt, 1000); + assert.equal(a.lastSeen, 1000); + const again = await store.upsertUser({ ...maya, firstName: 'Mia', role: 'teacher' }, 2000); + assert.equal(again.id, a.id); + assert.equal(again.firstName, 'Mia'); + assert.equal(again.role, 'student', 'signing in never changes the role'); + assert.equal(again.invitedBy, null); + assert.equal(again.createdAt, 1000); + assert.equal(again.lastSeen, 2000); + assert.equal((await store.userBySub('kc:maya')).id, a.id); + assert.equal((await store.user(a.id)).sub, 'kc:maya'); + assert.equal(await store.user('nope'), null); + await store.touchUser(a.id, 3000); + assert.equal((await store.user(a.id)).lastSeen, 3000); + await store.setRole(a.id, 'teacher', { invitedBy: 'someone' }); + assert.equal((await store.user(a.id)).role, 'teacher'); + assert.equal((await store.user(a.id)).invitedBy, 'someone'); + await store.setRole(a.id, 'student'); + assert.equal((await store.user(a.id)).invitedBy, 'someone', 'kept when not given'); + assert.equal((await store.upsertUser({ ...ben, role: undefined })).role, 'student', 'new accounts are students'); + const p = await store.upsertUser(mom, 1000); + assert.equal(p.name, 'Ana', 'no initial, no dot'); + assert.deepEqual((await store.usersWithRole('parent')).map((u) => u.id), [p.id]); + }); + + withStore('users for admins: newest first, by first name and role', async (store) => { + await store.upsertUser(maya, 10); + await store.upsertUser(ben, 30); + await store.upsertUser(msR, 20); + await store.upsertUser({ ...maya, sub: 'kc:m2', firstName: 'Max' }, 5); + assert.deepEqual((await store.listUsers()).users.map((u) => u.firstName), ['Ben', 'Ms', 'Maya', 'Max']); + assert.equal((await store.listUsers()).total, 4); + assert.deepEqual((await store.listUsers({ q: 'ma' })).users.map((u) => u.firstName), ['Maya', 'Max']); + assert.deepEqual((await store.listUsers({ q: 'm', role: 'teacher' })).users.map((u) => u.firstName), ['Ms']); + const page = await store.listUsers({ limit: 2, offset: 1 }); + assert.deepEqual([page.users.map((u) => u.firstName), page.total], [['Ms', 'Maya'], 4]); + assert.equal((await store.listUsers({ q: '.*' })).total, 0, 'the search is text, not a pattern'); + }); + + withStore('stale accounts: not seen since a time, oldest first', async (store) => { + const a = await store.upsertUser(maya, 100); + const b = await store.upsertUser(ben, 50); + await store.upsertUser(msR, 300); + assert.deepEqual(await store.staleUsers(200), [b.id, a.id]); + assert.deepEqual(await store.staleUsers(200, 1), [b.id]); + assert.deepEqual(await store.staleUsers(10), []); + }); + + withStore('invites: one use each, until they expire', async (store) => { + const t = await store.upsertUser(msR); + const a = await store.upsertUser(maya); + const b = await store.upsertUser(ben); + const i = await store.createInvite({ kind: 'parent', codeHash: 'h1', createdBy: t.id, childId: a.id, expiresAt: 1000 }, 100); + assert.equal(i.usedBy, null); + assert.equal((await store.invite(i.id)).childId, a.id); + assert.equal((await store.inviteByHash('h1')).id, i.id); + assert.equal(await store.inviteByHash('nope'), null); + await assert.rejects(store.createInvite({ kind: 'parent', codeHash: 'h1', createdBy: t.id, expiresAt: 1000 }, 100), 'codes are unique'); + assert.equal(await store.redeemInvite('nope', b.id, 200), null); + const [won, lost] = await Promise.all([store.redeemInvite('h1', b.id, 200), store.redeemInvite('h1', a.id, 200)]); + assert.ok(Boolean(won) !== Boolean(lost), 'one of two at once wins'); + assert.equal((await store.invite(i.id)).usedAt, 200); + await store.createInvite({ kind: 'teacher', codeHash: 'h2', createdBy: t.id, schoolId: 'cadence-park', expiresAt: 1000 }, 300); + assert.equal(await store.redeemInvite('h2', b.id, 1000), null, 'expired'); + await store.createInvite({ kind: 'parent', codeHash: 'h3', createdBy: t.id, childId: a.id, expiresAt: 5000 }, 400); + await store.createInvite({ kind: 'parent', codeHash: 'h4', createdBy: t.id, childId: b.id, expiresAt: 5000 }, 500); + assert.deepEqual((await store.invitesBy(t.id)).map((x) => x.codeHash), ['h4', 'h3', 'h2', 'h1']); + assert.equal(await store.countInvites({ createdBy: t.id, kind: 'parent', since: 150 }), 2); + assert.equal(await store.countInvites({ createdBy: t.id, kind: 'parent', unused: true }), 2); + await store.revokeInvites({ createdBy: t.id, kind: 'parent', childId: a.id }); + assert.deepEqual((await store.invitesBy(t.id)).map((x) => x.codeHash), ['h4', 'h2', 'h1'], 'used ones stay'); + assert.deepEqual((await store.allInvites({ kind: 'teacher' })).map((x) => x.codeHash), ['h2']); + await store.deleteInvite((await store.allInvites({ kind: 'teacher' }))[0].id); + assert.deepEqual((await store.allInvites()).map((x) => x.codeHash), ['h4', 'h1']); + }); + + withStore('deleting a user takes everything of theirs with them', async (store) => { + const t = await store.upsertUser(msR); + const a = await store.upsertUser(maya); + const b = await store.upsertUser(ben); + const p = await store.upsertUser(mom); + const c = await store.createClass({ teacherId: t.id, name: 'Room 12', code: 'ABC234' }); + await store.addMember(c.id, a.id); + await store.addMember(c.id, b.id); + await store.linkParent(p.id, a.id); + await store.putProgress(a.id, { coins: 1 }); + await store.putProgress(t.id, { coins: 2 }); + await store.createAssignment({ createdBy: t.id, classId: c.id, goal: { type: 'games', games: 1 } }); + await store.createAssignment({ createdBy: p.id, studentId: a.id, goal: { type: 'games', games: 1 } }); + await store.createSchedule({ teacherId: t.id, scope: 'class', classId: c.id, title: 'Cup', startsAt: 1 }); + await store.createInvite({ kind: 'parent', codeHash: 'x1', createdBy: t.id, childId: b.id, expiresAt: 9e12 }); + await store.createInvite({ kind: 'parent', codeHash: 'x2', createdBy: p.id, childId: a.id, expiresAt: 9e12 }); + + await store.deleteUser(a.id); + let all = JSON.stringify(await store.exportAll()); + assert.ok(!all.includes(a.id), 'nothing refers to the child any more'); + assert.deepEqual((await store.members(c.id)).map((u) => u.id), [b.id]); + assert.deepEqual(await store.parentsOf(a.id), []); + + await store.deleteUser(t.id); + all = await store.exportAll(); + assert.ok(!JSON.stringify(all).includes(t.id), 'nor to the teacher'); + assert.deepEqual([all.classes, all.classMembers, all.schedules], [[], [], []], 'their class went with them'); + assert.deepEqual(all.users.map((u) => u.id).sort(), [b.id, p.id].sort()); + assert.equal((await store.parentsOf(b.id)).length, 0); + }); + + withStore('progress: one save per user, replaced whole', async (store) => { + const a = await store.upsertUser(maya); + assert.equal(await store.getProgress(a.id), null); + await store.putProgress(a.id, { coins: 1, facts: { '3x4': [1, 0] } }, 10); + await store.putProgress(a.id, { coins: 2 }, 20); + assert.deepEqual(await store.getProgress(a.id), { data: { coins: 2 }, updatedAt: 20 }); + const got = await store.getProgress(a.id); + got.data.coins = 99; + assert.equal((await store.getProgress(a.id)).data.coins, 2, 'a copy: changing it changes nothing stored'); + }); + + withStore('classes and rosters', async (store) => { + const t = await store.upsertUser(msR); + const [a, b] = [await store.upsertUser(maya), await store.upsertUser(ben)]; + const c1 = await store.createClass({ teacherId: t.id, schoolId: 'cadence-park', name: 'Room 12', code: 'ABC234' }, 1); + const c2 = await store.createClass({ teacherId: t.id, name: 'Club', code: 'XYZ789' }, 2); + assert.equal(c2.schoolId, null); + await assert.rejects(store.createClass({ teacherId: t.id, name: 'Dup', code: 'ABC234' }, 3), 'codes are unique'); + assert.equal((await store.classByCode('ABC234')).id, c1.id); + assert.equal(await store.classByCode('NOPE'), null); + assert.deepEqual((await store.classesOfTeacher(t.id)).map((c) => c.name), ['Room 12', 'Club']); + await store.addMember(c1.id, a.id, 5); + await store.addMember(c1.id, b.id, 6); + await store.addMember(c1.id, b.id, 7); // (already in: ignored) + await store.addMember(c2.id, a.id, 8); + assert.deepEqual((await store.members(c1.id)).map((u) => u.name), ['Ben A.', 'Maya K.'], 'by first name'); + assert.deepEqual((await store.classesOfStudent(a.id)).map((c) => c.name), ['Room 12', 'Club']); + assert.ok(await store.isMember(c1.id, b.id)); + await store.removeMember(c1.id, b.id); + assert.ok(!(await store.isMember(c1.id, b.id))); + await store.renameClass(c2.id, 'Chess club'); + assert.equal((await store.classById(c2.id)).name, 'Chess club'); + }); + + withStore('parent links', async (store) => { + const p = await store.upsertUser(mom); + const [a, b] = [await store.upsertUser(maya), await store.upsertUser(ben)]; + await store.linkParent(p.id, a.id, 1); + await store.linkParent(p.id, b.id, 2); + await store.linkParent(p.id, b.id, 3); + assert.deepEqual((await store.childrenOf(p.id)).map((u) => u.firstName), ['Ben', 'Maya']); + assert.deepEqual((await store.parentsOf(a.id)).map((u) => u.id), [p.id]); + assert.ok(await store.isParentOf(p.id, a.id)); + await store.unlinkParent(p.id, a.id); + assert.ok(!(await store.isParentOf(p.id, a.id))); + assert.deepEqual((await store.childrenOf(p.id)).map((u) => u.firstName), ['Ben']); + }); + + withStore('homework: for a class or one student, in the order it was set', async (store) => { + const t = await store.upsertUser(msR); + const p = await store.upsertUser(mom); + const a = await store.upsertUser(maya); + const c = await store.createClass({ teacherId: t.id, name: 'Room 12', code: 'ABC234' }); + await store.addMember(c.id, a.id); + const h1 = await store.createAssignment({ createdBy: t.id, classId: c.id, goal: { type: 'games', games: 2 }, due: '2026-10-20' }, 10); + const h2 = await store.createAssignment({ createdBy: p.id, studentId: a.id, goal: { type: 'master', table: 7 } }, 20); + assert.deepEqual(h1.goal, { type: 'games', games: 2 }); + assert.equal(h2.due, null); + assert.equal(h2.classId, null); + assert.deepEqual((await store.assignmentsForClass(c.id)).map((h) => h.id), [h1.id]); + assert.deepEqual((await store.assignmentsForStudent(a.id)).map((h) => h.id), [h1.id, h2.id]); + await store.deleteAssignment(h1.id); + assert.equal(await store.assignment(h1.id), null); + assert.deepEqual((await store.assignmentsForStudent(a.id)).map((h) => h.id), [h2.id]); + }); + + withStore('scheduled tournaments: due ones open exactly once; students see their class and school ones', async (store) => { + const t = await store.upsertUser(msR); + const a = await store.upsertUser(maya); + const b = await store.upsertUser(ben); + const c = await store.createClass({ teacherId: t.id, schoolId: 'cadence-park', name: 'Room 12', code: 'ABC234' }); + await store.addMember(c.id, a.id); + const s1 = await store.createSchedule({ teacherId: t.id, scope: 'class', classId: c.id, schoolId: 'cadence-park', title: 'Class cup', startsAt: 100, settings: { format: 'swiss' } }, 1); + const s2 = await store.createSchedule({ teacherId: t.id, scope: 'school', schoolId: 'cadence-park', title: 'School cup', startsAt: 50 }, 2); + assert.deepEqual(s2.settings, {}); + assert.equal(s1.roomCode, null); + assert.deepEqual((await store.schedulesOfTeacher(t.id)).map((s) => s.title), ['School cup', 'Class cup'], 'by start time'); + assert.deepEqual((await store.dueSchedules(60)).map((s) => s.id), [s2.id]); + assert.equal(await store.markOpened(s2.id, 'ROOM', 'key', 60), true); + assert.equal(await store.markOpened(s2.id, 'ROOM2', 'key2', 61), false, 'only once'); + assert.equal((await store.schedule(s2.id)).roomCode, 'ROOM'); + assert.deepEqual(await store.dueSchedules(60), []); + await store.markOpened(s1.id, 'ROOM3', 'k', 100); + assert.deepEqual((await store.openSchedulesForStudent(a)).map((s) => s.title), ['School cup', 'Class cup']); + assert.deepEqual(await store.openSchedulesForStudent(b), [], 'not in the class, not at the school'); + await store.deleteSchedule(s1.id); + assert.equal(await store.schedule(s1.id), null); + }); + + withStore('export: everything stored, by collection', async (store) => { + const t = await store.upsertUser(msR); + await store.putProgress(t.id, { coins: 3 }, 5); + const all = await store.exportAll(); + for (const key of ['meta', 'schools', 'users', 'progress', 'classes', 'classMembers', 'parentLinks', 'assignments', 'schedules', 'invites']) assert.ok(key in all, key); + assert.deepEqual(all.progress, [{ userId: t.id, data: { coins: 3 }, updatedAt: 5 }]); + assert.equal(all.users[0].sub, 'kc:r'); + assert.ok(!('name' in all.users[0]), 'the shown name is derived, not stored'); + }); +} + +export { runStoreContract }; diff --git a/apps/server/test/store.test.js b/apps/server/test/store.test.js new file mode 100644 index 0000000..b59ce7f --- /dev/null +++ b/apps/server/test/store.test.js @@ -0,0 +1,90 @@ +// The accounts store contract, on every store that runs here. MongoDB runs when +// BLOCKOUT_MONGO_URL is set (CI starts one), each test in a database of its own. +import { runStoreContract } from './store.contract.js'; +import { openMemoryStore } from '../src/store/memory.js'; +import { openSqliteStore } from '../src/store/sqlite.js'; // (for the copy test) + +runStoreContract('memory', () => openMemoryStore()); + +// MongoDB: a database of its own for each test, dropped afterwards +const MONGO_URL = process.env.BLOCKOUT_MONGO_URL; +if (MONGO_URL) { + const { openMongoStore } = await import('../src/store/mongo.js'); + let n = 0; + runStoreContract('mongo', async () => { + const store = await openMongoStore(MONGO_URL, { dbName: `blockout_test_${process.pid}_${n++}` }); + const close = store.close; + store.close = async () => { + await store.db.dropDatabase(); + await close(); + }; + return store; + }); +} + +if (MONGO_URL) { + const test = (await import('node:test')).default; + const assert = (await import('node:assert')).default; + const { MongoClient } = await import('mongodb'); + const { migrate, MIGRATIONS } = await import('../src/store/migrations.js'); + + test('migrations (mongo): several servers starting at once run each step exactly once', async () => { + const client = await new MongoClient(MONGO_URL).connect(); + const db = client.db(`blockout_test_${process.pid}_migrate`); + try { + let runs = 0; + const steps = [...MIGRATIONS, { version: 99, up: async () => (runs++, new Promise((r) => setTimeout(r, 300))) }]; + const versions = await Promise.all([1, 2, 3].map((i) => migrate(db, { steps, owner: `server${i}` }))); + assert.deepEqual(versions, [99, 99, 99]); + assert.equal(runs, 1); + assert.equal(await migrate(db, { steps }), 99, 'nothing left to do'); + assert.ok((await db.collection('users').indexes()).some((ix) => ix.unique && ix.key.sub === 1)); + } finally { + await db.dropDatabase(); + await client.close(); + } + }); +} + +if (MONGO_URL) { + const test = (await import('node:test')).default; + const assert = (await import('node:assert')).default; + const { openMongoStore } = await import('../src/store/mongo.js'); + const { copyInto } = await import('../scripts/sqlite-to-mongo.js'); + + test('sqlite to MongoDB: everything is copied, and copying again changes nothing', async () => { + const sqlite = openSqliteStore(':memory:'); + const t = await sqlite.upsertUser({ sub: 'kc:t', role: 'teacher', firstName: 'Ms', lastInitial: 'R', domain: 'iusd.org', schoolId: 'cadence-park' }, 1); + const kid = await sqlite.upsertUser({ sub: 'kc:k', role: 'student', firstName: 'Maya', lastInitial: 'K', domain: 'iusd.org', schoolId: 'cadence-park' }, 2); + const mom = await sqlite.upsertUser({ sub: 'kc:m', role: 'parent', firstName: 'Ana', lastInitial: '', domain: 'gmail.com', schoolId: null }, 3); + await sqlite.setParentCode(kid.id, 'ABCD2345'); + await sqlite.putProgress(kid.id, { coins: 5, facts: { '3x4': [2, 1] } }, 4); + const c = await sqlite.createClass({ teacherId: t.id, schoolId: 'cadence-park', name: 'Room 12', code: 'ABC234' }, 5); + await sqlite.addMember(c.id, kid.id, 6); + await sqlite.linkParent(mom.id, kid.id, 7); + await sqlite.createAssignment({ createdBy: t.id, classId: c.id, goal: { type: 'games', games: 2 }, due: '2026-10-20' }, 8); + const s = await sqlite.createSchedule({ teacherId: t.id, scope: 'class', classId: c.id, title: 'Cup', startsAt: 100, settings: { format: 'swiss' } }, 9); + await sqlite.markOpened(s.id, 'ROOM', 'key', 100); + await sqlite.setMeta('devSecret', 'shh'); + const before = await sqlite.exportAll(); + + const mongo = await openMongoStore(MONGO_URL, { dbName: `blockout_test_${process.pid}_copy` }); + try { + await mongo.migrate(); + await copyInto(mongo.db, before); + await copyInto(mongo.db, before); + const after = await mongo.exportAll(); + const sorted = (o) => Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Array.isArray(v) ? [...v].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) : v])); + // (parent codes are retired; accounts start with no one who invited them) + const expected = { ...before, users: before.users.map(({ parentCode: _retired, ...u }) => ({ ...u, invitedBy: null })), invites: [] }; + assert.deepEqual(sorted(after), sorted(expected)); + // and the store reads it as its own + assert.equal((await mongo.user(kid.id)).name, 'Maya K.'); + assert.deepEqual((await mongo.members(c.id)).map((u) => u.id), [kid.id]); + assert.deepEqual((await mongo.openSchedulesForStudent(kid)).map((x) => x.roomCode), ['ROOM']); + } finally { + await mongo.db.dropDatabase(); + await mongo.close(); + } + }); +} diff --git a/test/tournament.test.js b/apps/server/test/tournament.test.js similarity index 97% rename from test/tournament.test.js rename to apps/server/test/tournament.test.js index 84d6ec2..ce3c097 100644 --- a/test/tournament.test.js +++ b/apps/server/test/tournament.test.js @@ -1,6 +1,6 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const T = require('../server/tournament.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as T from '../src/tournament.js'; // A seeded random number generator, so failures can be replayed. function rng(seed) { diff --git a/apps/web/addition/index.html b/apps/web/addition/index.html new file mode 100644 index 0000000..5295458 --- /dev/null +++ b/apps/web/addition/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Addition · Blockout + + + + + + diff --git a/apps/web/admin/index.html b/apps/web/admin/index.html new file mode 100644 index 0000000..698b31a --- /dev/null +++ b/apps/web/admin/index.html @@ -0,0 +1,63 @@ + + + + + + Admin · Blockout + + +
+
+ ← Blockout +

BLOCKOUT Admin

+

+
+ + +
+ + + diff --git a/apps/web/demos/index.html b/apps/web/demos/index.html new file mode 100644 index 0000000..44dd01b --- /dev/null +++ b/apps/web/demos/index.html @@ -0,0 +1,13 @@ + + + + + + Demos · Blockout + + + +
+ + + diff --git a/apps/web/division/index.html b/apps/web/division/index.html new file mode 100644 index 0000000..83acd35 --- /dev/null +++ b/apps/web/division/index.html @@ -0,0 +1,12 @@ + + + + + + Division · Blockout + + + + + + diff --git a/apps/web/fraction-add/index.html b/apps/web/fraction-add/index.html new file mode 100644 index 0000000..45460f1 --- /dev/null +++ b/apps/web/fraction-add/index.html @@ -0,0 +1,12 @@ + + + + + + Adding fractions · Blockout + + + + + + diff --git a/apps/web/fraction-divide/index.html b/apps/web/fraction-divide/index.html new file mode 100644 index 0000000..ea7e011 --- /dev/null +++ b/apps/web/fraction-divide/index.html @@ -0,0 +1,12 @@ + + + + + + Dividing fractions · Blockout + + + + + + diff --git a/apps/web/fraction-multiply/index.html b/apps/web/fraction-multiply/index.html new file mode 100644 index 0000000..887e02a --- /dev/null +++ b/apps/web/fraction-multiply/index.html @@ -0,0 +1,12 @@ + + + + + + Multiplying fractions · Blockout + + + + + + diff --git a/apps/web/fraction-subtract/index.html b/apps/web/fraction-subtract/index.html new file mode 100644 index 0000000..8c45c56 --- /dev/null +++ b/apps/web/fraction-subtract/index.html @@ -0,0 +1,12 @@ + + + + + + Subtracting fractions · Blockout + + + + + + diff --git a/index.html b/apps/web/index.html similarity index 75% rename from index.html rename to apps/web/index.html index e704dd7..b2377ed 100644 --- a/index.html +++ b/apps/web/index.html @@ -4,72 +4,18 @@ BLOCKOUT - - - -
-
🪙 0 points
+
+
🪙 0 points
+ +

BLOCKOUT

Roll two dice, draw a rectangle, claim the most squares.

+
+ +
+ +
+

+

+

+
    +
    +
    +
    Difficulty
    @@ -111,8 +71,8 @@

    BLOCKOUT

    Level
    - - + +

    @@ -161,11 +121,11 @@

    BLOCKOUT

    Your name -
    +
    Level
    - - + +

    Learn: see each fact as a rectangle and get help counting.

    @@ -179,7 +139,7 @@

    BLOCKOUT

    Expert only: answer before the clock runs out, or it counts as missed.

    -
    +
    Questions
    @@ -188,11 +148,6 @@

    BLOCKOUT

    -
    - Times tables to practice -
    -

    Unlock tables one at a time, then pick as many as you like. None picked: questions come from all your unlocked tables, focusing on the facts you find hardest.

    -

    @@ -222,6 +177,9 @@

    BLOCKOUT

    @@ -318,6 +278,7 @@

    Game settings

    +
    - - - @@ -472,6 +425,7 @@

    Practice

    - - - - - - - + diff --git a/apps/web/more-games/index.html b/apps/web/more-games/index.html new file mode 100644 index 0000000..3cf4e5a --- /dev/null +++ b/apps/web/more-games/index.html @@ -0,0 +1,19 @@ + + + + + + More games · Blockout + + +
    +
    + ← Blockout (multiplication) +

    BLOCKOUT

    +

    More games: the same roll, place and answer loop, built for other operations. Each one has Single player (you vs the CPU) and Practice (Learn or Expert). There’s no shop, stats or saving yet.

    +
    +
    +
    + + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..dac01fb --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@blockout/web", + "version": "0.11.0", + "private": true, + "type": "module", + "description": "Blockout's web pages: the multiplication game at /, the other games, More games, Snake, Demos and Admin, each at //.", + "dependencies": { + "@blockout/auth": "*", + "@blockout/classroom": "*", + "@blockout/engine": "*", + "@blockout/game-kit": "*", + "@blockout/progress": "*", + "@blockout/ui": "*", + "qrcode-generator": "^2.0.4" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/apps/web/snake/index.html b/apps/web/snake/index.html new file mode 100644 index 0000000..d82bc16 --- /dev/null +++ b/apps/web/snake/index.html @@ -0,0 +1,104 @@ + + + + + + Number Snake · Blockout + + +
    +
    + ← More games +
    🏅 Best 0
    +

    NUMBER SNAKE

    +

    No apples here: eat the right numbers to grow.

    +
    +
    + + + +
    +
    + Difficulty +
    +
    +
    + Movement +
    +
    +
    + Speed +
    +
    +
    + Walls +
    +
    + +
    +

    Arrow keys or WASD to steer (in Manual, each press moves one square), Space to pause. On a touch screen, swipe or use the arrows under the board. In Add it up, type the new total (or use the keypad) and press Enter.

    +
    +
    + +
    +
    + +

    NUMBER SNAKE

    +
    +
    +
    +
    + +
    +
    + + + + + diff --git a/apps/web/src/admin/admin.css b/apps/web/src/admin/admin.css new file mode 100644 index 0000000..0282458 --- /dev/null +++ b/apps/web/src/admin/admin.css @@ -0,0 +1,25 @@ +/* The admin page: a plain, wide work page for grown-ups (the game's tokens and buttons) */ +body { background: var(--bg); color: var(--ink); margin: 0; } +.admin { max-width: 1100px; margin: 0 auto; padding: 16px; } +.admin-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px 16px; margin-bottom: 12px; } +.admin-head .logo { margin: 0; font-size: 1.6rem; font-weight: 900; letter-spacing: 0.02em; } +.admin-head .logo span { color: var(--accent); } +.admin-head .logo small { color: var(--muted); font-size: 1rem; font-weight: 700; margin-left: 6px; } +.back-link { color: var(--muted); text-decoration: none; font-weight: 700; } +.admin-who { margin: 0 0 0 auto; color: var(--muted); } +.admin-note { padding: 14px; border: 2px solid var(--line); border-radius: var(--radius); background: var(--paper); } +.admin-tabs { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } +.admin-panel { background: var(--paper); border: 2px solid var(--edge); border-radius: var(--radius); padding: 14px; } +.admin-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin: 8px 0; } +.admin-row input, .admin-row select, .admin-table select { font: inherit; padding: 0.4rem 0.6rem; border: 2px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); } +.admin-row input { min-width: 0; flex: 1 1 160px; } +.admin-help, .admin-small { color: var(--muted); font-size: 0.9rem; } +.admin-table-wrap { overflow-x: auto; } +.admin-table { width: 100%; border-collapse: collapse; font-size: 0.95rem; } +.admin-table th, .admin-table td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--line); white-space: nowrap; } +.admin-table th { color: var(--muted); font-weight: 700; } +.admin-empty { color: var(--muted); text-align: center; } +.admin-code { display: flex; flex-direction: column; gap: 4px; padding: 12px; margin: 8px 0; border: 2px dashed var(--line); border-radius: var(--radius); background: var(--surface); } +.admin-code-text { font-size: 1.6rem; letter-spacing: 0.12em; font-family: ui-monospace, "Courier New", monospace; } +.admin-code code { overflow-wrap: anywhere; } +.admin-error { color: var(--bad); font-weight: 700; } diff --git a/apps/web/src/admin/main.js b/apps/web/src/admin/main.js new file mode 100644 index 0000000..726257e --- /dev/null +++ b/apps/web/src/admin/main.js @@ -0,0 +1,214 @@ +// The admin page: find accounts and change their role or delete them, make +// teacher codes, add schools. The server checks every request (only accounts +// with the Keycloak realm role admin get through); this page just shows what +// it answers. Everything is built with textContent: names come from people. +import '@blockout/ui/css/tokens.css'; +import '@blockout/ui/css/base.css'; +import './admin.css'; +import * as Auth from '@blockout/auth'; +import * as Grownups from '@blockout/progress/grownups'; + +const $ = (id) => document.getElementById(id); +const ROLES = { student: '🎒 Student', teacher: '🍎 Teacher', parent: '👪 Parent' }; +const PAGE = 50; + +function make(tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text !== undefined) n.textContent = text; + return n; +} +function button(text, cls, onClick) { + const b = make('button', cls, text); + b.type = 'button'; + b.addEventListener('click', onClick); + return b; +} +const day = (t) => (t ? new Date(t).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '—'); + +function showError(err) { + $('admin-error').textContent = err ? err.message : ''; + $('admin-error').hidden = !err; +} +// Every action: clear the last error, show a new one +const act = (fn) => async (...args) => { + showError(null); + try { + await fn(...args); + } catch (err) { + showError(err); + } +}; + +function table(id, head, rows) { + const t = $(id); + t.replaceChildren(); + const tr = make('tr'); + for (const h of head) tr.append(make('th', null, h)); + const thead = make('thead'); + thead.append(tr); + const tbody = make('tbody'); + for (const cells of rows) { + const row = make('tr'); + for (const c of cells) { + const td = make('td'); + td.append(c instanceof Node ? c : document.createTextNode(c ?? '—')); + row.append(td); + } + tbody.append(row); + } + if (!rows.length) { + const row = make('tr'); + const td = make('td', 'admin-empty', 'Nothing here.'); + td.colSpan = head.length; + row.append(td); + tbody.append(row); + } + t.append(thead, tbody); +} + +// ---- accounts + +let page = 0; + +async function loadUsers() { + const q = new URLSearchParams({ q: $('user-q').value.trim(), page: String(page), limit: String(PAGE) }); + if ($('user-role').value) q.set('role', $('user-role').value); + const { users, total } = await Auth.api('GET', `/api/admin/users?${q}`); + const me = Auth.user(); + table( + 'user-table', + ['Name', 'Role', 'Email domain', 'Joined', 'Last seen', ''], + users.map((u) => { + const role = make('select'); + for (const [value, label] of Object.entries(ROLES)) role.append(new Option(label, value, false, value === u.role)); + role.setAttribute('aria-label', `${u.name}'s role`); + role.addEventListener( + 'change', + act(async () => { + try { + await Auth.api('PUT', `/api/admin/users/${u.id}/role`, { role: role.value }); + } finally { + await loadUsers(); + } + }) + ); + const remove = + u.id === me.id + ? make('span', 'admin-small', 'you') + : button( + 'Delete', + 'btn btn-small btn-danger', + act(async () => { + const typed = prompt(`Delete ${u.name}'s account and everything in it (progress, classes they teach, homework, links)? This can't be undone. Type DELETE to go ahead.`); + if (!typed || typed.trim() !== 'DELETE') return; + await Auth.api('DELETE', `/api/admin/users/${u.id}`); + await loadUsers(); + }) + ); + return [u.name, role, u.domain, day(u.createdAt), day(u.lastSeen), remove]; + }) + ); + const pages = $('user-pages'); + pages.replaceChildren(make('span', 'admin-small', `${total} account${total === 1 ? '' : 's'}`)); + if (page > 0) pages.append(button('← Newer', 'btn btn-small', act(() => ((page -= 1), loadUsers())))); + if ((page + 1) * PAGE < total) pages.append(button('Older →', 'btn btn-small', act(() => ((page += 1), loadUsers())))); +} + +// ---- teacher codes + +async function loadSchools() { + const { schools } = await Auth.api('GET', '/api/admin/schools'); + table('school-table', ['School', 'Email domain'], schools.map((s) => [s.name, `@${s.domain}`])); + const pick = $('code-school'); + pick.replaceChildren(new Option('Any school (or none)', '')); + for (const s of schools) pick.append(new Option(`For ${s.name}`, s.id)); +} + +async function loadCodes() { + const { invites } = await Auth.api('GET', '/api/admin/invites?kind=teacher'); + table( + 'code-table', + ['Made', 'By', 'Used by', 'Until', ''], + invites.map((i) => [ + day(i.createdAt), + i.by, + Grownups.adminCodeState(i, day), + day(i.expiresAt), + i.usedAt + ? '' + : button( + 'Remove', + 'btn btn-small', + act(async () => { + await Auth.api('DELETE', `/api/me/invites/${i.id}`); + await loadCodes(); + }) + ), + ]) + ); +} + +function showCode(invite) { + const link = `${location.origin}/#code=${invite.code}`; + const box = make('div', 'admin-code'); + box.append( + make('strong', 'admin-code-text', invite.code.replace(/(.{4})/, '$1 ')), + make('span', 'admin-small', `Works once, until ${day(invite.expiresAt)}. Send it to one teacher, or this link:`), + make('code', null, link) + ); + $('code-made').replaceChildren(box); +} + +// ---- start + +function tab(name) { + for (const b of document.querySelectorAll('[data-tab]')) b.classList.toggle('btn-primary', b.dataset.tab === name); + for (const t of ['accounts', 'codes', 'schools']) $(`tab-${t}`).hidden = t !== name; + showError(null); +} + +async function start() { + const gate = (text) => { + $('admin-gate').textContent = text; + $('admin-gate').hidden = false; + }; + if (!Auth.signedIn()) return gate('Sign in on the Blockout page first, then come back here.'); + let me; + try { + me = await Auth.refreshUser(); + } catch (err) { + return gate(err.message); + } + if (!me) return gate('Sign in on the Blockout page first, then come back here.'); + $('admin-who').textContent = `Signed in as ${me.name}`; + if (!me.isAdmin) return gate('Admins only. An admin is an account with the admin role in Keycloak.'); + $('admin-app').hidden = false; + for (const b of document.querySelectorAll('[data-tab]')) b.addEventListener('click', () => tab(b.dataset.tab)); + $('user-search').addEventListener('submit', (e) => { + e.preventDefault(); + page = 0; + act(loadUsers)(); + }); + $('user-role').addEventListener('change', () => ((page = 0), act(loadUsers)())); + $('code-form').addEventListener( + 'submit', + act(async (e) => { + e.preventDefault(); + showCode(await Auth.api('POST', '/api/admin/invites', { schoolId: $('code-school').value || null })); + await loadCodes(); + }) + ); + $('school-form').addEventListener( + 'submit', + act(async (e) => { + e.preventDefault(); + await Auth.api('POST', '/api/admin/schools', { name: $('school-name').value, domain: $('school-domain').value }); + $('school-name').value = $('school-domain').value = ''; + await loadSchools(); + }) + ); + await act(async () => Promise.all([loadUsers(), loadSchools(), loadCodes()]))(); +} + +start(); diff --git a/apps/web/src/demos/catalog.js b/apps/web/src/demos/catalog.js new file mode 100644 index 0000000..04e5f52 --- /dev/null +++ b/apps/web/src/demos/catalog.js @@ -0,0 +1,44 @@ +// Every demo: a classic game mechanic turned into a math game. The hub lists +// these; opening one loads src/games/.js (see kit.js for what a game gets). + +export const GROUPS = [ + { id: 'arcade', name: 'Arcade' }, + { id: 'puzzle', name: 'Puzzle' }, + { id: 'board', name: 'Board & card' }, + { id: 'other', name: 'Other genres' }, +]; + +export const DEMOS = [ + // ---- arcade + { id: 'tetris', group: 'arcade', icon: '🧱', name: 'Times Tetris', classic: 'Tetris', idea: 'Falling rectangles: say the area to lock each one in, and fill whole rows to clear them.', how: ['← → move, ↑ turns, ↓ drops faster (Space drops at once).', 'When it lands, type its area (rows × columns) to lock it in.', 'Wrong: it crumbles away. Fill a whole row to clear it.'] }, + { id: 'invaders', group: 'arcade', icon: '👾', name: 'Fact Invaders', classic: 'Space Invaders', idea: 'A question loads your cannon; shoot the alien carrying the answer.', how: ['← → move your cannon (or drag).', 'Space (or tap Fire) shoots at the alien above you.', 'Hit the alien with the answer. Wrong aliens, or aliens landing, cost a heart!'] }, + { id: 'breakout', group: 'arcade', icon: '🏓', name: 'Product Breakout', classic: 'Breakout', idea: 'Your paddle shows a number; the ball only breaks bricks whose fact equals it.', how: ['← → or the mouse move the paddle.', 'Bricks show facts like 3 × 4. Only bricks equal to the paddle’s number break.', 'Break all the matching bricks to get a new number.'] }, + { id: 'pacman', group: 'arcade', icon: '🟡', name: 'Multiple Muncher', classic: 'Pac-Man', idea: 'Eat the dots that are multiples of a number; ghosts chase you.', how: ['Arrow keys move through the maze.', 'Eat only the multiples of the number at the top.', 'Wrong dots and ghosts cost a heart.'] }, + { id: 'frogger', group: 'arcade', icon: '🐸', name: 'Answer Hopper', classic: 'Frogger', idea: 'Hop across the river on the log with the right answer.', how: ['Each lane asks a question.', '← → pick a log in the next lane (or tap it); ↑ hops onto it.', 'Land on the right answer or splash!'] }, + { id: 'whack', group: 'arcade', icon: '🔨', name: 'Whack-a-Multiple', classic: 'Whack-a-Mole', idea: 'Moles pop up with numbers: whack only the ones in the table.', how: ['Click or tap a mole to whack it.', 'Whack only the multiples of the number shown.', '30 seconds: how many can you get?'] }, + { id: 'flappy', group: 'arcade', icon: '🐦', name: 'Flappy Facts', classic: 'Flappy Bird', idea: 'Fly through the gap marked with the right answer.', how: ['Space, click or tap flaps.', 'Each wall has gaps with numbers: fly through the answer.', 'Wrong gap or a crash costs a heart.'] }, + { id: 'asteroids', group: 'arcade', icon: '☄️', name: 'Factor Asteroids', classic: 'Asteroids', idea: 'Shoot a number and it splits into two factors; blast them down to primes.', how: ['← → turn, ↑ thrust, Space shoots.', 'A hit number splits into two factors (24 → 4 and 6).', 'Primes vanish when hit. Clear the field!'] }, + // ---- puzzle + { id: 'twenty48', group: 'puzzle', icon: '🔢', name: '2048 ×2', classic: '2048', idea: 'Slide tiles; equal tiles merge into their double. Say the double to merge them.', how: ['Arrow keys or swipes slide all the tiles.', 'Two equal tiles merge: pick or type their double (14 + 14 = 28).', 'Reach the goal tile (64 on Easy), or as far as you can.'] }, + { id: 'match3', group: 'puzzle', icon: '🍬', name: 'Product Match', classic: 'Match-3', idea: 'Pick three tiles in a row that multiply to the target.', how: ['Tap three touching tiles in a line.', 'Their product must equal the target.', 'Matches clear and new tiles fall in.'] }, + { id: 'memory', group: 'puzzle', icon: '🃏', name: 'Fact Pairs', classic: 'Memory', idea: 'Flip cards to match each fact with its answer.', how: ['Tap two cards to flip them.', 'A fact (6 × 7) and its answer (42) are a pair.', 'Find all the pairs in as few tries as you can.'] }, + { id: 'minesweeper', group: 'puzzle', icon: '💣', name: 'Multiple Sweeper', classic: 'Minesweeper', idea: 'Mines hide under multiples; the clues count the multiples around each square.', how: ['Every square shows a number. Tap to open it; right-click, long-press or Flag mode to flag.', 'Mines are the multiples of the number shown: flag them, open the rest.', 'Each opened square says how many mines touch it.'] }, + { id: 'kenken', group: 'puzzle', icon: '🧮', name: 'Times Cages', classic: 'KenKen', idea: 'Fill the grid so each row and column has each number once and every cage multiplies to its clue.', how: ['Tap a square, then a number.', 'Rows and columns use each number once.', 'The numbers in a cage multiply to its clue.'] }, + { id: 'nonogram', group: 'puzzle', icon: '🖼️', name: 'Picture Products', classic: 'Nonogram', idea: 'Solve facts to learn which squares to fill; a picture appears.', how: ['Each row and column clue is a fact: its answer is how many squares to fill in one block.', 'Tap squares to fill them.', 'Get every row and column right to reveal the picture.'] }, + { id: 'tangram', group: 'puzzle', icon: '🧩', name: 'Area Puzzle', classic: 'Tangram / pentominoes', idea: 'Fill a shape exactly with rectangles of the right areas.', how: ['Pick a rectangle from the tray and tap where it goes.', 'Its area must fit the space exactly.', 'Fill the whole shape with no gaps.'] }, + // ---- board and card + { id: 'connect4', group: 'board', icon: '🔴', name: 'Four in a Row', classic: 'Connect Four', idea: 'Move a marker along the factor strip; the product is the square you claim.', how: ['The two markers on the strip multiply to a square on the board.', 'On your turn, move one marker, then tap that product on the board to claim it.', 'Four in a row wins. The CPU plays too.'] }, + { id: 'ladders', group: 'board', icon: '🪜', name: 'Snakes & Ladders', classic: 'Snakes and Ladders', idea: 'Answer a fact to roll; answer right on a ladder to climb it, or on a snake to dodge it.', how: ['Answer the fact to roll the die (wrong: you move just 1 square).', 'Land on a ladder: answer to climb it. Land on a snake: answer right to dodge it.', 'First to 50 wins. The CPU races you.'] }, + { id: 'battleship', group: 'board', icon: '🚢', name: 'Fact Battleship', classic: 'Battleship', idea: 'Fire at a square by naming its product.', how: ['Rows and columns are numbers; each square is their product.', 'To fire, type the product of the square you picked.', 'Sink the CPU’s ships before it sinks yours.'] }, + { id: 'war', group: 'board', icon: '🂡', name: 'Product War', classic: 'War / Top Trumps', idea: 'Both flip two cards; the bigger product wins the round.', how: ['Flip: you and the CPU get two cards each.', 'Say which product is bigger (and what yours is).', 'The bigger product wins all four cards. Most cards after 10 rounds wins.'] }, + { id: 'bingo', group: 'board', icon: '🎱', name: 'Times Bingo', classic: 'Bingo', idea: 'Facts are called; mark their answers on your card.', how: ['The caller reads out a fact (6 × 8).', 'Tap its answer on your card if you have it.', 'Five in a row: Bingo!'] }, + { id: 'dominoes', group: 'board', icon: '🁫', name: 'Fact Dominoes', classic: 'Dominoes', idea: 'Match domino ends that are equal: 3 × 4 next to 12 or 2 × 6.', how: ['Tap a domino in your hand, then an end of the line.', 'The touching ends must be equal.', 'Play all your dominoes first to win.'] }, + // ---- other genres + { id: 'towers', group: 'other', icon: '🏰', name: 'Fact Towers', classic: 'Tower defense', idea: 'Answer facts to power your towers; harder facts make stronger shots.', how: ['Monsters walk the path.', 'Answer a fact to fire; the bigger the answer, the stronger the shot.', 'Don’t let 10 monsters through.'] }, + { id: 'racing', group: 'other', icon: '🏎️', name: 'Fact Racer', classic: 'Racing', idea: 'Every right answer is a boost; beat the CPU cars to the flag.', how: ['Answer the questions as fast as you can.', 'Each right answer boosts your car.', 'Wrong answers slow you down.'] }, + { id: 'rhythm', group: 'other', icon: '🥁', name: 'Skip-Count Beats', classic: 'Rhythm', idea: 'Skip-count on the beat: tap the next multiple as it reaches the line.', how: ['Numbers fall in lanes to a beat.', 'Tap (or press 1–4) the lane with the next multiple as it reaches the line.', 'Keep the count going!'] }, + { id: 'clicker', group: 'other', icon: '🍪', name: 'Multiplier Bakery', classic: 'Idle / clicker', idea: 'Bake cookies; buy ovens and recipes by working out how many you’ll make.', how: ['Click the cookie to bake.', 'Buy upgrades: more ovens and better recipes.', 'To buy, say the new total per second (ovens × cookies each).'] }, + { id: 'platformer', group: 'other', icon: '🏃', name: 'Jump the Answer', classic: 'Platformer', idea: 'Jump onto the platform with the right answer.', how: ['← → run, ↑ or Space jumps.', 'Platforms carry numbers: land on the answer.', 'Wrong platforms crumble!'] }, +]; + +export const byId = (id) => DEMOS.find((d) => d.id === id) || null; diff --git a/apps/web/src/demos/games/asteroids.js b/apps/web/src/demos/games/asteroids.js new file mode 100644 index 0000000..796ed1d --- /dev/null +++ b/apps/web/src/demos/games/asteroids.js @@ -0,0 +1,272 @@ +// Factor Asteroids (like Asteroids): rocks carry composite numbers. A hit +// splits a rock into a factor pair (24 → 4 and 6); primes can't split, so they +// vanish. Clear the field. Arrow keys + Space, on-screen buttons, or tap to aim. + +const W = 640; +const H = 480; +const SET = { easy: { rocks: 3, speed: [20, 35] }, medium: { rocks: 4, speed: [28, 45] }, hard: { rocks: 5, speed: [36, 58] } }; +const TURN = 3.4; // radians per second +const THRUST = 220; +const BULLET = { speed: 400, life: 1.1, gap: 0.22 }; + +export function mount(stage, ctx) { + const set = SET[ctx.level]; + const { canvas, ctx: g, point } = ctx.canvas(stage, W, H); + const ship = { x: W / 2, y: H / 2, vx: 0, vy: 0, angle: -Math.PI / 2, inv: 2 }; + let rocks = []; + let bullets = []; + let hearts = 3; + let score = 0; + let splits = 0; + let cooldown = 0; + let over = false; + const held = new Set(); // on-screen buttons held down: 'left' | 'right' | 'thrust' | 'fire' + + const radius = (v) => 21 + Math.sqrt(v) * 2.4; + // wrap round the edges; m lets a rock slide fully off before it comes back + const wrap = (o, m = 0) => { + if (o.x < -m) o.x += W + 2 * m; + else if (o.x > W + m) o.x -= W + 2 * m; + if (o.y < -m) o.y += H + 2 * m; + else if (o.y > H + m) o.y -= H + 2 * m; + }; + function rock(value, x, y, dir = ctx.rng() * Math.PI * 2) { + const sp = set.speed[0] + ctx.rng() * (set.speed[1] - set.speed[0]); + // a lumpy outline, fixed per rock + const bumps = Array.from({ length: 11 }, () => 0.82 + ctx.rng() * 0.22); + return { value, x, y, vx: Math.cos(dir) * sp, vy: Math.sin(dir) * sp, r: radius(value), bumps, spin: ctx.rng() * 6 }; + } + // the factor pair to split into: a fact from the level's tables if there is one + function split(n) { + const pairs = []; + for (let d = 2; d * d <= n; d++) if (n % d === 0) pairs.push([d, n / d]); + const fact = ([a, b]) => (ctx.L.tables.includes(a) && b <= ctx.L.upTo) || (ctx.L.tables.includes(b) && a <= ctx.L.upTo); + const fromTables = pairs.filter(fact); + return ctx.pick(fromTables.length ? fromTables : pairs); + } + + // starting rocks: composite times-table answers, away from the ship + for (let tries = 0; rocks.length < set.rocks && tries < 200; tries++) { + const { answer } = ctx.fact(); + if (answer < 4 || ctx.isPrime(answer) || rocks.some((r) => r.value === answer)) continue; + const a = ctx.rng() * Math.PI * 2; + const d = 170 + ctx.rng() * 60; + rocks.push(rock(answer, W / 2 + Math.cos(a) * d * 1.2, H / 2 + Math.sin(a) * d * 0.8)); + } + + function fire() { + if (over || cooldown > 0 || bullets.length >= 5) return; + cooldown = BULLET.gap; + const c = Math.cos(ship.angle); + const s = Math.sin(ship.angle); + bullets.push({ x: ship.x + c * 16, y: ship.y + s * 16, vx: c * BULLET.speed + ship.vx, vy: s * BULLET.speed + ship.vy, life: BULLET.life }); + } + + function hit(r, b) { + rocks = rocks.filter((x) => x !== r); + if (ctx.isPrime(r.value)) { + score += 5; + ctx.hud.msg(`${r.value} is prime: it can’t split. Poof!`, 'good'); + } else { + const [a, c] = split(r.value); + splits++; + score += 10; + ctx.hud.msg(`${r.value} = ${a} × ${c}`, 'good'); + // the two halves fly apart, across the bullet's path + const dir = Math.atan2(b.vy, b.vx); + const [ox, oy] = [Math.cos(dir + Math.PI / 2) * r.r * 0.6, Math.sin(dir + Math.PI / 2) * r.r * 0.6]; + rocks.push(rock(a, r.x + ox, r.y + oy, dir + Math.PI / 2), rock(c, r.x - ox, r.y - oy, dir - Math.PI / 2)); + } + ctx.hud.score(score); + info(); + if (!rocks.length) { + over = true; + ctx.after(600, () => + ctx.finish({ title: 'Field cleared!', won: true, lines: [`Splits: ${splits}`, `Hearts left: ${hearts}`, `Score: ${score}`] }) + ); + } + } + const info = () => ctx.hud.info(`${rocks.length} rock${rocks.length === 1 ? '' : 's'} left`); + + // keyboard: held keys come from ctx.keys; stop the page scrolling + ctx.listen(window, 'keydown', (e) => { + if ([' ', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) e.preventDefault(); + if (e.key === ' ') fire(); + }); + // tap or click the field: turn to face it and shoot + ctx.listen(canvas, 'pointerdown', (e) => { + e.preventDefault(); + const p = point(e); + ship.angle = Math.atan2(p.y - ship.y, p.x - ship.x); + cooldown = 0; + fire(); + }); + // on-screen buttons for touch + const pad = document.createElement('div'); + pad.className = 'demo-row asteroids-pad'; + for (const [id, label] of [['left', '⟲'], ['thrust', '▲'], ['right', '⟳'], ['fire', '● Fire']]) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn asteroids-${id}`; + b.dataset.act = id; + b.textContent = label; + b.setAttribute('aria-label', id); + pad.append(b); + } + stage.append(pad); + const release = (e) => { + const b = e.target.closest('[data-act]'); + if (b) held.delete(b.dataset.act); + }; + ctx.listen(pad, 'pointerdown', (e) => { + const b = e.target.closest('[data-act]'); + if (!b) return; + e.preventDefault(); + held.add(b.dataset.act); + if (b.dataset.act === 'fire') fire(); + }); + for (const type of ['pointerup', 'pointerleave', 'pointercancel']) ctx.listen(pad, type, release, true); + addStyle(); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + info(); + ctx.hud.msg('Shoot a rock to split it into factors'); + + ctx.loop((dt, t) => { + if (!over) step(dt); + draw(t); + }); + + function step(dt) { + const k = ctx.keys; + if (k.has('ArrowLeft') || k.has('a') || held.has('left')) ship.angle -= TURN * dt; + if (k.has('ArrowRight') || k.has('d') || held.has('right')) ship.angle += TURN * dt; + ship.thrust = k.has('ArrowUp') || k.has('w') || held.has('thrust'); + if (ship.thrust) { + ship.vx += Math.cos(ship.angle) * THRUST * dt; + ship.vy += Math.sin(ship.angle) * THRUST * dt; + } + // drag, so the ship settles down on its own (easier for small hands) + const drag = Math.exp(-0.9 * dt); + ship.vx *= drag; + ship.vy *= drag; + ship.x += ship.vx * dt; + ship.y += ship.vy * dt; + wrap(ship); + ship.inv = Math.max(0, ship.inv - dt); + cooldown = Math.max(0, cooldown - dt); + if (k.has(' ') || held.has('fire')) fire(); + + for (const r of rocks) { + r.x += r.vx * dt; + r.y += r.vy * dt; + r.spin += dt * 0.4; + wrap(r, r.r); + } + for (const b of bullets) { + b.x += b.vx * dt; + b.y += b.vy * dt; + b.life -= dt; + wrap(b); + const r = rocks.find((x) => Math.hypot(x.x - b.x, x.y - b.y) < x.r); + if (r) { + b.life = 0; + hit(r, b); + if (over) return; + } + } + bullets = bullets.filter((b) => b.life > 0); + // bumping a rock costs a heart; then a short safe time in the middle + if (ship.inv <= 0 && rocks.some((r) => Math.hypot(r.x - ship.x, r.y - ship.y) < r.r + 10)) { + hearts--; + ctx.hud.hearts(hearts); + if (hearts <= 0) { + over = true; + const left = rocks.map((r) => r.value).join(', '); + return ctx.after(600, () => ctx.finish({ title: 'Ship down!', lines: [`Splits: ${splits}`, `Rocks left: ${left}`, `Score: ${score}`] })); + } + ctx.hud.msg('Ouch! Keep away from the rocks', 'bad'); + Object.assign(ship, { x: W / 2, y: H / 2, vx: 0, vy: 0, inv: 2.5 }); + } + } + + function draw(t) { + const ink = ctx.color('--ink'); + g.fillStyle = ctx.color('--surface'); + g.fillRect(0, 0, W, H); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.lineWidth = 2; + for (const r of rocks) { + g.beginPath(); + r.bumps.forEach((k, i) => { + const a = r.spin + (i / r.bumps.length) * Math.PI * 2; + g[i ? 'lineTo' : 'moveTo'](r.x + Math.cos(a) * r.r * k, r.y + Math.sin(a) * r.r * k); + }); + g.closePath(); + // on Easy, primes are gold: a hint that they won't split + g.fillStyle = ctx.level === 'easy' && ctx.isPrime(r.value) ? ctx.color('--gold-bg') : ctx.color('--soft-2'); + g.fill(); + g.strokeStyle = ctx.color('--edge'); + g.stroke(); + g.fillStyle = ink; + g.font = `900 ${Math.round(12 + r.r * 0.45)}px system-ui, sans-serif`; + g.fillText(String(r.value), r.x, r.y + 1); + } + g.fillStyle = ctx.color('--accent'); + for (const b of bullets) { + g.beginPath(); + g.arc(b.x, b.y, 4, 0, Math.PI * 2); + g.fill(); + } + // ship (blinks while safe) + if (ship.inv > 0 && Math.floor(t / 120) % 2) return; + g.save(); + g.translate(ship.x, ship.y); + g.rotate(ship.angle); + if (ship.thrust && !over) { + g.fillStyle = '#f2c14e'; + g.beginPath(); + g.moveTo(-10, -6); + g.lineTo(-22 - Math.random() * 6, 0); + g.lineTo(-10, 6); + g.fill(); + } + g.fillStyle = ctx.color('--accent'); + g.strokeStyle = ctx.color('--edge'); + g.beginPath(); + g.moveTo(18, 0); + g.lineTo(-12, -12); + g.lineTo(-6, 0); + g.lineTo(-12, 12); + g.closePath(); + g.fill(); + g.stroke(); + g.restore(); + } + + return { + state: () => ({ + hearts, + score, + splits, + over, + ship: { x: ship.x, y: ship.y, angle: ship.angle, inv: ship.inv }, + rocks: rocks.map((r) => ({ x: r.x, y: r.y, r: r.r, value: r.value, prime: ctx.isPrime(r.value) })), + bullets: bullets.length, + }), + }; +} + +function addStyle() { + if (document.getElementById('asteroids-style')) return; + const s = document.createElement('style'); + s.id = 'asteroids-style'; + s.textContent = ` + .asteroids-pad { gap: 10px; user-select: none; -webkit-user-select: none; } + .asteroids-pad .btn { min-width: 64px; min-height: 52px; font-size: 1.4rem; font-weight: 900; touch-action: none; } + .asteroids-pad .asteroids-fire { background: var(--accent); color: var(--accent-ink); min-width: 110px; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/battleship.js b/apps/web/src/demos/games/battleship.js new file mode 100644 index 0000000..9e607c0 --- /dev/null +++ b/apps/web/src/demos/games/battleship.js @@ -0,0 +1,210 @@ +// Fact Battleship: both fleets hide on a grid whose rows and columns are numbers, +// so each square is a product. Tap a square in the CPU's waters, then type its +// product to fire; a wrong product means the shot goes wide and the turn passes. +// The CPU fires back at your fleet. Sink all its ships first. + +const SETUP = { + easy: { rows: () => [2, 3, 4, 5, 10], cols: () => [1, 2, 3, 4, 5], ships: [3, 2, 2] }, + medium: { rows: () => [2, 3, 4, 5, 6], cols: (ctx) => sample(ctx, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], 6), ships: [3, 3, 2] }, + hard: { rows: (ctx) => sample(ctx, [3, 4, 6, 7, 8, 9, 11, 12], 6), cols: (ctx) => sample(ctx, [2, 3, 4, 5, 6, 7, 8, 9, 11, 12], 6), ships: [4, 3, 2, 2] }, +}; +const sample = (ctx, list, n) => ctx.shuffle(list).slice(0, n).sort((a, b) => a - b); +const key = (r, c) => `${r},${c}`; + +export function mount(stage, ctx) { + const set = SETUP[ctx.level]; + const rows = set.rows(ctx); + const cols = set.cols(ctx); + const R = rows.length; + const C = cols.length; + const fleet = () => placeShips(ctx, R, C, set.ships); + const enemy = { ships: fleet(), shots: new Map() }; // shots: key -> 'hit' | 'miss' + const mine = { ships: fleet(), shots: new Map() }; + let turn = 'you'; // 'you' (pick a square) | 'aim' (type its product) | 'cpu' | 'over' + let target = null; // [r, c] + let score = 0; + let right = 0; + let wrong = 0; + + addStyle(); + const makeBoard = (label, cls) => { + const box = document.createElement('div'); + box.className = `bs-side ${cls}`; + box.innerHTML = `

    ${label}

    `; + const grid = document.createElement('div'); + grid.className = 'bs-grid'; + grid.style.setProperty('--c', C); + grid.insertAdjacentHTML('beforeend', '×' + cols.map((c) => `${c}`).join('')); + rows.forEach((rv, r) => { + grid.insertAdjacentHTML('beforeend', `${rv}`); + cols.forEach((_, c) => grid.insertAdjacentHTML('beforeend', ``)); + }); + box.append(grid); + return { box, cell: (r, c) => grid.querySelector(`[data-r="${r}"][data-c="${c}"]`), grid }; + }; + const them = makeBoard('CPU’s waters: fire here', 'bs-them'); + const us = makeBoard('Your fleet', 'bs-us'); + const padBox = document.createElement('div'); + const pad = ctx.keypad(padBox, { onSubmit: fire, prompt: 'Pick a square' }); + const row = document.createElement('div'); + row.className = 'demo-row'; + row.append(them.box, padBox, us.box); + stage.append(row); + + const shipAt = (side, r, c) => side.ships.find((s) => s.cells.some(([sr, sc]) => sr === r && sc === c)); + const sunk = (side, ship) => ship.cells.every(([r, c]) => side.shots.get(key(r, c)) === 'hit'); + const allSunk = (side) => side.ships.every((s) => sunk(side, s)); + + function render() { + for (const [side, view, showShips] of [[enemy, them, false], [mine, us, true]]) { + for (let r = 0; r < R; r++) { + for (let c = 0; c < C; c++) { + const b = view.cell(r, c); + const shot = side.shots.get(key(r, c)); + const ship = shipAt(side, r, c); + b.className = 'bs-cell'; + if (showShips && ship) b.classList.add('ship'); + if (shot) b.classList.add(shot); + if (shot === 'hit' && sunk(side, ship)) b.classList.add('sunk'); + if (side === enemy && target && target[0] === r && target[1] === c) b.classList.add('aim'); + b.textContent = shot === 'hit' ? '💥' : shot === 'miss' ? '•' : ''; + b.disabled = side === mine || !!shot || turn === 'cpu' || turn === 'over'; + } + } + } + pad.enable(turn === 'aim'); + ctx.hud.info(`Ships left: yours ${mine.ships.filter((s) => !sunk(mine, s)).length}, CPU ${enemy.ships.filter((s) => !sunk(enemy, s)).length}`); + } + + function aim(r, c) { + if (turn !== 'you' && turn !== 'aim') return; + target = [r, c]; + turn = 'aim'; + pad.clear(); + pad.prompt(`${rows[r]} × ${cols[c]} =`); + ctx.hud.msg('Type the product to fire!'); + render(); + } + + function fire(v) { + if (turn !== 'aim') return; + const [r, c] = target; + const answer = rows[r] * cols[c]; + target = null; + pad.prompt('Pick a square'); + if (v !== answer) { + wrong++; + ctx.hud.msg(`${rows[r]} × ${cols[c]} = ${answer}, not ${v}. Your shot went wide!`, 'bad'); + } else { + right++; + const ship = shipAt(enemy, r, c); + enemy.shots.set(key(r, c), ship ? 'hit' : 'miss'); + if (ship) score += 10; + if (ship && sunk(enemy, ship)) score += 20; + ctx.hud.score(score); + ctx.hud.msg(`${rows[r]} × ${cols[c]} = ${answer} ✓ ${!ship ? 'Splash, a miss' : sunk(enemy, ship) ? 'Hit and SUNK! 🚢' : 'Hit! 💥'}`, ship ? 'good' : ''); + if (allSunk(enemy)) return end(true); + } + turn = 'cpu'; + render(); + ctx.after(1300, cpuFire); + } + + // CPU: after a hit it tries the squares next to it (not on Easy), otherwise random + function cpuFire() { + if (turn !== 'cpu') return; + const open = []; + for (let r = 0; r < R; r++) for (let c = 0; c < C; c++) if (!mine.shots.has(key(r, c))) open.push([r, c]); + let pickFrom = open; + if (ctx.level !== 'easy') { + const near = open.filter(([r, c]) => + [[1, 0], [-1, 0], [0, 1], [0, -1]].some(([dr, dc]) => { + const s = mine.shots.get(key(r + dr, c + dc)); + return s === 'hit' && !sunk(mine, shipAt(mine, r + dr, c + dc)); + }) + ); + if (near.length) pickFrom = near; + } + const [r, c] = ctx.pick(pickFrom); + const ship = shipAt(mine, r, c); + mine.shots.set(key(r, c), ship ? 'hit' : 'miss'); + ctx.hud.msg(`CPU fires at ${rows[r]} × ${cols[c]} = ${rows[r] * cols[c]}: ${!ship ? 'miss' : sunk(mine, ship) ? 'it sank your ship!' : 'hit!'}`, ship ? 'bad' : ''); + if (allSunk(mine)) return end(false); + turn = 'you'; + render(); + } + + function end(won) { + turn = 'over'; + render(); + for (const s of enemy.ships) for (const [r, c] of s.cells) them.cell(r, c).classList.add('ship'); + ctx.after(1400, () => + ctx.finish({ title: won ? 'You sank the whole fleet!' : 'The CPU sank your fleet', won, lines: [`${right} shots aimed right, ${wrong} went wide`, `Score: ${score}`] }) + ); + } + + ctx.listen(them.grid, 'click', (e) => { + const b = e.target.closest('.bs-cell'); + if (b && !b.disabled) aim(Number(b.dataset.r), Number(b.dataset.c)); + }); + + ctx.hud.msg('Tap a square in the CPU’s waters'); + ctx.hud.score(0); + render(); + + const shipsOf = (side) => side.ships.map((s) => ({ cells: s.cells.map((x) => [...x]), sunk: sunk(side, s) })); + return { + state: () => ({ rows, cols, turn, target, score, right, wrong, enemy: { ships: shipsOf(enemy), shots: Object.fromEntries(enemy.shots) }, mine: { ships: shipsOf(mine), shots: Object.fromEntries(mine.shots) } }), + }; +} + +// ships in straight lines that don't overlap or touch sideways +function placeShips(ctx, R, C, lengths) { + for (let attempt = 0; attempt < 200; attempt++) { + const taken = new Set(); + const ships = []; + for (const len of lengths) { + const spots = []; + for (let r = 0; r < R; r++) { + for (let c = 0; c < C; c++) { + for (const [dr, dc] of [[0, 1], [1, 0]]) { + const cells = Array.from({ length: len }, (_, k) => [r + dr * k, c + dc * k]); + const ok = cells.every(([rr, cc]) => rr < R && cc < C && !taken.has(key(rr, cc))); + if (ok) spots.push(cells); + } + } + } + if (!spots.length) break; + const cells = ctx.pick(spots); + // keep a one-square gap around each ship + for (const [r, c] of cells) for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) taken.add(key(r + dr, c + dc)); + ships.push({ cells }); + } + if (ships.length === lengths.length) return ships; + } + // tiny boards: allow touching + return lengths.map((len, i) => ({ cells: Array.from({ length: len }, (_, k) => [i, k]) })); +} + +function addStyle() { + if (document.getElementById('battleship-style')) return; + const s = document.createElement('style'); + s.id = 'battleship-style'; + s.textContent = ` + .bs-side { display: flex; flex-direction: column; align-items: center; gap: 4px; } + .bs-side h3 { margin: 0; font-size: 1rem; } + .bs-grid { --cell: min(48px, calc((100vw - 48px) / (var(--c) + 1))); display: grid; grid-template-columns: repeat(calc(var(--c) + 1), var(--cell)); gap: 3px; } + .bs-us .bs-grid { --cell: min(34px, calc((100vw - 48px) / (var(--c) + 1))); } + .bs-head { display: flex; align-items: center; justify-content: center; font-weight: 900; color: var(--accent); aspect-ratio: 1; font-size: 1.05rem; } + .bs-us .bs-head { font-size: 0.85rem; color: var(--muted); } + .bs-cell { aspect-ratio: 1; border: 2px solid var(--board-grid); border-radius: 6px; background: color-mix(in srgb, #3a7bd5 22%, var(--surface)); padding: 0; font: inherit; font-size: 1.1rem; cursor: pointer; color: var(--ink); } + .bs-cell:disabled { cursor: default; } + .bs-cell.ship { background: color-mix(in srgb, #5d6675 75%, var(--surface)); border-color: #5d6675; } + .bs-cell.miss { background: var(--soft-2); } + .bs-cell.hit { background: var(--gold-bg); border-color: var(--danger); } + .bs-cell.sunk { background: var(--bad-bg); } + .bs-cell.aim { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); background: var(--accent-soft); } + .bs-us .bs-cell { font-size: 0.8rem; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/bingo.js b/apps/web/src/demos/games/bingo.js new file mode 100644 index 0000000..aba2942 --- /dev/null +++ b/apps/web/src/demos/games/bingo.js @@ -0,0 +1,189 @@ +// Times Bingo (like Bingo): the caller reads out facts; tap their answers on +// your 5 × 5 card. Any fact called so far can still be marked (forgiving), and +// a wrong tap shows the right fact. Five in a row, column or diagonal: Bingo! + +const GAP = { easy: 8000, medium: 7000, hard: 6000 }; // ms between calls +const MAX_CALLS = 45; +const LINES = (() => { + const out = []; + for (let i = 0; i < 5; i++) { + out.push([0, 1, 2, 3, 4].map((j) => i * 5 + j)); + out.push([0, 1, 2, 3, 4].map((j) => j * 5 + i)); + } + out.push([0, 6, 12, 18, 24], [4, 8, 12, 16, 20]); + return out; +})(); + +export function mount(stage, ctx) { + addStyle(); + // every product in the level's tables, and the facts that make each one + const byValue = new Map(); + for (const a of ctx.L.tables) { + for (let b = 1; b <= ctx.L.upTo; b++) { + const n = a * b; + if (!byValue.has(n)) byValue.set(n, []); + byValue.get(n).push([a, b]); + } + } + // prefer facts without a × 1 (5 × 4 rather than 20 × 1) when there is one + const real = (n) => byValue.get(n).filter(([a, b]) => a > 1 && b > 1); + const factFor = (n) => { + const [a, b] = ctx.pick(real(n).length ? real(n) : byValue.get(n)); + return ctx.rng() < 0.5 ? { a, b, answer: n, text: `${a} × ${b}` } : { a: b, b: a, answer: n, text: `${b} × ${a}` }; + }; + + // the card: 24 different answers around a free centre (Easy has only 23 + // different products, so a spare square there is free too) + const all = ctx.shuffle([...byValue.keys()]); + const values = [...all.filter((n) => real(n).length), ...all.filter((n) => !real(n).length)].slice(0, 24); + const card = []; + for (let i = 0; i < 25; i++) { + if (i === 12) card.push({ value: null, free: true, marked: true }); + else { + const value = values.shift(); + card.push(value == null ? { value: null, free: true, marked: true } : { value, free: false, marked: false }); + } + } + const onCard = new Set(card.filter((c) => !c.free).map((c) => c.value)); + const offCard = [...byValue.keys()].filter((n) => !onCard.has(n)); + + const top = document.createElement('div'); + top.className = 'bingo-top'; + top.innerHTML = `
    The caller says…
    `; + const grid = document.createElement('div'); + grid.className = 'bingo-card'; + card.forEach((c, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'bingo-cell' + (c.free ? ' free marked' : ''); + b.dataset.i = i; + b.textContent = c.free ? '★' : c.value; + b.setAttribute('aria-label', c.free ? 'Free square' : String(c.value)); + grid.append(b); + }); + const past = document.createElement('div'); + past.className = 'bingo-past'; + stage.append(top, grid, past); + + const calls = []; // every fact called so far (newest last) + let current = null; + let score = 0; + let wrong = 0; + let over = false; + let stopTimer = null; + const called = (n) => calls.some((f) => f.answer === n); + + // mostly numbers that are on the card and not called yet; sometimes one that isn't + function nextCall() { + if (over) return; + if (calls.length >= MAX_CALLS) return end(false); + const fresh = [...onCard].filter((n) => !called(n)); + const n = offCard.length && (ctx.rng() < 0.2 || !fresh.length) ? ctx.pick(offCard) : ctx.pick(fresh); + current = factFor(n); + calls.push(current); + top.querySelector('.bingo-call').textContent = `${current.text}`; + top.querySelector('.bingo-caller').classList.remove('pop'); + void top.offsetWidth; // restart the pop animation + top.querySelector('.bingo-caller').classList.add('pop'); + top.querySelector('.bingo-timer i').style.animationDuration = `${GAP[ctx.level]}ms`; + past.textContent = calls.length > 1 ? `Called before: ${calls.slice(-6, -1).map((f) => f.text).reverse().join(' · ')}` : ''; + ctx.hud.info(`Call ${calls.length} of ${MAX_CALLS}`); + if (stopTimer) stopTimer(); + stopTimer = ctx.after(GAP[ctx.level], nextCall); + } + + function tap(i) { + const c = card[i]; + if (over || !c || c.marked) return; + if (called(c.value)) { + c.marked = true; + grid.children[i].classList.add('marked'); + const f = calls.findLast((x) => x.answer === c.value); + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${f.text} = ${c.value} ✓`, 'good'); + if (hasBingo()) end(true); + return; + } + wrong++; + score = Math.max(0, score - 2); + ctx.hud.score(score); + grid.children[i].classList.add('shake'); + ctx.after(400, () => grid.children[i].classList.remove('shake')); + ctx.hud.msg(current ? `${current.text} = ${current.answer}, not ${c.value}` : `${c.value} hasn't been called yet`, 'bad'); + } + + const hasBingo = () => LINES.some((line) => line.every((k) => card[k].marked)); + + function end(won) { + over = true; + const line = LINES.find((l) => l.every((k) => card[k].marked)); + if (line) for (const k of line) grid.children[k].classList.add('line'); + ctx.after(won ? 900 : 300, () => + ctx.finish({ + title: won ? 'BINGO!' : 'Out of calls', + won, + lines: [won ? `Bingo after ${calls.length} calls` : `No line after ${MAX_CALLS} calls`, `Squares marked: ${card.filter((c) => c.marked && !c.free).length}`, `Wrong taps: ${wrong}`, `Score: ${score}`], + }) + ); + } + + ctx.listen(grid, 'click', (e) => { + const b = e.target.closest('.bingo-cell'); + if (b) tap(Number(b.dataset.i)); + }); + // the Next button skips the wait (for quick kids) + ctx.listen(top.querySelector('.bingo-next'), 'click', () => !over && nextCall()); + // arrow keys move between squares (Enter / Space presses the focused square) + ctx.listen(grid, 'keydown', (e) => { + const i = Number(e.target.dataset?.i); + const d = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -5, ArrowDown: 5 }[e.key]; + if (Number.isNaN(i) || !d) return; + e.preventDefault(); + const j = i + d; + if (j >= 0 && j < 25 && !(Math.abs(d) === 1 && Math.floor(j / 5) !== Math.floor(i / 5))) grid.children[j].focus(); + }); + + ctx.hud.msg('Tap the answer if it’s on your card'); + ctx.hud.score(0); + ctx.after(1200, nextCall); + + return { + state: () => ({ + card: card.map((c) => ({ ...c })), + current: current && { ...current }, + calls: calls.map((f) => f.answer), + score, + wrong, + over, + bingo: hasBingo(), + }), + }; +} + +function addStyle() { + if (document.getElementById('bingo-style')) return; + const s = document.createElement('style'); + s.id = 'bingo-style'; + s.textContent = ` + .bingo-top { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; justify-content: center; } + .bingo-caller { display: flex; flex-direction: column; align-items: center; min-width: 170px; padding: 8px 18px; border: 2px solid var(--edge); border-radius: 14px; background: var(--gold-bg); color: var(--ink); } + .bingo-caller small { color: var(--muted); font-weight: 700; } + .bingo-call { font-size: 2rem; font-weight: 900; } + .bingo-caller.pop { animation: bingo-pop 0.4s; } + .bingo-timer { display: block; width: 100%; height: 5px; margin-top: 4px; border-radius: 3px; background: var(--soft-2); overflow: hidden; } + .bingo-timer i { display: block; height: 100%; background: var(--accent); transform-origin: left; } + .bingo-caller.pop .bingo-timer i { animation: bingo-timer linear forwards; } + @keyframes bingo-timer { from { transform: scaleX(1); } to { transform: scaleX(0); } } + @keyframes bingo-pop { 40% { transform: scale(1.12); } } + .bingo-card { display: grid; grid-template-columns: repeat(5, minmax(0, 72px)); gap: 6px; width: 100%; justify-content: center; } + .bingo-cell { aspect-ratio: 1; border: 2px solid var(--edge); border-radius: 10px; background: var(--paper); color: var(--ink); font: inherit; font-size: 1.4rem; font-weight: 900; cursor: pointer; box-shadow: 0 3px 0 var(--edge-shadow); } + .bingo-cell:hover { border-color: var(--accent); } + .bingo-cell.marked { background: var(--accent); color: #fff; cursor: default; } + .bingo-cell.free { background: var(--soft-2); color: var(--ink); } + .bingo-cell.line { background: var(--good); color: #fff; } + .bingo-cell.shake { animation: shake 0.35s; border-color: var(--bad); } + .bingo-past { color: var(--muted); font-weight: 700; text-align: center; min-height: 1.3em; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/breakout.js b/apps/web/src/demos/games/breakout.js new file mode 100644 index 0000000..96730a5 --- /dev/null +++ b/apps/web/src/demos/games/breakout.js @@ -0,0 +1,156 @@ +// Product Breakout (like Breakout): the paddle shows a number; the ball only +// breaks bricks whose fact equals it (other bricks just bounce it). +// A reference demo for a canvas game: ctx.canvas for drawing, ctx.loop for the +// frame loop, ctx.keys for held keys, ctx.listen for the pointer. + +const W = 640; +const H = 480; +const COLS = 8; +const ROWS = 5; +const SPEED = { easy: 240, medium: 300, hard: 360 }; // ball, px per second + +export function mount(stage, ctx) { + const { ctx: g, point } = ctx.canvas(stage, W, H); + const bw = W / COLS; + const bh = 34; + let target = 0; + let bricks = []; + let score = 0; + let hearts = 3; + const paddle = { x: W / 2, w: 110, h: 16, y: H - 34 }; + const ball = { x: W / 2, y: H - 60, vx: 0, vy: 0, r: 8, stuck: true }; + + // a new target number and a wall where some bricks equal it + function newWall() { + const f = ctx.fact(); + target = f.answer; + bricks = []; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + let fact; + if (ctx.rng() < 0.3) fact = factFor(target) || ctx.fact(); + else fact = ctx.fact(); + bricks.push({ x: c * bw, y: 50 + r * bh, fact, alive: true }); + } + } + if (!bricks.some((b) => b.fact.answer === target)) bricks[ctx.randInt(0, bricks.length - 1)].fact = factFor(target); + ctx.hud.info(`Break the bricks that equal ${target}`); + } + // a fact for this number from the level's tables (e.g. 24 → 4 × 6) + function factFor(n) { + const pairs = []; + for (const a of ctx.L.tables) if (n % a === 0 && n / a >= 1 && n / a <= ctx.L.upTo) pairs.push([a, n / a]); + if (!pairs.length) return null; + const [a, b] = ctx.pick(pairs); + return ctx.rng() < 0.5 ? { a, b, answer: n, text: `${a} × ${b}` } : { a: b, b: a, answer: n, text: `${b} × ${a}` }; + } + const left = () => bricks.filter((b) => b.alive && b.fact.answer === target).length; + + function launch() { + if (!ball.stuck) return; + ball.stuck = false; + const a = (-Math.PI / 2) + (ctx.rng() - 0.5) * 0.8; + ball.vx = Math.cos(a) * SPEED[ctx.level]; + ball.vy = Math.sin(a) * SPEED[ctx.level]; + } + + ctx.listen(stage.querySelector('canvas'), 'pointermove', (e) => (paddle.x = point(e).x)); + ctx.listen(stage.querySelector('canvas'), 'pointerdown', (e) => ((paddle.x = point(e).x), launch())); + ctx.listen(window, 'keydown', (e) => { + if (e.key === ' ' || e.key === 'ArrowUp') { + e.preventDefault(); + launch(); + } + }); + + newWall(); + ctx.hud.hearts(hearts); + ctx.hud.msg('Click or press Space to launch'); + + ctx.loop((dt) => { + // paddle + if (ctx.keys.has('ArrowLeft')) paddle.x -= 480 * dt; + if (ctx.keys.has('ArrowRight')) paddle.x += 480 * dt; + paddle.x = Math.max(paddle.w / 2, Math.min(W - paddle.w / 2, paddle.x)); + // ball + if (ball.stuck) { + ball.x = paddle.x; + ball.y = paddle.y - ball.r - 2; + } else { + ball.x += ball.vx * dt; + ball.y += ball.vy * dt; + if (ball.x < ball.r || ball.x > W - ball.r) ball.vx *= -1; + if (ball.y < ball.r) ball.vy = Math.abs(ball.vy); + // paddle: angle depends on where it hits + if (ball.vy > 0 && ball.y + ball.r >= paddle.y && ball.y < paddle.y + paddle.h && Math.abs(ball.x - paddle.x) < paddle.w / 2 + ball.r) { + const off = (ball.x - paddle.x) / (paddle.w / 2); + const sp = Math.hypot(ball.vx, ball.vy); + const a = -Math.PI / 2 + off * 1.0; + ball.vx = Math.cos(a) * sp; + ball.vy = Math.sin(a) * sp; + } + // bricks + for (const b of bricks) { + if (!b.alive || ball.x + ball.r < b.x || ball.x - ball.r > b.x + bw || ball.y + ball.r < b.y || ball.y - ball.r > b.y + bh) continue; + ball.vy *= -1; + if (b.fact.answer === target) { + b.alive = false; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${b.fact.text} = ${target} ✓`, 'good'); + if (!left()) { + ctx.toast(`🧱 Every ${target} broken!`, '+25 bonus points'); + score += 25; + ctx.hud.score(score); + newWall(); + ball.stuck = true; + } + } else ctx.hud.msg(`${b.fact.text} = ${b.fact.answer}, not ${target}`, 'bad'); + break; + } + // missed + if (ball.y > H + ball.r) { + hearts--; + ctx.hud.hearts(hearts); + if (hearts <= 0) return ctx.finish({ title: 'Out of balls!', lines: [`Score: ${score}`, `Last number: ${target}`] }); + ball.stuck = true; + ctx.hud.msg('Missed! Click or Space to launch again', 'bad'); + } + } + draw(); + }); + + function draw() { + g.fillStyle = ctx.color('--surface') || '#fff'; + g.fillRect(0, 0, W, H); + g.fillStyle = ctx.color('--ink') || '#222'; + g.font = '900 22px system-ui, sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText(`Break: ${target}`, W / 2, 24); + g.font = '800 15px system-ui, sans-serif'; + for (const b of bricks) { + if (!b.alive) continue; + g.fillStyle = b.fact.answer === target && ctx.level === 'easy' ? ctx.color('--gold-bg') : ctx.color('--soft-2'); + g.fillRect(b.x + 2, b.y + 2, bw - 4, bh - 4); + g.strokeStyle = ctx.color('--edge'); + g.strokeRect(b.x + 2, b.y + 2, bw - 4, bh - 4); + g.fillStyle = ctx.color('--ink'); + g.fillText(b.fact.text, b.x + bw / 2, b.y + bh / 2 + 1); + } + // paddle with the number + g.fillStyle = ctx.color('--accent') || '#e4572e'; + g.beginPath(); + g.roundRect(paddle.x - paddle.w / 2, paddle.y, paddle.w, paddle.h + 6, 8); + g.fill(); + g.fillStyle = '#fff'; + g.fillText(String(target), paddle.x, paddle.y + 11); + // ball + g.fillStyle = ctx.color('--ink'); + g.beginPath(); + g.arc(ball.x, ball.y, ball.r, 0, Math.PI * 2); + g.fill(); + } + + return { state: () => ({ target, left: left(), score, hearts, stuck: ball.stuck, ball: { ...ball }, paddle: { ...paddle } }) }; +} diff --git a/apps/web/src/demos/games/clicker.js b/apps/web/src/demos/games/clicker.js new file mode 100644 index 0000000..62b7722 --- /dev/null +++ b/apps/web/src/demos/games/clicker.js @@ -0,0 +1,220 @@ +// Multiplier Bakery (like an idle / clicker game): click the cookie to bake. +// Ovens bake by themselves: ovens × cookies per oven, every second. To buy an +// oven or a better recipe you must say the new per-second total (a real +// times-table fact). Bake the goal to finish. + +const GOAL = { easy: 1000, medium: 1000, hard: 1500 }; +// cookies per oven, one recipe step at a time (all from the level's tables) +const RECIPES = { easy: [2, 5, 10], medium: [2, 3, 4, 5, 6], hard: [3, 6, 7, 8, 9, 12] }; + +export function mount(stage, ctx) { + addStyle(); + const goal = GOAL[ctx.level]; + const recipes = RECIPES[ctx.level]; + const maxOvens = ctx.L.upTo; + let cookies = 0; // to spend + let baked = 0; // all-time, for the goal + let ovens = 0; + let recipe = 0; // index into recipes + let asking = null; // { kind, text, answer } while buying + let right = 0; + let wrong = 0; + let clicks = 0; + let time = 0; + let done = false; + + const per = () => recipes[recipe]; + const rate = () => ovens * per(); + const ovenCost = () => 10 + 15 * ovens; + const recipeCost = () => 25 * (recipe + 1) * (recipe + 2); + + const box = document.createElement('div'); + box.className = 'bake'; + box.innerHTML = ` +
    + +
    cookies
    +
    +
    + +
    +
    + + + +
    `; + stage.append(box); + const $ = (s) => box.querySelector(s); + const pad = ctx.keypad($('.bake-pad'), { onSubmit: answer }); + pad.enable(false); + + function render() { + $('.bake-count strong').textContent = Math.floor(cookies); + $('.bake-rate').textContent = ovens ? `${ovens} oven${ovens > 1 ? 's' : ''} × ${per()} = ${rate()} per second` : 'No ovens yet: click the cookie!'; + $('.bake-goal i').style.width = `${Math.min(100, (baked / goal) * 100)}%`; + $('.bake-goal-text').textContent = `Baked ${Math.floor(baked)} of ${goal}`; + const oven = $('[data-kind="oven"]'); + oven.innerHTML = ovens >= maxOvens ? `🔥 Ovens: ${ovens} (kitchen full)` : `🔥 Buy an oven (you have ${ovens})${ovenCost()} 🍪`; + oven.disabled = Boolean(asking) || ovens >= maxOvens || cookies < ovenCost(); + oven.hidden = Boolean(asking); // the question takes their place while buying + const rec = $('[data-kind="recipe"]'); + const next = recipes[recipe + 1]; + rec.innerHTML = !next ? `📜 Best recipe: ${per()} per oven` : !ovens ? `📜 Better recipe (buy an oven first)` : `📜 Better recipe ${per()} → ${next} per oven${recipeCost()} 🍪`; + rec.disabled = Boolean(asking) || !next || !ovens || cookies < recipeCost(); + rec.hidden = Boolean(asking); + ctx.hud.score(Math.floor(baked)); + ctx.hud.info(`${rate()} / sec`); + } + + function bake() { + if (done) return; + cookies++; + baked++; + clicks++; + const c = $('.bake-cookie'); + c.classList.remove('pop'); + void c.offsetWidth; // restart the squish animation + c.classList.add('pop'); + check(); + render(); + } + + // buying asks for the new per-second total: that's the multiplication + function buy(kind) { + if (done || asking) return; + if (kind === 'oven' && (ovens >= maxOvens || cookies < ovenCost())) return; + if (kind === 'recipe' && (!recipes[recipe + 1] || !ovens || cookies < recipeCost())) return; + const a = kind === 'oven' ? ovens + 1 : ovens; + const b = kind === 'oven' ? per() : recipes[recipe + 1]; + asking = { kind, a, b, text: `${a} × ${b}`, answer: a * b }; + $('.bake-ask-title').innerHTML = `${kind === 'oven' ? 'One more oven!' : 'A better recipe!'} With ${a} oven${a > 1 ? 's' : ''} baking ${b} each, how many cookies a second?`; + pad.prompt(`${a} × ${b} =`); + pad.clear(); + pad.enable(true); + $('.bake-ask').hidden = false; + render(); + } + + function answer(v) { + if (!asking) return; + const q = asking; + if (v === q.answer) { + right++; + if (q.kind === 'oven') { + cookies -= ovenCost(); + ovens++; + } else { + cookies -= recipeCost(); + recipe++; + } + ctx.hud.msg(`${q.text} = ${q.answer} ✓ Now ${q.answer} cookies a second!`, 'good'); + } else { + wrong++; + ctx.hud.msg(`${q.text} = ${q.answer}, not ${v}. Try buying again!`, 'bad'); + } + close(); + } + + function close() { + asking = null; + pad.enable(false); + $('.bake-ask').hidden = true; + render(); + } + + function check() { + if (done || baked < goal) return; + done = true; + close(); + ctx.after(500, () => + ctx.finish({ + title: `${goal} cookies baked! 🍪`, + won: true, + lines: [`Time: ${Math.round(time)} seconds`, `Final bakery: ${ovens} ovens × ${per()} = ${rate()} per second`, `Upgrades answered right: ${right}, wrong: ${wrong}`, `Cookies clicked: ${clicks}`], + }) + ); + } + + ctx.listen($('.bake-cookie'), 'click', bake); + ctx.listen(box, 'click', (e) => { + const b = e.target.closest('.bake-buy'); + if (b) buy(b.dataset.kind); + }); + ctx.listen($('.bake-cancel'), 'click', close); + // keys: Space or C bakes, O buys an oven, R a recipe, Esc cancels + ctx.listen(window, 'keydown', (e) => { + const k = e.key.toLowerCase(); + if (k === 'escape' && asking) close(); + else if (asking) return; + else if (k === ' ' && e.target.closest('button')) return; // a focused button handles its own Space + else if ((k === ' ' || k === 'c') && !e.repeat) bake(); + else if (k === 'o') buy('oven'); + else if (k === 'r') buy('recipe'); + else return; + e.preventDefault(); + }); + + let last = -1; + ctx.loop((dt) => { + if (done) return; + time += dt; + cookies += rate() * dt; + baked += rate() * dt; + check(); + // redraw a few times a second, not every frame (the buttons are DOM) + if (Math.floor(time * 5) !== last) { + last = Math.floor(time * 5); + render(); + } + }); + + ctx.hud.msg('Click the cookie! Space works too'); + render(); + + return { + state: () => ({ + cookies: Math.floor(cookies), + baked: Math.floor(baked), + goal, + ovens, + perOven: per(), + rate: rate(), + ovenCost: ovenCost(), + recipeCost: recipes[recipe + 1] ? recipeCost() : null, + nextPer: recipes[recipe + 1] || null, + asking: asking && { ...asking }, + right, + wrong, + done, + }), + }; +} + +function addStyle() { + if (document.getElementById('clicker-style')) return; + const s = document.createElement('style'); + s.id = 'clicker-style'; + s.textContent = ` + .bake { display: flex; flex-wrap: wrap; gap: 20px; justify-content: center; align-items: flex-start; width: 100%; } + .bake-left, .bake-right { display: flex; flex-direction: column; align-items: center; gap: 8px; width: min(100%, 340px); } + .bake-cookie { width: 180px; height: 180px; border-radius: 50%; border: 3px solid var(--edge); background: var(--gold-bg); font-size: 110px; line-height: 1; cursor: pointer; box-shadow: 0 6px 0 var(--edge-shadow); user-select: none; touch-action: manipulation; } + .bake-cookie:active { transform: translateY(3px); box-shadow: 0 3px 0 var(--edge-shadow); } + .bake-cookie.pop { animation: bake-pop 0.15s; } + @keyframes bake-pop { 50% { transform: scale(0.94); } } + .bake-count { font-size: 1.3rem; font-weight: 800; } + .bake-count strong { font-size: 2rem; font-variant-numeric: tabular-nums; } + .bake-rate { font-weight: 800; color: var(--muted); text-align: center; } + .bake-goal { width: 100%; height: 14px; border: 2px solid var(--edge); border-radius: 8px; background: var(--soft-2); overflow: hidden; } + .bake-goal i { display: block; height: 100%; background: var(--good); transition: width 0.2s; } + .bake-goal-text { color: var(--muted); font-weight: 700; } + .bake-buy { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; width: 100%; padding: 12px 14px; border: 2px solid var(--edge); border-radius: 12px; background: var(--paper); color: var(--ink); font: inherit; font-weight: 900; font-size: 1.05rem; text-align: left; cursor: pointer; box-shadow: 0 3px 0 var(--edge-shadow); } + .bake-buy small { color: var(--muted); font-weight: 700; } + .bake-buy b { margin-left: auto; } + .bake-buy:hover:not(:disabled) { border-color: var(--accent); } + .bake-buy:disabled { opacity: 0.5; cursor: not-allowed; } + .bake-ask { display: flex; flex-direction: column; align-items: center; gap: 8px; width: 100%; padding: 10px; border: 2px solid var(--accent); border-radius: 12px; background: var(--accent-soft); box-sizing: border-box; } + .bake-ask-title { text-align: center; font-weight: 700; } + @media (max-width: 560px) { .bake-cookie { width: 140px; height: 140px; font-size: 84px; } .bake { gap: 12px; } } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/connect4.js b/apps/web/src/demos/games/connect4.js new file mode 100644 index 0000000..658f4b6 --- /dev/null +++ b/apps/web/src/demos/games/connect4.js @@ -0,0 +1,225 @@ +// Four in a Row (the classic "Product Game"): the board holds the 36 products +// of 1–9. Two markers sit on a factor strip 1–9. On your turn move ONE marker, +// work out the product of the two markers and tap that square to claim it. +// The CPU does the same. Four in a row (across, down or diagonal) wins. +// The level sets how clever the CPU is and how much help you get. + +const N = 6; +const PRODUCTS = [...new Set(Array.from({ length: 81 }, (_, i) => (Math.floor(i / 9) + 1) * ((i % 9) + 1)))].sort((a, b) => a - b); // 36 of them +const LINES = (() => { + const out = []; + for (let r = 0; r < N; r++) { + for (let c = 0; c < N; c++) { + for (const [dr, dc] of [[0, 1], [1, 0], [1, 1], [1, -1]]) { + const cells = [0, 1, 2, 3].map((k) => [r + dr * k, c + dc * k]); + if (cells.every(([rr, cc]) => rr >= 0 && cc >= 0 && rr < N && cc < N)) out.push(cells.map(([rr, cc]) => rr * N + cc)); + } + } + } + return out; +})(); + +export function mount(stage, ctx) { + const owner = Array(N * N).fill(null); // 'you' | 'cpu' + const markers = { a: 0, b: 0 }; + let turn = 'you'; // 'you' (move a marker) | 'find' (tap the product) | 'cpu' | 'over' + let pending = null; // { a, b, product, tries } + let score = 0; + let winner = null; + + // start on an unclaimed pair that isn't a giveaway + markers.a = ctx.randInt(2, 5); + markers.b = ctx.randInt(2, 5); + + addStyle(); + const board = document.createElement('div'); + board.className = 'c4-board'; + PRODUCTS.forEach((p, i) => board.insertAdjacentHTML('beforeend', ``)); + const strips = document.createElement('div'); + strips.className = 'c4-strips'; + const say = document.createElement('div'); + say.className = 'demo-big c4-say'; + strips.append(say); + for (const m of ['a', 'b']) { + const row = document.createElement('div'); + row.className = `c4-strip c4-${m}`; + row.innerHTML = `${m === 'a' ? '▲' : '▼'}` + [1, 2, 3, 4, 5, 6, 7, 8, 9].map((v) => ``).join(''); + strips.append(row); + } + const wrap = document.createElement('div'); + wrap.className = 'demo-row'; + wrap.append(board, strips); + stage.append(wrap); + + const index = (p) => PRODUCTS.indexOf(p); + const free = (p) => owner[index(p)] === null; + const winLine = (who) => LINES.find((l) => l.every((i) => owner[i] === who)); + // all moves from here: move marker a or b to another number, onto an unclaimed product + const moves = (m = markers) => { + const out = []; + for (const k of ['a', 'b']) { + for (let v = 1; v <= 9; v++) { + if (v === m[k]) continue; + const next = { ...m, [k]: v }; + const p = next.a * next.b; + if (free(p) && !out.some((x) => x.product === p)) out.push({ ...next, product: p }); + } + } + return out; + }; + + function render() { + board.querySelectorAll('.c4-cell').forEach((b, i) => { + b.className = `c4-cell${owner[i] ? ` ${owner[i]}` : ''}`; + b.disabled = turn !== 'find' || !!owner[i]; + }); + for (const b of strips.querySelectorAll('button')) { + const m = b.dataset.m; + const v = Number(b.dataset.v); + b.classList.toggle('on', markers[m] === v); + b.disabled = turn !== 'you'; + } + say.textContent = { you: 'Your turn: move ▲ or ▼', find: `${pending?.a} × ${pending?.b} = ?`, cpu: 'CPU is thinking…', over: '' }[turn]; + const line = winner && winLine(winner); + if (line) for (const i of line) board.children[i].classList.add('win'); + } + + function claim(who, p) { + owner[index(p)] = who; + if (winLine(who)) return end(who); + if (!moves().length || owner.every(Boolean)) return end(null); + turn = who === 'you' ? 'cpu' : 'you'; + render(); + if (turn === 'cpu') ctx.after(900, cpuMove); + } + + function end(who) { + winner = who; + turn = 'over'; + render(); + const title = who === 'you' ? 'Four in a row. You win!' : who === 'cpu' ? 'The CPU got four in a row' : 'No moves left: a draw!'; + ctx.hud.msg(title, who === 'you' ? 'good' : ''); + ctx.after(1500, () => ctx.finish({ title, won: who === 'you', lines: [`You claimed ${owner.filter((o) => o === 'you').length} squares`, `Score: ${score}`] })); + } + + // your move: a marker to a new number; then find the product on the board + function moveMarker(m, v) { + if (turn !== 'you' || markers[m] === v) return; + const next = { ...markers, [m]: v }; + const p = next.a * next.b; + if (!free(p)) return ctx.hud.msg(`${next.a} × ${next.b} = ${p} is taken. Try another number`, 'bad'); + Object.assign(markers, next); + pending = { a: next.a, b: next.b, product: p, tries: 0 }; + turn = 'find'; + ctx.hud.msg(`Find ${next.a} × ${next.b} on the board`); + render(); + } + + function tapSquare(i) { + if (turn !== 'find') return; + const p = PRODUCTS[i]; + const { a, b, product } = pending; + if (p !== product) { + pending.tries++; + ctx.hud.msg(`${a} × ${b} = ${product}, not ${p}`, 'bad'); + // after a slip, point at the right square (straight away on Easy) + if (ctx.level === 'easy' || pending.tries > 1) board.children[index(product)].classList.add('hint'); + return; + } + score += pending.tries ? 5 : 10; + ctx.hud.score(score); + ctx.hud.msg(`${a} × ${b} = ${product} ✓`, 'good'); + board.children[i].classList.remove('hint'); + pending = null; + claim('you', p); + } + + // CPU: win if it can, and don't hand you a winning move (Easy slips up, Hard picks its best line) + function cpuMove() { + if (turn !== 'cpu') return; + const options = moves(); + const wins = (who, mv) => { + const i = index(mv.product); + owner[i] = who; + const w = !!winLine(who); + owner[i] = null; + return w; + }; + const givesWin = (mv) => { + const i = index(mv.product); + owner[i] = 'cpu'; + const bad = moves(mv).some((x) => wins('you', x)); + owner[i] = null; + return bad; + }; + const lineScore = (mv) => { + const i = index(mv.product); + return LINES.filter((l) => l.includes(i) && !l.some((j) => owner[j] === 'you')).reduce((s, l) => s + 1 + l.filter((j) => owner[j] === 'cpu').length ** 2, 0); + }; + const easy = ctx.level === 'easy'; + let mv = easy && ctx.rng() < 0.3 ? null : options.find((x) => wins('cpu', x)); + if (!mv) { + // Easy only looks out for you half the time and then plays anywhere + const safe = easy && ctx.rng() < 0.5 ? [] : options.filter((x) => !givesWin(x)); + const pool = safe.length ? safe : options; + pool.sort((x, y) => lineScore(y) - lineScore(x)); + mv = easy ? ctx.pick(pool) : ctx.level === 'hard' || ctx.rng() < 0.6 ? pool[0] : ctx.pick(pool.slice(0, 4)); + } + markers.a = mv.a; + markers.b = mv.b; + ctx.hud.msg(`CPU moved to ${mv.a} × ${mv.b} = ${mv.product}`); + claim('cpu', mv.product); + } + + ctx.listen(strips, 'click', (e) => { + const b = e.target.closest('button[data-m]'); + if (b) moveMarker(b.dataset.m, Number(b.dataset.v)); + }); + ctx.listen(board, 'click', (e) => { + const b = e.target.closest('.c4-cell'); + if (b) tapSquare(Number(b.dataset.i)); + }); + + ctx.hud.info('You 🔴 vs CPU 🔵'); + ctx.hud.msg(`Markers on ${markers.a} and ${markers.b}. Move one!`); + ctx.hud.score(0); + render(); + + return { + state: () => ({ + products: PRODUCTS, + owner: [...owner], + markers: { ...markers }, + turn, + pending: pending && { ...pending }, + legal: turn === 'you' ? moves() : [], + winner, + score, + }), + }; +} + +function addStyle() { + if (document.getElementById('connect4-style')) return; + const s = document.createElement('style'); + s.id = 'connect4-style'; + s.textContent = ` + .c4-board { --cell: min(60px, calc((100vw - 80px) / 6)); display: grid; grid-template-columns: repeat(6, var(--cell)); gap: 4px; padding: 6px; border: 2px solid var(--edge); border-radius: 12px; background: var(--soft-2); } + .c4-cell { aspect-ratio: 1; border: 2px solid var(--edge); border-radius: 50%; background: var(--surface); color: var(--ink); font: inherit; font-weight: 900; font-size: clamp(0.9rem, 3.6vw, 1.2rem); padding: 0; cursor: pointer; } + .c4-cell:disabled { cursor: default; opacity: 1; } + .c4-cell.you { background: #e4572e; color: #fff; } + .c4-cell.cpu { background: #3a7bd5; color: #fff; } + .c4-cell.hint { box-shadow: 0 0 0 4px var(--gold-line); } + .c4-cell.win { outline: 4px solid var(--star); outline-offset: 1px; } + .c4-strips { display: flex; flex-direction: column; gap: 10px; } + .c4-strip { display: grid; grid-template-columns: 22px repeat(9, min(38px, calc((100vw - 90px) / 9))); gap: 3px; align-items: center; } + .c4-say { min-height: 1.4em; } + .c4-label { font-size: 1.1rem; text-align: center; } + .c4-strip button { aspect-ratio: 1; border: 2px solid var(--edge); border-radius: 8px; background: var(--surface); color: var(--ink); font: inherit; font-weight: 900; font-size: 1.1rem; padding: 0; cursor: pointer; } + .c4-strip button:disabled { cursor: default; } + .c4-a button.on { background: var(--accent); color: #fff; } + .c4-b button.on { background: var(--ink); color: var(--surface); } + @media (min-width: 900px) { .c4-strips { align-self: center; } } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/dominoes.js b/apps/web/src/demos/games/dominoes.js new file mode 100644 index 0000000..192d194 --- /dev/null +++ b/apps/web/src/demos/games/dominoes.js @@ -0,0 +1,227 @@ +// Fact Dominoes (like Dominoes): each half of a domino is a number or a fact +// (12, 3 × 4, 2 × 6). Play a domino next to an end of the line when the +// touching halves are equal. You and the CPU take turns; first to empty their +// hand wins. The set is a classic "double-five" set built on 6 answers. + +const VALUES = 6; // 6 answers → 21 dominoes (every pair, doubles included) +const HAND = { easy: 5, medium: 6, hard: 7 }; +const CPU_WAIT = 1100; + +export function mount(stage, ctx) { + addStyle(); + // 6 different answers that have a real fact (no × 1) in the level's tables + const facts = new Map(); + for (const a of ctx.L.tables) for (let b = 2; b <= ctx.L.upTo; b++) (facts.get(a * b) || facts.set(a * b, []).get(a * b)).push([a, b]); + const values = ctx.shuffle([...facts.keys()]).slice(0, VALUES); + const half = (value, asNumber) => { + if (asNumber) return { value, text: String(value) }; + const [a, b] = ctx.pick(facts.get(value)); + return ctx.rng() < 0.5 ? { value, text: `${a} × ${b}` } : { value, text: `${b} × ${a}` }; + }; + let set = []; + for (let i = 0; i < VALUES; i++) { + for (let j = i; j < VALUES; j++) { + // doubles get one number and one fact, so they still need thinking + set.push({ halves: [half(values[i], i === j || ctx.rng() < 0.4), half(values[j], i !== j && ctx.rng() < 0.4)] }); + } + } + set = ctx.shuffle(set); + const hand = set.splice(0, HAND[ctx.level]); + const cpu = set.splice(0, HAND[ctx.level]); + const line = [set.shift()]; // each domino's halves in line order, left to right + const boneyard = set; + + let turn = 'me'; + let selected = -1; + let score = 0; + let wrong = 0; + let passes = 0; // passes in a row (two means the game is blocked) + + const board = document.createElement('div'); + board.className = 'dom-board'; + const handEl = document.createElement('div'); + handEl.className = 'dom-hand'; + const bar = document.createElement('div'); + bar.className = 'dom-bar'; + bar.innerHTML = ``; + stage.append(bar, board, handEl); + + const ends = () => ({ left: line[0].halves[0], right: line[line.length - 1].halves[1] }); + const fits = (d, end) => d.halves.findIndex((h) => h.value === ends()[end].value); + const moves = (list) => { + const out = []; + list.forEach((d, i) => ['left', 'right'].forEach((end) => fits(d, end) >= 0 && out.push({ i, end }))); + return out; + }; + const tile = (d, cls = '') => `${d.halves[0].text}${d.halves[1].text}`; + + function render() { + // long lines show their two ends and hide the middle + const shown = line.length > 7 ? [...line.slice(0, 3).map((d) => tile(d)), `+${line.length - 6}`, ...line.slice(-3).map((d) => tile(d))] : line.map((d) => tile(d)); + const can = turn === 'me' ? '' : ' disabled'; + board.innerHTML = `${shown.join('')}`; + handEl.innerHTML = hand.map((d, i) => ``).join(''); + bar.querySelector('.dom-cpu').textContent = `CPU: ${cpu.length} dominoes · Pile: ${boneyard.length}`; + const draw = bar.querySelector('.dom-draw'); + draw.textContent = boneyard.length ? 'Draw a domino' : 'Pass'; + draw.disabled = turn !== 'me'; + ctx.hud.info(`Your hand: ${hand.length}`); + ctx.hud.score(score); + } + + // put domino d on an end, turned so the matching half touches the line + function place(d, end) { + const k = fits(d, end); + const halves = end === 'left' ? (k === 1 ? d.halves : [d.halves[1], d.halves[0]]) : k === 0 ? d.halves : [d.halves[1], d.halves[0]]; + if (end === 'left') line.unshift({ halves }); + else line.push({ halves }); + } + + function play(i, end) { + if (turn !== 'me' || !hand[i]) return; + const d = hand[i]; + const e = ends()[end]; + if (fits(d, end) < 0) { + wrong++; + selected = -1; + const said = (h) => (h.text === String(h.value) ? h.text : `${h.text} = ${h.value}`); + ctx.hud.msg(`That end is ${said(e)}; your domino has ${said(d.halves[0])} and ${said(d.halves[1])}`, 'bad'); + return render(); + } + const h = d.halves[fits(d, end)]; + hand.splice(i, 1); + place(d, end); + selected = -1; + passes = 0; + score += 10; + // facts first, the number last: "3 × 4 = 2 × 6 = 12 ✓" + const said = [...new Set([e.text, h.text, String(e.value)])].sort((x, y) => /×/.test(y) - /×/.test(x)); + ctx.hud.msg(`${said.join(' = ')} ✓`, 'good'); + endTurn(); + } + + function drawOrPass() { + if (turn !== 'me') return; + if (moves(hand).length) return ctx.hud.msg(`You can play! Look for ${ends().left.value} or ${ends().right.value}`, 'bad'); + if (boneyard.length) { + hand.push(boneyard.shift()); + ctx.hud.msg('You drew a domino'); + return render(); + } + passes++; + ctx.hud.msg('You pass'); + endTurn(); + } + + function endTurn() { + if (!hand.length) return over(); + if (passes >= 2) return over(); + turn = 'cpu'; + render(); + ctx.after(CPU_WAIT, cpuTurn); + } + + // the CPU plays its first fit, drawing until it has one + function cpuTurn() { + let m = moves(cpu); + while (!m.length && boneyard.length) { + cpu.push(boneyard.shift()); + m = moves(cpu); + } + if (m.length) { + const { i, end } = ctx.pick(m); + const d = cpu.splice(i, 1)[0]; + const e = ends()[end]; + place(d, end); + passes = 0; + ctx.hud.msg(`CPU played ${d.halves.map((h) => h.text).join(' | ')} on ${e.text}`); + } else { + passes++; + ctx.hud.msg('The CPU passes'); + } + if (!cpu.length || passes >= 2) return over(); + turn = 'me'; + render(); + } + + function over() { + turn = 'over'; + render(); + const won = !hand.length || (cpu.length && hand.length < cpu.length); + const blocked = hand.length && cpu.length; + ctx.after(800, () => + ctx.finish({ + title: won ? 'You win!' : !blocked || hand.length > cpu.length ? 'The CPU wins' : 'A draw!', + won: Boolean(won), + lines: [blocked ? `Nobody could play: you had ${hand.length}, the CPU had ${cpu.length}` : won ? 'You played all your dominoes!' : 'The CPU played all its dominoes first.', `Dominoes in the line: ${line.length}`, `Wrong tries: ${wrong}`, `Score: ${score}`], + }) + ); + } + + ctx.listen(handEl, 'click', (e) => { + const b = e.target.closest('.dom-pick'); + if (!b || turn !== 'me') return; + selected = Number(b.dataset.i) === selected ? -1 : Number(b.dataset.i); + render(); + }); + ctx.listen(board, 'click', (e) => { + const b = e.target.closest('.dom-end'); + if (!b) return; + if (selected < 0) return ctx.hud.msg('First tap a domino in your hand'); + play(selected, b.dataset.end); + }); + ctx.listen(bar.querySelector('.dom-draw'), 'click', drawOrPass); + // keys: 1–9 pick a domino, ← → play it on that end, D draws / passes + ctx.listen(window, 'keydown', (e) => { + if (turn !== 'me') return; + if (/^[1-9]$/.test(e.key) && hand[Number(e.key) - 1]) { + selected = Number(e.key) - 1; + render(); + } else if ((e.key === 'ArrowLeft' || e.key === 'ArrowRight') && selected >= 0) play(selected, e.key === 'ArrowLeft' ? 'left' : 'right'); + else if (e.key === 'd' || e.key === 'D') drawOrPass(); + else return; + e.preventDefault(); + }); + + ctx.hud.msg('Tap a domino, then an end where it fits'); + render(); + + return { + state: () => ({ + turn, + selected, + hand: hand.map((d) => ({ halves: d.halves.map((h) => ({ ...h })) })), + ends: ends(), + moves: turn === 'me' ? moves(hand) : [], + line: line.length, + cpu: cpu.length, + boneyard: boneyard.length, + score, + wrong, + }), + }; +} + +function addStyle() { + if (document.getElementById('dominoes-style')) return; + const s = document.createElement('style'); + s.id = 'dominoes-style'; + s.textContent = ` + .dom-bar { display: flex; gap: 12px; align-items: center; justify-content: center; flex-wrap: wrap; font-weight: 800; color: var(--muted); } + .dom-board { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; justify-content: center; width: 100%; min-height: 90px; padding: 14px; border: 2px solid var(--edge); border-radius: 14px; background: var(--good-bg); box-sizing: border-box; } + .dom-tile { display: inline-flex; border: 2px solid var(--edge); border-radius: 8px; background: var(--paper); color: var(--ink); box-shadow: 0 3px 0 var(--edge-shadow); } + .dom-half { display: flex; align-items: center; justify-content: center; min-width: 58px; height: 48px; padding: 0 6px; font-weight: 900; font-size: 1.05rem; white-space: nowrap; } + .dom-half + .dom-half { border-left: 2px solid var(--edge); } + .dom-board > .dom-tile:first-of-type .dom-half:first-child, .dom-board > .dom-tile:last-of-type .dom-half:last-child { background: var(--gold-bg); } + .dom-more { font-weight: 900; color: var(--muted); padding: 0 4px; } + .dom-end { font-weight: 900; } + .dom-hand { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; } + .dom-pick { display: flex; flex-direction: column; align-items: center; gap: 2px; padding: 4px; border: 3px solid transparent; border-radius: 12px; background: none; font: inherit; color: var(--muted); cursor: pointer; } + .dom-pick small { font-weight: 800; } + .dom-pick:hover:not(:disabled) .dom-tile { border-color: var(--accent); } + .dom-pick.selected { border-color: var(--accent); background: var(--accent-soft); transform: translateY(-4px); } + .dom-pick:disabled { opacity: 0.6; cursor: default; } + @media (max-width: 560px) { .dom-half { min-width: 44px; height: 42px; font-size: 0.9rem; padding: 0 4px; } .dom-board { padding: 8px; } } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/flappy.js b/apps/web/src/demos/games/flappy.js new file mode 100644 index 0000000..3a1f1a7 --- /dev/null +++ b/apps/web/src/demos/games/flappy.js @@ -0,0 +1,219 @@ +// Flappy Facts (like Flappy Bird): each wall has gaps with numbers; flap +// through the gap with the answer to the question. One wall at a time, so +// there's always just one question to think about. + +const W = 560; +const H = 440; // a bit tall, so it stays big on a phone +const WALLS = 10; // walls to fly through to win +const BIRD_X = 130; +const HIT_R = 9; // forgiving hitbox (the bird is drawn bigger) +const SET = { + easy: { gaps: 2, gapH: 130, speed: 100 }, + medium: { gaps: 3, gapH: 108, speed: 120 }, + hard: { gaps: 3, gapH: 96, speed: 140 }, +}; +const GRAVITY = 760; +const FLAP = -260; + +export function mount(stage, ctx) { + const set = SET[ctx.level]; + const { canvas, ctx: g } = ctx.canvas(stage, W, H); + const bird = { x: BIRD_X, y: H / 2, vy: 0 }; + let phase = 'ready'; // ready → fly → over + let wall = null; + let hearts = 3; + let score = 0; + let passed = 0; // walls resolved, right or wrong + let right = 0; + let ghost = 0; // seconds the bird can pass through walls after a bonk + let t0 = 0; + + // a wall with one gap per band; one of the gaps carries the answer + function newWall() { + const fact = ctx.fact(); + const values = ctx.shuffle([fact.answer, ...ctx.nearMisses(fact.answer, set.gaps - 1, fact)]); + const band = (H - 30) / set.gaps; + const gaps = values.map((value, i) => { + const slack = band - set.gapH; + const top = 15 + i * band + slack / 2 + (ctx.rng() - 0.5) * slack * 0.7; + return { top, bot: top + set.gapH, value }; + }); + wall = { x: W + 10, w: 64, fact, gaps, through: null, done: false }; + ctx.hud.info(`Wall ${passed + 1}/${WALLS}`); + } + + function flap() { + if (phase === 'over') return; + if (phase === 'ready') { + phase = 'fly'; + ctx.hud.msg(`Fly through ${wall.fact.text}`); + } + bird.vy = FLAP; + } + ctx.listen(canvas, 'pointerdown', (e) => { + e.preventDefault(); + flap(); + }); + ctx.listen(window, 'keydown', (e) => { + if (e.key === ' ' || e.key === 'ArrowUp' || e.key === 'w') { + e.preventDefault(); + if (!e.repeat) flap(); + } + }); + + // a wall is decided when the bird bonks it or comes out the other side + function resolve(gap) { + wall.done = true; + passed++; + const { text, answer } = wall.fact; + if (gap && gap.value === answer) { + right++; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${text} = ${answer} ✓`, 'good'); + } else { + hearts--; + ctx.hud.hearts(hearts); + if (gap) ctx.hud.msg(`${text} = ${answer}, not ${gap.value}`, 'bad'); + else { + ctx.hud.msg(`Bonk! ${text} = ${answer}`, 'bad'); + ghost = 1.4; + } + } + if (hearts <= 0 || passed >= WALLS) { + phase = 'over'; + const won = hearts > 0; + ctx.after(700, () => + ctx.finish({ + title: won ? 'You made it!' : 'Out of hearts!', + won, + lines: [`Right gaps: ${right} of ${passed}`, `Score: ${score}`, won ? 'Great flying!' : `Last one: ${text} = ${answer}`], + }) + ); + } + } + + newWall(); + ctx.hud.hearts(hearts); + ctx.hud.msg('Tap, click or Space to flap'); + + ctx.loop((dt, t) => { + if (!t0) t0 = t; + if (phase === 'ready') { + bird.y = H / 2 + Math.sin((t - t0) / 250) * 8; // bob until the first flap + } else if (phase === 'fly') { + bird.vy = Math.min(bird.vy + GRAVITY * dt, 360); + bird.y += bird.vy * dt; + if (bird.y < 12) (bird.y = 12), (bird.vy = 0); + // the floor bounces rather than hurts: only walls cost hearts + if (bird.y > H - 14) { + bird.y = H - 14; + bird.vy = FLAP * 0.8; + if (!wall.done) ctx.hud.msg('Flap to stay up!'); + } + ghost = Math.max(0, ghost - dt); + wall.x -= set.speed * dt; + const inside = bird.x + HIT_R > wall.x && bird.x - HIT_R < wall.x + wall.w; + if (!wall.done && inside) { + const gap = wall.gaps.find((gp) => bird.y - HIT_R >= gp.top && bird.y + HIT_R <= gp.bot); + if (gap) wall.through = gap; + else if (ghost <= 0) resolve(null); + } + if (!wall.done && bird.x - HIT_R > wall.x + wall.w) resolve(wall.through); + if (wall.x + wall.w < bird.x - 40 && phase === 'fly') { + newWall(); + ctx.hud.msg(`Next: ${wall.fact.text}`); + } + } + draw(t); + }); + + function draw(t) { + const ink = ctx.color('--ink'); + g.fillStyle = ctx.color('--surface'); + g.fillRect(0, 0, W, H); + g.fillStyle = ctx.color('--soft-2'); + g.fillRect(0, H - 6, W, 6); + // wall: solid pieces between the gaps + const edges = [0, ...wall.gaps.flatMap((gp) => [gp.top, gp.bot]), H]; + g.lineWidth = 2; + for (let i = 0; i < edges.length; i += 2) { + g.fillStyle = ctx.color('--good'); + g.fillRect(wall.x, edges[i], wall.w, edges[i + 1] - edges[i]); + g.strokeStyle = ctx.color('--edge'); + g.strokeRect(wall.x, edges[i], wall.w, edges[i + 1] - edges[i]); + } + // gap numbers; once decided, the answer's gap lights up + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = '900 26px system-ui, sans-serif'; + for (const gp of wall.gaps) { + const cy = (gp.top + gp.bot) / 2; + const isAnswer = gp.value === wall.fact.answer; + g.fillStyle = wall.done && isAnswer ? ctx.color('--good') : ctx.color('--gold-bg'); + g.strokeStyle = ctx.color('--edge'); + g.beginPath(); + g.roundRect(wall.x - 8, cy - 20, wall.w + 16, 40, 12); + g.fill(); + g.stroke(); + g.fillStyle = wall.done && isAnswer ? '#fff' : ink; + g.fillText(String(gp.value), wall.x + wall.w / 2, cy + 1); + } + // the question, big, top left + g.fillStyle = ctx.color('--paper'); + g.strokeStyle = ctx.color('--edge'); + g.beginPath(); + g.roundRect(10, 10, 200, 50, 12); + g.fill(); + g.stroke(); + g.fillStyle = ink; + g.font = '900 30px system-ui, sans-serif'; + g.fillText(`${wall.fact.text} = ?`, 110, 36); + // bird (blinks while it can pass through walls) + if (ghost > 0 && Math.floor(t / 100) % 2) return; + const tilt = Math.max(-0.5, Math.min(0.7, bird.vy / 500)); + g.save(); + g.translate(bird.x, bird.y); + g.rotate(tilt); + g.fillStyle = ctx.color('--accent'); + g.beginPath(); + g.arc(0, 0, 14, 0, Math.PI * 2); + g.fill(); + g.strokeStyle = ctx.color('--edge'); + g.stroke(); + g.fillStyle = '#fff'; + g.beginPath(); + g.arc(5, -4, 5, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#222'; + g.beginPath(); + g.arc(6.5, -4, 2.2, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#f2c14e'; + g.beginPath(); + g.moveTo(12, 0); + g.lineTo(21, 3); + g.lineTo(12, 6); + g.fill(); + g.restore(); + if (phase === 'ready') { + g.fillStyle = ink; + g.font = '800 20px system-ui, sans-serif'; + g.fillText('Tap or press Space to fly!', W / 2, H - 40); + } + } + + return { + state: () => ({ + phase, + hearts, + score, + passed, + right, + total: WALLS, + ghost, + bird: { ...bird }, + wall: { x: wall.x, w: wall.w, done: wall.done, text: wall.fact.text, answer: wall.fact.answer, gaps: wall.gaps.map((gp) => ({ ...gp })) }, + }), + }; +} diff --git a/apps/web/src/demos/games/frogger.js b/apps/web/src/demos/games/frogger.js new file mode 100644 index 0000000..5604c90 --- /dev/null +++ b/apps/web/src/demos/games/frogger.js @@ -0,0 +1,286 @@ +// Answer Hopper (like Frogger): each river lane asks a question and its logs +// carry numbers. Pick a log in the next lane (← →, or tap it), then hop (↑). +// The right answer: the frog rides that log. Wrong: splash, a heart, and that +// log sinks, so there are fewer choices next try. Cross the river to win. + +const W = 640; +const H = 480; +const SIGN = 124; // the question signs on the left bank +const LANES = 4; +const LANE_H = 80; +const BANK = 80; // far bank at the top, near bank at the bottom +const LOG_W = 116; +const LOGS = { easy: 3, medium: 3, hard: 4 }; +const DRIFT = { easy: 26, medium: 38, hard: 50 }; // px per second +const CROSSINGS = 2; + +export function mount(stage, ctx) { + addStyle(); + document.activeElement?.blur?.(); + const { canvas, ctx: g, point } = ctx.canvas(stage, W, H); + const span = W - SIGN + LOG_W; // logs wrap around this distance, out of sight + const laneY = (i) => BANK + (LANES - 1 - i) * LANE_H; // lane 0 is nearest the frog's bank + + let lanes = []; + let crossing = 0; + let score = 0; + let hearts = 3; + let right = 0; + let wrong = 0; + let over = false; + const frog = { lane: -1, log: null, x: SIGN + (W - SIGN) / 2, hop: 0 }; // lane -1: near bank, LANES: far bank + let sel = null; // the chosen log in the next lane + let splash = null; + + function newRiver() { + crossing++; + lanes = []; + for (let i = 0; i < LANES; i++) { + let fact = ctx.fact(); + for (let k = 0; k < 20 && (fact.a === 1 || fact.b === 1); k++) fact = ctx.fact(); // ×1 is too easy + const n = LOGS[ctx.level]; + const nums = ctx.shuffle([fact.answer, ...ctx.nearMisses(fact.answer, n - 1, fact)]); + const speed = DRIFT[ctx.level] * (i % 2 ? -1 : 1) * (0.8 + ctx.rng() * 0.4); + const offset = ctx.rng() * span; + lanes.push({ fact, speed, logs: nums.map((num, k) => ({ n: num, pos: (offset + (k * span) / n) % span, sunk: false })) }); + } + Object.assign(frog, { lane: -1, log: null, x: SIGN + (W - SIGN) / 2 }); + sel = null; + ctx.hud.info(`Crossing ${crossing}/${CROSSINGS}`); + ask(); + } + const logX = (log) => SIGN - LOG_W + log.pos; // left edge on screen + const next = () => lanes[frog.lane + 1]; + // logs you can pick: afloat and (nearly) all in view + const choices = () => (next() ? next().logs.filter((l) => !l.sunk && logX(l) > SIGN - 12 && logX(l) + LOG_W < W + 12) : []); + function ask() { + const lane = next(); + ctx.hud.msg(lane ? `${lane.fact.text} = ? Pick a log, then hop` : 'Hop up onto the far bank!'); + pickNearest(); + } + function pickNearest() { + const list = choices(); + sel = list.length ? list.reduce((a, b) => (Math.abs(logX(b) + LOG_W / 2 - frog.x) < Math.abs(logX(a) + LOG_W / 2 - frog.x) ? b : a)) : null; + } + // ← →: the next log along to the left or right of the chosen one + function choose(dir) { + if (over) return; + const list = choices().sort((a, b) => logX(a) - logX(b)); + if (!list.length) return; + const i = list.indexOf(sel); + sel = i < 0 ? list[0] : list[Math.max(0, Math.min(list.length - 1, i + dir))]; + } + + function hop() { + if (over || frog.hop > 0 || frog.lane >= LANES) return; + const lane = next(); + if (!lane) { + // the far bank: always safe + frog.lane = LANES; + frog.log = null; + frog.hop = 0.25; + score += 25; + ctx.hud.score(score); + if (crossing >= CROSSINGS) return end(true, 'Across the river!'); + ctx.toast('🐸 Across!', '+25 bonus points. A new river…'); + return ctx.after(700, newRiver); + } + if (!sel) return; + if (sel.n === lane.fact.answer) { + right++; + score += 10; + ctx.hud.score(score); + frog.lane++; + frog.log = sel; + frog.hop = 0.25; + ctx.hud.msg(`${lane.fact.text} = ${lane.fact.answer} ✓`, 'good'); + ctx.after(900, () => !over && ask()); + sel = null; + } else { + wrong++; + hearts--; + ctx.hud.hearts(hearts); + ctx.hud.msg(`${lane.fact.text} = ${lane.fact.answer}, not ${sel.n}. Splash!`, 'bad'); + splash = { x: logX(sel) + LOG_W / 2, y: laneY(frog.lane + 1) + LANE_H / 2, t: 0.7 }; + sel.sunk = true; + if (hearts <= 0) return end(false, 'Out of hearts!'); + pickNearest(); + } + } + function end(won, title) { + over = true; + draw(); + const lane = lanes[Math.max(0, Math.min(LANES - 1, frog.lane + 1))]; + ctx.finish({ title, won, lines: [`Score: ${score}`, `Right hops: ${right}`, `Splashes: ${wrong}`, won ? 'The frog made it home!' : `${lane.fact.text} = ${lane.fact.answer}`] }); + } + + ctx.listen(window, 'keydown', (e) => { + const k = e.key; + if (k === 'ArrowLeft') choose(-1); + else if (k === 'ArrowRight') choose(1); + else if (k === 'ArrowUp' || k === ' ') hop(); + else return; + e.preventDefault(); + }); + // tap a log in the next lane to hop onto it (or the far bank when it's next) + ctx.listen(canvas, 'pointerdown', (e) => { + const p = point(e); + if (!next()) return p.y < BANK + LANE_H && hop(); + const y = laneY(frog.lane + 1); + if (p.y < y || p.y > y + LANE_H) return; + const log = choices().find((l) => p.x >= logX(l) - 8 && p.x <= logX(l) + LOG_W + 8); + if (log) { + sel = log; + hop(); + } + }); + const pad = document.createElement('div'); + pad.className = 'frogger-pad'; + for (const [label, name, fn] of [['◀', 'Pick the log to the left', () => choose(-1)], ['▲ Hop', 'Hop', hop], ['▶', 'Pick the log to the right', () => choose(1)]]) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn frogger-btn${fn === hop ? ' btn-primary' : ''}`; + b.textContent = label; + b.setAttribute('aria-label', name); + b.tabIndex = -1; + ctx.listen(b, 'pointerdown', (e) => { + e.preventDefault(); + fn(); + }); + pad.append(b); + } + stage.append(pad); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + newRiver(); + + ctx.loop((dt) => { + if (over) return; + // logs drift, except under the frog: its lane holds still so it never rides off the edge + lanes.forEach((lane, i) => { + if (i === frog.lane) return; + for (const log of lane.logs) log.pos = (((log.pos + lane.speed * dt) % span) + span) % span; + }); + if (frog.log) frog.x = logX(frog.log) + LOG_W / 2; + frog.hop = Math.max(0, frog.hop - dt); + if (splash) splash.t -= dt; + if (splash && splash.t <= 0) splash = null; + // keep the choice valid as logs drift off the edge + if (next() && (!sel || !choices().includes(sel))) pickNearest(); + draw(); + }); + + function draw() { + // banks and water + g.fillStyle = ctx.color('--good-bg') || '#dff5e6'; + g.fillRect(0, 0, W, H); + g.fillStyle = 'rgba(64, 150, 230, 0.28)'; // water: see-through blue works on light and dark + g.fillRect(SIGN, BANK, W - SIGN, LANES * LANE_H); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = '800 16px system-ui, sans-serif'; + g.fillStyle = ctx.color('--ink'); + g.fillText('🏁 Far bank', SIGN + (W - SIGN) / 2, BANK / 2); + for (let i = 0; i < LANES; i++) { + const lane = lanes[i]; + const y = laneY(i); + const isNext = i === frog.lane + 1; + // the question sign; the next lane's is lit up + g.fillStyle = isNext ? ctx.color('--gold-bg') : ctx.color('--paper'); + g.strokeStyle = ctx.color('--edge'); + g.lineWidth = isNext ? 3 : 1.5; + g.beginPath(); + g.roundRect(8, y + 12, SIGN - 16, LANE_H - 24, 10); + g.fill(); + g.stroke(); + g.fillStyle = ctx.color(isNext ? '--ink' : '--muted'); + g.font = '900 20px system-ui, sans-serif'; + g.fillText(i < frog.lane + 1 ? '✓' : lane.fact.text, SIGN / 2, y + LANE_H / 2 + 1); + // logs (clipped to the river) + g.save(); + g.beginPath(); + g.rect(SIGN, y, W - SIGN, LANE_H); + g.clip(); + for (const log of lane.logs) { + if (log.sunk) continue; + const x = logX(log); + const chosen = log === sel; + g.fillStyle = chosen ? ctx.color('--gold-line') || '#c98a1b' : '#9a6a3a'; + g.strokeStyle = chosen ? ctx.color('--ink') : '#6b4423'; + g.lineWidth = chosen ? 4 : 2; + g.beginPath(); + g.roundRect(x, y + 16, LOG_W, LANE_H - 32, 20); + g.fill(); + g.stroke(); + g.fillStyle = chosen ? ctx.color('--ink') : '#fff'; + g.font = '900 22px system-ui, sans-serif'; + g.fillText(String(log.n), x + LOG_W / 2, y + LANE_H / 2 + 1); + } + g.restore(); + } + if (splash) { + g.strokeStyle = '#fff'; + g.lineWidth = 3; + for (const r of [0.4, 0.7, 1]) { + g.beginPath(); + g.arc(splash.x, splash.y, 40 * r * (1 - splash.t / 0.7) + 6, 0, Math.PI * 2); + g.stroke(); + } + } + // the frog (a little bigger mid-hop) + const fy = frog.lane < 0 ? H - BANK / 2 : frog.lane >= LANES ? BANK / 2 + 14 : laneY(frog.lane) + LANE_H / 2; + const fx = frog.x; + const r = 18 * (1 + frog.hop); + g.fillStyle = ctx.color('--good') || '#2e9e5b'; + g.beginPath(); + g.arc(fx, fy - (frog.log ? 22 : 0), r, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#fff'; + for (const ex of [-7, 7]) { + g.beginPath(); + g.arc(fx + ex, fy - (frog.log ? 22 : 0) - 8, 5, 0, Math.PI * 2); + g.fill(); + } + // a dotted line to the chosen log + if (sel && !over) { + g.setLineDash([6, 6]); + g.strokeStyle = ctx.color('--ink'); + g.lineWidth = 2; + g.beginPath(); + g.moveTo(fx, fy - (frog.log ? 22 : 0) - r); + g.lineTo(logX(sel) + LOG_W / 2, laneY(frog.lane + 1) + LANE_H - 16); + g.stroke(); + g.setLineDash([]); + } + } + + return { + state: () => { + const lane = next(); + return { + lane: frog.lane, + question: lane ? lane.fact.text : null, + answer: lane ? lane.fact.answer : null, + selected: sel ? sel.n : null, + choices: choices().map((l) => ({ n: l.n, x: logX(l) + LOG_W / 2, y: laneY(frog.lane + 1) + LANE_H / 2 })), + frog: { lane: frog.lane, x: frog.x }, + crossing, + crossings: CROSSINGS, + score, + hearts, + }; + }, + }; +} + +function addStyle() { + if (document.getElementById('frogger-style')) return; + const s = document.createElement('style'); + s.id = 'frogger-style'; + s.textContent = ` + .frogger-pad { display: grid; grid-template-columns: 72px 120px 72px; gap: 10px; } + .frogger-btn { font-size: 1.3rem; font-weight: 900; padding: 12px 0; touch-action: none; user-select: none; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/invaders.js b/apps/web/src/demos/games/invaders.js new file mode 100644 index 0000000..845e22f --- /dev/null +++ b/apps/web/src/demos/games/invaders.js @@ -0,0 +1,273 @@ +// Fact Invaders (like Space Invaders): the cannon carries a question; shoot the +// alien carrying its answer. Every alien is the answer to a real fact, so each +// one gets asked in turn. Wrong alien: a heart. Aliens landing: a heart, and +// they're pushed back up (forgiving). Clear the waves to win. + +const W = 640; +const H = 480; +const ALIENS = { easy: 6, medium: 8, hard: 10 }; +const MARCH = { easy: 26, medium: 34, hard: 44 }; // px per second, sideways +const SINK = { easy: 3, medium: 4.5, hard: 6 }; // px per second, down (easy lands in ~90 s) +const WAVES = 2; +const AW = 76; // alien size +const AH = 40; +const GROUND = 420; // aliens touching this have landed +const CANNON_Y = 446; + +export function mount(stage, ctx) { + addStyle(); + document.activeElement?.blur?.(); // so Space doesn't press a focused button + const { canvas, ctx: g, point } = ctx.canvas(stage, W, H); + const held = new Set(); // on-screen ◀ ▶ held down + // the question again in big type: on a phone the canvas is scaled right down + const qEl = document.createElement('div'); + qEl.className = 'demo-big'; + qEl.setAttribute('aria-live', 'polite'); + stage.append(qEl); + const bar = document.createElement('div'); + bar.className = 'invaders-pad'; + stage.append(bar); + const button = (label, title, onPress, holdKey) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn invaders-btn${onPress ? ' btn-primary' : ''}`; + b.textContent = label; + b.setAttribute('aria-label', title); + b.tabIndex = -1; + ctx.listen(b, 'pointerdown', (e) => { + e.preventDefault(); + if (onPress) onPress(); + if (holdKey) held.add(holdKey); + }); + for (const t of ['pointerup', 'pointerleave', 'pointercancel']) ctx.listen(b, t, () => holdKey && held.delete(holdKey)); + bar.append(b); + }; + button('◀', 'Move left', null, 'left'); + button('Fire', 'Fire', () => fire()); + button('▶', 'Move right', null, 'right'); + + let aliens = []; + let q = null; // the current question: a fact whose answer an alien carries + let wave = 0; + let score = 0; + let hearts = 3; + let right = 0; + let wrong = 0; + let over = false; + const cannon = { x: W / 2, w: 96 }; + let bullet = null; + let fx = 0; // formation offset and direction + let fy = 0; + let dir = 1; + let booms = []; // short-lived hit effects + + // a wave of aliens with different answers, in two rows + function newWave() { + wave++; + const n = ALIENS[ctx.level]; + const facts = []; + const seen = new Set(); + for (let i = 0; facts.length < n && i < 500; i++) { + const f = ctx.fact(); + if (f.a === 1 || f.b === 1 || seen.has(f.answer)) continue; + seen.add(f.answer); + facts.push(f); + } + const perRow = Math.ceil(facts.length / 2); + aliens = facts.map((fact, i) => ({ fact, n: fact.answer, col: i % perRow, row: Math.floor(i / perRow), alive: true, flash: 0 })); + fx = 0; + fy = 0; + dir = 1; + ctx.hud.info(`Wave ${wave}/${WAVES}`); + ask(); + } + const spanW = () => Math.ceil(aliens.length / 2) * (AW + 16) - 16; + const alienPos = (a) => ({ x: (W - spanW()) / 2 + fx + a.col * (AW + 16), y: 40 + fy + a.row * (AH + 18) }); + const alive = () => aliens.filter((a) => a.alive); + + // ask about an alien in front (nothing alive below it), so it can be hit + // without shooting through another alien first + function ask() { + const front = alive().filter((a) => !aliens.some((b) => b.alive && b.col === a.col && b.row > a.row)); + const pool = front.filter((a) => !q || a.n !== q.answer); + const target = ctx.pick(pool.length ? pool : front); + q = target.fact; + qEl.textContent = `${q.text} = ?`; + } + + function fire() { + if (bullet || over) return; + bullet = { x: cannon.x, y: CANNON_Y - 20 }; + } + + function hit(a) { + const p = alienPos(a); + if (a.n === q.answer) { + a.alive = false; + right++; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${q.text} = ${q.answer} ✓`, 'good'); + booms.push({ x: p.x + AW / 2, y: p.y + AH / 2, t: 0.5 }); + if (alive().length) return ask(); + if (wave >= WAVES) return end(true, 'The invaders are gone!'); + ctx.toast('👾 Wave cleared!', '+25 bonus points'); + score += 25; + ctx.hud.score(score); + newWave(); + } else { + wrong++; + a.flash = 0.6; + ctx.hud.msg(`${q.text} = ${q.answer}, not ${a.n}`, 'bad'); + loseHeart(); + } + } + function loseHeart() { + hearts--; + ctx.hud.hearts(hearts); + if (hearts <= 0) end(false, 'Out of hearts!'); + } + function end(won, title) { + over = true; + draw(); + ctx.finish({ title, won, lines: [`Score: ${score}`, `Aliens hit: ${right}`, `Wrong aliens: ${wrong}`, won ? 'Earth is safe!' : `Last question: ${q.text} = ${q.answer}`] }); + } + + ctx.listen(canvas, 'pointermove', (e) => (cannon.x = point(e).x)); + ctx.listen(canvas, 'pointerdown', (e) => (cannon.x = point(e).x)); + ctx.listen(window, 'keydown', (e) => { + if (e.key === ' ' || e.key === 'ArrowUp') { + e.preventDefault(); + fire(); + } else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') e.preventDefault(); + }); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + ctx.hud.msg('Shoot the alien with the answer'); + newWave(); + + ctx.loop((dt) => { + if (over) return; + // cannon + if (ctx.keys.has('ArrowLeft') || held.has('left')) cannon.x -= 340 * dt; + if (ctx.keys.has('ArrowRight') || held.has('right')) cannon.x += 340 * dt; + cannon.x = Math.max(cannon.w / 2, Math.min(W - cannon.w / 2, cannon.x)); + // march sideways (turning at the edges) and sink slowly, at a steady pace + fx += dir * MARCH[ctx.level] * dt; + fy += SINK[ctx.level] * dt; + const left = (W - spanW()) / 2 + fx; + if ((dir > 0 && left + spanW() > W - 8) || (dir < 0 && left < 8)) dir *= -1; + // landed: a heart, and the wave goes back to the top + const low = Math.max(...alive().map((a) => alienPos(a).y + AH)); + if (low >= GROUND) { + fy = 0; + ctx.hud.msg(`They landed! ${q.text} = ${q.answer}`, 'bad'); + loseHeart(); + if (over) return; + } + // bullet + if (bullet) { + bullet.y -= 560 * dt; + if (bullet.y < 0) bullet = null; + else { + for (const a of alive()) { + const p = alienPos(a); + if (bullet.x > p.x && bullet.x < p.x + AW && bullet.y > p.y && bullet.y < p.y + AH) { + bullet = null; + hit(a); + break; + } + } + } + } + for (const a of aliens) a.flash = Math.max(0, a.flash - dt); + for (const b of booms) b.t -= dt; + booms = booms.filter((b) => b.t > 0); + if (!over) draw(); + }); + + function draw() { + g.fillStyle = ctx.color('--surface'); + g.fillRect(0, 0, W, H); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + // ground + g.fillStyle = ctx.color('--soft-2'); + g.fillRect(0, GROUND + 2, W, 2); + // aliens: the one a shot would hit now is highlighted, to help aim + g.font = '900 22px system-ui, sans-serif'; + const inLine = alive().filter((a) => cannon.x > alienPos(a).x && cannon.x < alienPos(a).x + AW); + const aimedAt = inLine.sort((a, b) => b.row - a.row)[0]; + for (const a of alive()) { + const p = alienPos(a); + const aimed = a === aimedAt; + g.fillStyle = a.flash > 0 ? ctx.color('--bad') : aimed ? ctx.color('--gold-bg') : ctx.color('--soft-2'); + g.strokeStyle = ctx.color('--edge'); + g.lineWidth = 2; + g.beginPath(); + g.roundRect(p.x, p.y, AW, AH, [16, 16, 6, 6]); + g.fill(); + g.stroke(); + // little legs and eyes + g.fillStyle = ctx.color('--edge'); + for (const lx of [12, AW / 2 - 3, AW - 18]) g.fillRect(p.x + lx, p.y + AH, 6, 6); + g.fillRect(p.x + 10, p.y + 8, 5, 5); + g.fillRect(p.x + AW - 15, p.y + 8, 5, 5); + g.fillStyle = a.flash > 0 ? '#fff' : ctx.color('--ink'); + g.fillText(String(a.n), p.x + AW / 2, p.y + AH / 2 + 3); + } + for (const b of booms) { + g.fillStyle = ctx.color('--good'); + g.globalAlpha = b.t * 2; + g.beginPath(); + g.arc(b.x, b.y, 40 * (1 - b.t), 0, Math.PI * 2); + g.fill(); + g.globalAlpha = 1; + } + // bullet + if (bullet) { + g.fillStyle = ctx.color('--accent'); + g.fillRect(bullet.x - 3, bullet.y - 10, 6, 18); + } + // cannon with the question on it + g.fillStyle = ctx.color('--accent'); + g.fillRect(cannon.x - 6, CANNON_Y - 26, 12, 12); + g.beginPath(); + g.roundRect(cannon.x - cannon.w / 2, CANNON_Y - 16, cannon.w, 34, 10); + g.fill(); + g.fillStyle = '#fff'; + g.font = '900 18px system-ui, sans-serif'; + g.fillText(q ? q.text : '', cannon.x, CANNON_Y + 2); + } + + return { + state: () => ({ + question: q && q.text, + answer: q && q.answer, + cannon: { x: cannon.x }, + bullet: bullet && { ...bullet }, + aliens: aliens.map((a) => { + const p = alienPos(a); + return { n: a.n, alive: a.alive, x: p.x, y: p.y, w: AW, h: AH, cx: p.x + AW / 2 }; + }), + wave, + waves: WAVES, + score, + hearts, + width: W, + height: H, + }), + }; +} + +function addStyle() { + if (document.getElementById('invaders-style')) return; + const s = document.createElement('style'); + s.id = 'invaders-style'; + s.textContent = ` + .invaders-pad { display: grid; grid-template-columns: 80px 120px 80px; gap: 10px; } + .invaders-btn { font-size: 1.4rem; font-weight: 900; padding: 12px 0; touch-action: none; user-select: none; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/kenken.js b/apps/web/src/demos/games/kenken.js new file mode 100644 index 0000000..91a80cf --- /dev/null +++ b/apps/web/src/demos/games/kenken.js @@ -0,0 +1,240 @@ +// Times Cages (like KenKen): fill an n × n grid so every row and column has +// 1..n once, and the numbers in each cage multiply to its clue. +// Puzzles are made fresh: a random Latin square, cut into cages, kept only if +// a small solver finds exactly one solution (so logic alone solves it). + +const SIZE = { easy: 3, medium: 4, hard: 5 }; +const CAGE = { easy: [1, 2, 2, 3], medium: [1, 2, 2, 3, 3], hard: [2, 2, 3, 3, 4] }; // cage sizes to pick from + +export function mount(stage, ctx) { + const n = SIZE[ctx.level]; + const { solution, cages, cageOf } = makePuzzle(ctx, n); + const grid = Array.from({ length: n }, () => Array(n).fill(0)); + let sel = [0, 0]; + let mistakes = 0; + let done = false; + const judged = new Map(); // cage index -> 'good' | 'bad' (last verdict, so each wrong cage counts once) + + addStyle(); + const board = document.createElement('div'); + board.className = 'kk-board'; + board.style.setProperty('--n', n); + const cells = []; + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'kk-cell'; + b.dataset.r = r; + b.dataset.c = c; + // thick edges where the neighbour is in another cage + const k = cageOf[r][c]; + const other = (rr, cc) => rr < 0 || cc < 0 || rr >= n || cc >= n || cageOf[rr][cc] !== k; + const sides = [other(r - 1, c), other(r, c + 1), other(r + 1, c), other(r, c - 1)]; + b.style.borderWidth = sides.map((o) => (o ? '3px' : '1px')).join(' '); + b.style.borderColor = sides.map((o) => (o ? 'var(--edge)' : 'var(--board-grid)')).join(' '); + const first = cages[k].cells[0]; + b.innerHTML = `${first[0] === r && first[1] === c ? `${cages[k].product}${cages[k].cells.length > 1 ? '×' : ''}` : ''}`; + board.append(b); + cells.push(b); + } + } + const pad = document.createElement('div'); + pad.className = 'kk-pad'; + for (let v = 1; v <= n; v++) pad.insertAdjacentHTML('beforeend', ``); + pad.insertAdjacentHTML('beforeend', ''); + stage.append(board, pad); + + const cellEl = (r, c) => cells[r * n + c]; + const product = (cage) => cage.cells.reduce((p, [r, c]) => p * grid[r][c], 1); + + function render() { + for (let r = 0; r < n; r++) { + for (let c = 0; c < n; c++) { + const b = cellEl(r, c); + b.querySelector('.kk-val').textContent = grid[r][c] || ''; + b.classList.toggle('sel', r === sel[0] && c === sel[1]); + const v = grid[r][c]; + const dup = v && (grid[r].filter((x) => x === v).length > 1 || grid.filter((row) => row[c] === v).length > 1); + b.classList.toggle('dup', !!dup); + const verdict = judged.get(cageOf[r][c]); + b.classList.toggle('good', verdict === 'good'); + b.classList.toggle('bad', verdict === 'bad'); + } + } + } + + function put(v) { + if (done) return; + const [r, c] = sel; + grid[r][c] = v; + const k = cageOf[r][c]; + const cage = cages[k]; + const full = cage.cells.every(([rr, cc]) => grid[rr][cc]); + const factText = cage.cells.map(([rr, cc]) => grid[rr][cc]).join(' × '); + const right = full && product(cage) === cage.product; + // a full cage gets a verdict; each wrong cage counts as one mistake until it changes + if (!full) judged.delete(k); + else if (right) judged.set(k, 'good'); + else { + if (judged.get(k) !== 'bad') mistakes++; + judged.set(k, 'bad'); + } + if (v && grid[r].filter((x) => x === v).length > 1) ctx.hud.msg(`Row ${r + 1} already has a ${v}`, 'bad'); + else if (v && grid.filter((row) => row[c] === v).length > 1) ctx.hud.msg(`Column ${c + 1} already has a ${v}`, 'bad'); + else if (right) ctx.hud.msg(cage.cells.length > 1 ? `${factText} = ${cage.product} ✓` : `${v} ✓`, 'good'); + else if (full && cage.cells.length === 1) ctx.hud.msg(`This square is a ${cage.product}, not ${v}`, 'bad'); + else if (full) ctx.hud.msg(`${factText} = ${product(cage)}, not ${cage.product}`, 'bad'); + else ctx.hud.msg(''); + ctx.hud.score([...judged.values()].filter((x) => x === 'good').length * 10); + render(); + checkWin(); + } + + function checkWin() { + for (let r = 0; r < n; r++) for (let c = 0; c < n; c++) if (!grid[r][c]) return; + for (let i = 0; i < n; i++) { + if (new Set(grid[i]).size < n || new Set(grid.map((row) => row[i])).size < n) return; + } + if (!cages.every((cage) => product(cage) === cage.product)) return; + done = true; + ctx.hud.msg('Solved! Every cage is right ✓', 'good'); + board.classList.add('solved'); + ctx.after(700, () => + ctx.finish({ title: 'Puzzle solved!', won: true, lines: [`${n} × ${n} grid, ${cages.length} cages`, mistakes ? `${mistakes} cage${mistakes > 1 ? 's' : ''} needed a second try` : 'No mistakes. Brilliant!'] }) + ); + } + + ctx.listen(board, 'click', (e) => { + const b = e.target.closest('.kk-cell'); + if (!b) return; + sel = [Number(b.dataset.r), Number(b.dataset.c)]; + render(); + }); + ctx.listen(pad, 'click', (e) => { + const b = e.target.closest('[data-v]'); + if (b) put(Number(b.dataset.v)); + }); + ctx.listen(window, 'keydown', (e) => { + const moves = { ArrowUp: [-1, 0], ArrowDown: [1, 0], ArrowLeft: [0, -1], ArrowRight: [0, 1] }; + if (moves[e.key]) { + sel = [(sel[0] + moves[e.key][0] + n) % n, (sel[1] + moves[e.key][1] + n) % n]; + render(); + } else if (/^[1-9]$/.test(e.key) && Number(e.key) <= n) put(Number(e.key)); + else if (e.key === 'Backspace' || e.key === 'Delete' || e.key === '0') put(0); + else return; + e.preventDefault(); + }); + + ctx.hud.info(`Each row and column: 1 to ${n}`); + ctx.hud.msg('Tap a square, then a number'); + render(); + + return { state: () => ({ n, solution, cages: cages.map((c) => ({ cells: c.cells, product: c.product })), grid: grid.map((r) => [...r]), sel, mistakes, done }) }; +} + +// A puzzle with exactly one answer (or the best try if none turns up quickly). +function makePuzzle(ctx, n) { + let best = null; + for (let tries = 0; tries < 300; tries++) { + const solution = latinSquare(ctx, n); + const { cages, cageOf } = cutCages(ctx, n, solution); + const count = countSolutions(n, cages, cageOf, 2); + if (count === 1) return { solution, cages, cageOf }; + best = best || { solution, cages, cageOf }; + } + return best; +} + +// shuffle the rows, columns and symbols of the plain cyclic square +function latinSquare(ctx, n) { + const rows = ctx.shuffle([...Array(n).keys()]); + const cols = ctx.shuffle([...Array(n).keys()]); + const syms = ctx.shuffle([...Array(n).keys()].map((i) => i + 1)); + return rows.map((r) => cols.map((c) => syms[(r + c) % n])); +} + +// grow cages from random cells into random unclaimed neighbours +function cutCages(ctx, n, solution) { + const cageOf = Array.from({ length: n }, () => Array(n).fill(-1)); + const cages = []; + const order = ctx.shuffle([...Array(n * n).keys()]); + for (const i of order) { + let r = Math.floor(i / n); + let c = i % n; + if (cageOf[r][c] >= 0) continue; + const k = cages.length; + const cells = [[r, c]]; + cageOf[r][c] = k; + const want = ctx.pick(CAGE[ctx.level]); + while (cells.length < want) { + const next = []; + for (const [cr, cc] of cells) { + for (const [dr, dc] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const rr = cr + dr; + const c2 = cc + dc; + if (rr >= 0 && c2 >= 0 && rr < n && c2 < n && cageOf[rr][c2] < 0) next.push([rr, c2]); + } + } + if (!next.length) break; + [r, c] = ctx.pick(next); + cageOf[r][c] = k; + cells.push([r, c]); + } + cells.sort((a, b) => a[0] - b[0] || a[1] - b[1]); // clue goes in the top-left cell + cages.push({ cells, product: cells.reduce((p, [rr, cc]) => p * solution[rr][cc], 1) }); + } + return { cages, cageOf }; +} + +// backtracking: rows/columns distinct, partial cage products must divide the clue +function countSolutions(n, cages, cageOf, limit) { + const g = Array.from({ length: n }, () => Array(n).fill(0)); + let count = 0; + const fits = (r, c, v) => { + for (let i = 0; i < n; i++) if (g[r][i] === v || g[i][c] === v) return false; + const cage = cages[cageOf[r][c]]; + let p = v; + let left = 0; + for (const [rr, cc] of cage.cells) { + if (rr === r && cc === c) continue; + if (g[rr][cc]) p *= g[rr][cc]; + else left++; + } + return left ? cage.product % p === 0 : p === cage.product; + }; + (function step(i) { + if (count >= limit) return; + if (i === n * n) return void count++; + const r = Math.floor(i / n); + const c = i % n; + for (let v = 1; v <= n; v++) { + if (!fits(r, c, v)) continue; + g[r][c] = v; + step(i + 1); + g[r][c] = 0; + } + })(0); + return count; +} + +function addStyle() { + if (document.getElementById('kenken-style')) return; + const s = document.createElement('style'); + s.id = 'kenken-style'; + s.textContent = ` + .kk-board { display: grid; grid-template-columns: repeat(var(--n), minmax(0, 76px)); width: min(100%, calc(var(--n) * 76px)); border: 2px solid var(--edge); border-radius: 8px; overflow: hidden; background: var(--surface); } + .kk-cell { position: relative; aspect-ratio: 1; border-style: solid; background: var(--surface); color: var(--ink); font: inherit; cursor: pointer; padding: 0; display: flex; align-items: center; justify-content: center; } + .kk-cell.sel { background: var(--accent-soft); box-shadow: inset 0 0 0 3px var(--accent); } + .kk-cell.good { background: var(--good-bg); } + .kk-cell.bad { background: var(--bad-bg); } + .kk-cell.sel.good, .kk-cell.sel.bad { box-shadow: inset 0 0 0 3px var(--accent); } + .kk-clue { position: absolute; top: 3px; left: 5px; font-size: clamp(0.7rem, 2.6vw, 0.85rem); font-weight: 800; color: var(--muted); } + .kk-val { font-size: clamp(1.4rem, 6vw, 2rem); font-weight: 900; } + .kk-cell.dup .kk-val { color: var(--bad); } + .kk-board.solved .kk-cell { background: var(--good-bg); } + .kk-pad { display: flex; gap: 8px; flex-wrap: wrap; justify-content: center; } + .kk-pad .btn { min-width: 56px; font-size: 1.4rem; font-weight: 900; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/ladders.js b/apps/web/src/demos/games/ladders.js new file mode 100644 index 0000000..1e08bf0 --- /dev/null +++ b/apps/web/src/demos/games/ladders.js @@ -0,0 +1,246 @@ +// Snakes & Ladders: race the CPU to square 50. Each turn starts with a fact: +// right, and you roll the die; wrong, and you shuffle on 1 square. Land on a +// ladder: answer a fact to climb it. Land on a snake's head: answer a fact to +// dodge it (wrong, and down you slide). The CPU answers ladder and snake facts +// too, and sometimes gets them wrong. Board on a canvas; die and keypad below. + +const COLS = 10; +const ROWS = 5; +const END = COLS * ROWS; +const LADDERS = { 3: 22, 6: 15, 9: 29, 24: 43, 27: 47 }; // short and mostly upright, so they don't tangle +const SNAKES = { 16: 4, 26: 14, 36: 17, 39: 20, 48: 32 }; +const CPU_RIGHT = { easy: 0.6, medium: 0.7, hard: 0.8 }; // how often the CPU answers right +const CELL = 60; + +export function mount(stage, ctx) { + const W = COLS * CELL; + const H = ROWS * CELL; + const { ctx: g } = ctx.canvas(stage, W, H); + const panel = document.createElement('div'); + panel.className = 'demo-row lad-panel'; + const dieEl = document.createElement('div'); + dieEl.className = 'demo-big lad-die'; + const padBox = document.createElement('div'); + panel.append(dieEl, padBox); + stage.append(panel); + + const pos = { you: 1, cpu: 1 }; + let turn = 'ask'; // 'ask' (your fact) | 'moving' | 'cpu' | 'over' + let die = 0; + let question = null; // { fact, kind: 'roll' | 'ladder' | 'snake', to, who } + let score = 0; + let right = 0; + let asked = 0; + + const pad = ctx.keypad(padBox, { onSubmit: answer, prompt: '' }); + + // square n → centre of its cell; row 0 is the bottom and rows snake left/right + const where = (n) => { + const i = n - 1; + const row = Math.floor(i / COLS); + const col = row % 2 ? COLS - 1 - (i % COLS) : i % COLS; + return { x: col * CELL + CELL / 2, y: H - row * CELL - CELL / 2 }; + }; + + function setTurn(t) { + turn = t; + pad.enable(t === 'ask'); + if (t !== 'ask') pad.prompt(t === 'over' ? '' : turn === 'cpu' ? 'CPU’s turn' : '…'); + dieEl.textContent = die ? `🎲 ${die}` : '🎲'; + draw(); + } + + // your turn: a fact to earn the roll + function yourTurn() { + die = 0; + ask({ fact: ctx.fact(), kind: 'roll', who: 'you' }); + ctx.hud.msg('Answer to roll the die!'); + } + + function ask(q) { + question = q; + asked++; + pad.clear(); + pad.prompt(`${q.fact.text} =`); + setTurn('ask'); + } + + // step a token one square at a time, then see what it landed on + function walk(who, steps) { + setTurn('moving'); + const step = () => { + if (pos[who] >= END || steps === 0) return land(who); + pos[who]++; + steps--; + draw(); + ctx.after(170, step); + }; + ctx.after(170, step); + } + + function land(who) { + const at = pos[who]; + if (at >= END) return win(who); + const to = LADDERS[at] || SNAKES[at]; + if (!to) return next(who); + const kind = LADDERS[at] ? 'ladder' : 'snake'; + const fact = ctx.fact(); + if (who === 'cpu') { + // the CPU "thinks", then answers (not always right) + question = { fact, to, kind, who }; + const ok = ctx.rng() < CPU_RIGHT[ctx.level]; + const said = ok ? fact.answer : ctx.pick(ctx.nearMisses(fact.answer, 3, fact)); + ctx.hud.msg(`CPU on a ${kind}: ${fact.text} = ?`); + ctx.after(1100, () => resolve(said)); + return; + } + ask({ fact, to, kind, who }); + ctx.hud.msg(kind === 'ladder' ? `A ladder! Answer to climb to ${to}` : 'A snake! Answer right to dodge it', kind === 'ladder' ? 'good' : 'bad'); + } + + function answer(v) { + if (turn !== 'ask') return; + const { fact, kind } = question; + if (kind !== 'roll') return resolve(v); + // the roll fact: right rolls the die, wrong moves 1 + question = null; + if (v === fact.answer) { + right++; + score += 5; + ctx.hud.score(score); + die = ctx.randInt(1, 6); + ctx.hud.msg(`${fact.text} = ${fact.answer} ✓ You rolled ${die}`, 'good'); + walk('you', die); + } else { + die = 1; + ctx.hud.msg(`${fact.text} = ${fact.answer}, not ${v}. Move 1 square`, 'bad'); + walk('you', 1); + } + } + + // climb or slide (or not) depending on the answer + function resolve(said) { + const { fact, to, kind, who } = question; + const ok = said === fact.answer; + const name = who === 'you' ? 'You' : 'CPU'; + if (who === 'you' && ok) { + right++; + score += kind === 'ladder' ? 20 : 10; + ctx.hud.score(score); + } + if (ok) ctx.hud.msg(`${name}: ${fact.text} = ${fact.answer} ✓ ${kind === 'ladder' ? `Climb to ${to}!` : 'Snake dodged!'}`, who === 'you' ? 'good' : ''); + else ctx.hud.msg(`${name}: ${fact.text} = ${fact.answer}, not ${said}. ${kind === 'ladder' ? 'No climb' : `Slide to ${to}`}`, 'bad'); + if ((kind === 'ladder') === ok) pos[who] = to; + question = null; + setTurn('moving'); + ctx.after(ok ? 900 : 1500, () => next(who)); + } + + function next(who) { + if (who === 'you') { + setTurn('cpu'); + ctx.after(700, () => { + die = ctx.randInt(1, 6); + ctx.hud.msg(`CPU rolled ${die}`); + walk('cpu', die); + }); + } else { + ctx.hud.info(`You ${pos.you} · CPU ${pos.cpu}`); + yourTurn(); + } + } + + function win(who) { + pos[who] = END; + setTurn('over'); + const won = who === 'you'; + ctx.hud.msg(won ? 'You reached 50 first! 🏁' : 'The CPU reached 50 first', won ? 'good' : 'bad'); + ctx.after(1200, () => + ctx.finish({ title: won ? 'You win the race!' : 'The CPU wins this time', won, lines: [`${right} of ${asked} facts right`, `Score: ${score}`] }) + ); + } + + + function draw() { + const ink = ctx.color('--ink'); + for (let n = 1; n <= END; n++) { + const { x, y } = where(n); + g.fillStyle = (Math.floor((n - 1) / COLS) + n) % 2 ? ctx.color('--surface') : ctx.color('--soft-2'); + if (n === END) g.fillStyle = ctx.color('--gold-bg'); + g.fillRect(x - CELL / 2, y - CELL / 2, CELL, CELL); + g.strokeStyle = ctx.color('--board-grid'); + g.strokeRect(x - CELL / 2, y - CELL / 2, CELL, CELL); + g.fillStyle = ctx.color('--muted'); + g.font = '800 13px system-ui, sans-serif'; + g.textAlign = 'left'; + g.textBaseline = 'top'; + g.fillText(n === END ? '50 🏁' : String(n), x - CELL / 2 + 4, y - CELL / 2 + 3); + } + // ladders: two rails and rungs + for (const [a, b] of Object.entries(LADDERS)) { + const p = where(Number(a)); + const q = where(b); + const len = Math.hypot(q.x - p.x, q.y - p.y); + const nx = ((q.y - p.y) / len) * 8; + const ny = (-(q.x - p.x) / len) * 8; + g.strokeStyle = '#a0662b'; + g.lineWidth = 4; + for (const s of [1, -1]) { + g.beginPath(); + g.moveTo(p.x + nx * s, p.y + ny * s); + g.lineTo(q.x + nx * s, q.y + ny * s); + g.stroke(); + } + g.lineWidth = 3; + for (let t = 0.12; t < 1; t += 0.16) { + const x = p.x + (q.x - p.x) * t; + const y = p.y + (q.y - p.y) * t; + g.beginPath(); + g.moveTo(x + nx, y + ny); + g.lineTo(x - nx, y - ny); + g.stroke(); + } + } + // snakes: a wiggly body from head (top) to tail + for (const [a, b] of Object.entries(SNAKES)) { + const p = where(Number(a)); + const q = where(b); + g.strokeStyle = '#2a9d5c'; + g.lineWidth = 7; + g.lineCap = 'round'; + g.beginPath(); + for (let t = 0; t <= 1.001; t += 0.05) { + const wob = Math.sin(t * Math.PI * 4) * 10; + const len = Math.hypot(q.x - p.x, q.y - p.y); + g.lineTo(p.x + (q.x - p.x) * t + ((q.y - p.y) / len) * wob, p.y + (q.y - p.y) * t - ((q.x - p.x) / len) * wob); + } + g.stroke(); + g.fillStyle = '#1d6e41'; + g.beginPath(); + g.arc(p.x, p.y, 9, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#fff'; + g.fillRect(p.x - 4, p.y - 3, 2, 2); + g.fillRect(p.x + 2, p.y - 3, 2, 2); + } + g.lineWidth = 1; + g.lineCap = 'butt'; + // tokens (side by side when they share a square) + g.font = '30px system-ui, sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + for (const [who, face, dx] of [['cpu', '🤖', 10], ['you', '🙂', -10]]) { + const { x, y } = where(Math.min(END, pos[who])); + const off = pos.you === pos.cpu ? dx : 0; + g.fillStyle = ink; + g.fillText(face, x + off, y + 6); + } + } + + ctx.hud.info('You 1 · CPU 1'); + yourTurn(); + + return { + state: () => ({ you: pos.you, cpu: pos.cpu, turn, die, end: END, question: question && { text: question.fact.text, answer: question.fact.answer, kind: question.kind, to: question.to }, ladders: LADDERS, snakes: SNAKES, score, right, asked }), + }; +} diff --git a/apps/web/src/demos/games/match3.js b/apps/web/src/demos/games/match3.js new file mode 100644 index 0000000..3640427 --- /dev/null +++ b/apps/web/src/demos/games/match3.js @@ -0,0 +1,210 @@ +// Product Match (like Match-3): pick three touching tiles in a straight line +// whose product is the target. They clear, the tiles above fall down and new +// ones drop in. The target always comes from a line on the board, so there is +// always at least one answer. + +const N = 6; +const GOAL = 8; // matches to win +const VALUES = { easy: [1, 2, 3, 5], medium: [1, 2, 3, 4, 5, 6], hard: [1, 2, 3, 4, 5, 6, 7, 8, 9] }; +const MAX = { easy: 60, medium: 120, hard: 200 }; // biggest target +const STEPS = [1, N]; // right, down + +export function mount(stage, ctx) { + addStyle(); + const values = VALUES[ctx.level]; + let grid = Array.from({ length: N * N }, () => ctx.pick(values)); + let selected = []; + let cursor = 0; + let target = 0; + let hearts = 3; + let score = 0; + let matches = 0; + let busy = false; + let keyboard = false; // show the cursor only once the keys are used + const fresh = new Set(); // tiles that just dropped in (for the animation) + + const head = document.createElement('div'); + head.className = 'm3-head'; + const board = document.createElement('div'); + board.className = 'm3-board'; + board.style.setProperty('--n', N); + const cells = Array.from({ length: N * N }, (_, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'm3-tile'; + b.dataset.i = i; + return board.appendChild(b); + }); + stage.append(head, board); + + const rowOf = (i) => Math.floor(i / N); + const colOf = (i) => i % N; + // every straight line of three, as index triples + const allLines = []; + for (let i = 0; i < N * N; i++) { + if (colOf(i) <= N - 3) allLines.push([i, i + 1, i + 2]); + if (rowOf(i) <= N - 3) allLines.push([i, i + N, i + 2 * N]); + } + const product = (line) => line.reduce((p, i) => p * grid[i], 1); + const solutions = () => allLines.filter((l) => product(l) === target); + + // a new target from a line on the board (so it can always be made); + // prefer lines without a 1 and with a product that isn't tiny + function newTarget() { + const ok = allLines.filter((l) => product(l) <= MAX[ctx.level] && product(l) > 3); + const good = ok.filter((l) => l.every((i) => grid[i] !== 1)); + target = product(ctx.pick(good.length && ctx.rng() < 0.8 ? good : ok)); + render(); + } + + // is `sel` a straight, touching line (in any order)? + function isLine(sel) { + const s = [...sel].sort((a, b) => a - b); + return STEPS.some((d) => s.every((v, k) => k === 0 || (v - s[k - 1] === d && (d !== 1 || rowOf(v) === rowOf(s[0]))))); + } + + function tap(i) { + if (busy) return; + cursor = i; + if (selected.includes(i)) selected = selected.filter((x) => x !== i); + else if (!selected.length || isLine([...selected, i])) selected.push(i); + else selected = [i]; // not touching: start a new line from here + render(); + if (selected.length === 3) check(); + } + + function check() { + const line = [...selected].sort((a, b) => a - b); + const p = product(line); + const text = line.map((i) => grid[i]).join(' × '); + if (p === target) { + matches++; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${text} = ${target} ✓`, 'good'); + busy = true; + for (const i of line) cells[i].classList.add('good'); + ctx.after(450, () => { + clear(line); + selected = []; + busy = false; + if (matches >= GOAL) return end(true); + newTarget(); + }); + } else { + hearts--; + ctx.hud.hearts(hearts); + ctx.hud.msg(`${text} = ${p}, not ${target}`, 'bad'); + busy = true; + for (const i of line) cells[i].classList.add('bad'); + ctx.after(900, () => { + selected = []; + busy = false; + if (hearts <= 0) return end(false); + render(); + }); + } + } + + // remove the tiles; the ones above fall down; new ones fill the top + function clear(line) { + const gone = new Set(line); + fresh.clear(); + for (let c = 0; c < N; c++) { + const col = []; + for (let r = N - 1; r >= 0; r--) if (!gone.has(r * N + c)) col.push(grid[r * N + c]); + for (let r = N - 1, k = 0; r >= 0; r--, k++) { + const i = r * N + c; + if (k < col.length) grid[i] = col[k]; + else { + grid[i] = ctx.pick(values); + fresh.add(i); + } + } + } + } + + function end(won) { + busy = true; + render(); + ctx.after(500, () => + ctx.finish({ + title: won ? 'Board master!' : 'Out of hearts!', + won, + lines: [`Matches: ${matches} of ${GOAL}`, `Hearts left: ${hearts}`, `Score: ${score}`], + }) + ); + } + + function render() { + const sel = selected.map((i) => grid[i]); + const sofar = sel.length ? `${sel.join(' × ')}${sel.length < 3 ? ' × ?' : ''}` : 'Pick 3 in a row'; + head.innerHTML = `
    Make ${target}
    ${sofar}
    `; + cells.forEach((b, i) => { + b.textContent = grid[i]; + b.className = `m3-tile v${grid[i]}${selected.includes(i) ? ' sel' : ''}${keyboard && i === cursor ? ' cur' : ''}${fresh.has(i) ? ' drop' : ''}`; + b.setAttribute('aria-pressed', selected.includes(i)); + }); + fresh.clear(); + ctx.hud.info(`Matches ${matches}/${GOAL}`); + } + + ctx.listen(board, 'click', (e) => { + const b = e.target.closest('.m3-tile'); + if (b) tap(Number(b.dataset.i)); + }); + // keyboard: arrows move the cursor, Enter/Space pick, Escape clears + ctx.listen(window, 'keydown', (e) => { + const r = rowOf(cursor); + const c = colOf(cursor); + const moves = { ArrowLeft: [r, c - 1], ArrowRight: [r, c + 1], ArrowUp: [r - 1, c], ArrowDown: [r + 1, c] }; + if (moves[e.key]) { + keyboard = true; + const [nr, nc] = moves[e.key]; + if (nr >= 0 && nr < N && nc >= 0 && nc < N) cursor = nr * N + nc; + render(); + } else if (e.key === 'Enter' || e.key === ' ') { + tap(cursor); // (preventDefault below stops a focused tile button clicking too) + } else if (e.key === 'Escape') { + selected = []; + render(); + } else return; + e.preventDefault(); + }); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + newTarget(); + ctx.hud.msg('Tap three touching tiles in a line'); + + return { + state: () => ({ size: N, grid: [...grid], target, selected: [...selected], cursor, hearts, score, matches, goal: GOAL, solutions: solutions() }), + }; +} + +function addStyle() { + if (document.getElementById('m3-style')) return; + const s = document.createElement('style'); + s.id = 'm3-style'; + s.textContent = ` + .m3-head { text-align: center; } + .m3-head .demo-big { margin: 0; } + .m3-target { color: var(--accent); font-size: 2.2rem; } + .m3-sofar { min-height: 1.5em; font-weight: 800; font-size: 1.2rem; color: var(--muted); } + .m3-board { display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 6px; width: min(100%, 420px); } + .m3-tile { aspect-ratio: 1; border: 2px solid var(--edge); border-radius: 12px; font: inherit; font-weight: 900; font-size: clamp(1.3rem, 6vw, 1.8rem); color: var(--ink); background: var(--surface); cursor: pointer; box-shadow: 0 3px 0 var(--edge-shadow); padding: 0; touch-action: manipulation; } + .m3-tile.v1 { background: var(--soft-2); color: var(--muted); } + .m3-tile.v2, .m3-tile.v7 { background: var(--gold-bg); } + .m3-tile.v3, .m3-tile.v8 { background: var(--good-bg); } + .m3-tile.v4, .m3-tile.v9 { background: var(--accent-soft); } + .m3-tile.v5 { background: color-mix(in srgb, var(--accent) 22%, var(--surface)); } + .m3-tile.v6 { background: color-mix(in srgb, var(--good) 22%, var(--surface)); } + .m3-tile.cur { outline: 3px dashed var(--muted); outline-offset: 1px; } + .m3-tile.sel { background: var(--accent); color: var(--accent-ink); transform: translateY(-2px); } + .m3-tile.good { background: var(--good); color: #fff; } + .m3-tile.bad { background: var(--bad); color: #fff; } + .m3-tile.drop { animation: m3-drop 0.3s ease-out; } + @keyframes m3-drop { from { transform: translateY(-40%); opacity: 0; } } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/memory.js b/apps/web/src/demos/games/memory.js new file mode 100644 index 0000000..7862918 --- /dev/null +++ b/apps/web/src/demos/games/memory.js @@ -0,0 +1,95 @@ +// Fact Pairs (like Memory): flip two cards; a fact and its answer are a pair. +// A reference demo for a DOM-based game: build elements in `stage`, use ctx.listen +// for events and ctx.after for timers, report with ctx.hud, end with ctx.finish. + +const PAIRS = { easy: 6, medium: 8, hard: 10 }; + +export function mount(stage, ctx) { + // distinct answers, so every answer card has exactly one fact + const facts = []; + const answers = new Set(); + for (let tries = 0; facts.length < PAIRS[ctx.level] && tries < 500; tries++) { + const f = ctx.fact(); + if (f.b === 1 || f.a === 1 || answers.has(f.answer)) continue; + answers.add(f.answer); + facts.push(f); + } + const cards = ctx.shuffle(facts.flatMap((f, i) => [{ pair: i, text: f.text, kind: 'fact' }, { pair: i, text: String(f.answer), kind: 'answer' }])); + + const grid = document.createElement('div'); + grid.className = 'memory-grid'; + grid.style.setProperty('--cols', cards.length > 16 ? 5 : 4); + cards.forEach((c, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `memory-card ${c.kind}`; + b.dataset.i = i; + b.innerHTML = `?${c.text}`; + b.setAttribute('aria-label', 'Hidden card'); + grid.append(b); + }); + stage.append(grid); + addStyle(); + + let open = []; // indexes of the face-up, unmatched cards + let tries = 0; + let found = 0; + let busy = false; + ctx.hud.info(`${facts.length} pairs`); + + ctx.listen(grid, 'click', (e) => { + const b = e.target.closest('.memory-card'); + if (!b || busy || b.classList.contains('up')) return; + const i = Number(b.dataset.i); + b.classList.add('up'); + b.setAttribute('aria-label', cards[i].text); + open.push(i); + if (open.length < 2) return; + tries++; + const [x, y] = open; + if (cards[x].pair === cards[y].pair && cards[x].kind !== cards[y].kind) { + found++; + for (const k of open) grid.children[k].classList.add('matched'); + open = []; + const f = facts[cards[x].pair]; + ctx.hud.msg(`${f.text} = ${f.answer} ✓`, 'good'); + ctx.hud.score(found * 10); + if (found === facts.length) { + ctx.after(600, () => + ctx.finish({ title: 'All pairs found!', won: true, lines: [`${facts.length} pairs in ${tries} tries`, tries <= facts.length + 2 ? 'Amazing memory!' : 'Try for fewer tries next time.'] }) + ); + } + } else { + busy = true; + ctx.hud.msg('Not a pair', 'bad'); + ctx.after(900, () => { + for (const k of open) { + grid.children[k].classList.remove('up'); + grid.children[k].setAttribute('aria-label', 'Hidden card'); + } + open = []; + busy = false; + }); + } + }); + + return { state: () => ({ cards, found, tries, pairs: facts.length }) }; +} + +function addStyle() { + if (document.getElementById('memory-style')) return; + const s = document.createElement('style'); + s.id = 'memory-style'; + s.textContent = ` + .memory-grid { display: grid; grid-template-columns: repeat(var(--cols), minmax(0, 110px)); gap: 10px; width: 100%; justify-content: center; } + .memory-card { display: flex; align-items: center; justify-content: center; aspect-ratio: 3 / 4; border: 2px solid var(--edge); border-radius: 12px; background: var(--accent); color: #fff; font: inherit; font-weight: 900; font-size: 1.3rem; cursor: pointer; box-shadow: 0 4px 0 var(--edge-shadow); position: relative; } + .memory-card .memory-back { display: none; } + .memory-card.up { background: var(--paper); color: var(--ink); } + .memory-card.up .memory-front { display: none; } + .memory-card.up .memory-back { display: inline; } + .memory-card.answer.up { background: var(--gold-bg); } + .memory-card.matched, .memory-card.answer.matched { background: var(--good); color: #fff; border-color: var(--good-ink); cursor: default; } + .memory-front { font-size: 1.8rem; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/minesweeper.js b/apps/web/src/demos/games/minesweeper.js new file mode 100644 index 0000000..97bdf0d --- /dev/null +++ b/apps/web/src/demos/games/minesweeper.js @@ -0,0 +1,244 @@ +// Multiple Sweeper (like Minesweeper): every square shows a number, and the +// mines are the multiples of the round's number. Open the squares that aren't +// multiples; flag the ones that are. Opened squares show a clue: how many mines +// touch them, so the child can check their thinking. +// Forgiving: a small 7 × 7 board, 3 hearts, and the first slip is free. + +const N = 7; +const MINES = { easy: 8, medium: 10, hard: 12 }; + +export function mount(stage, ctx) { + addStyle(); + const n = ctx.pick(ctx.L.tables.filter((t) => t > 1)); + const top = n * ctx.L.upTo; + const mineAt = new Set(ctx.shuffle([...Array(N * N).keys()]).slice(0, MINES[ctx.level])); + const cells = [...Array(N * N).keys()].map((i) => { + let value; + if (mineAt.has(i)) value = n * ctx.randInt(1, ctx.L.upTo); + else { + do value = ctx.randInt(2, top); + while (value % n === 0); + } + return { value, mine: value % n === 0, open: false, flag: false, boom: false }; + }); + const around = (i) => { + const r = Math.floor(i / N); + const c = i % N; + const out = []; + for (let dr = -1; dr <= 1; dr++) + for (let dc = -1; dc <= 1; dc++) if ((dr || dc) && r + dr >= 0 && r + dr < N && c + dc >= 0 && c + dc < N) out.push((r + dr) * N + c + dc); + return out; + }; + cells.forEach((cell, i) => (cell.clue = around(i).filter((j) => cells[j].mine).length)); + const safeTotal = cells.filter((c) => !c.mine).length; + + let hearts = 3; + let score = 0; + let slips = 0; // mistakes so far (the first one is free) + let over = false; + let flagMode = false; + let cursor = Math.floor((N * N) / 2); + let keyboard = false; + + const head = document.createElement('div'); + head.className = 'demo-big'; + head.innerHTML = `Mines are multiples of ${n}`; + const board = document.createElement('div'); + board.className = 'ms-board'; + const els = cells.map((_, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'ms-cell'; + b.dataset.i = i; + return board.appendChild(b); + }); + const modeBtn = document.createElement('button'); + modeBtn.type = 'button'; + modeBtn.className = 'btn ms-mode'; + stage.append(head, board, modeBtn); + + const fact = (v) => `${n} × ${v / n} = ${v}`; + const neighbours = (v) => { + const lo = Math.floor(v / n); + return lo < 1 ? `the ×${n} table starts at ${n}` : `${fact(lo * n)}, ${fact((lo + 1) * n)}`; + }; + const opened = () => cells.filter((c) => c.open).length; + + // a wrong move: the first one is free, then each costs a heart + function slip(text) { + slips++; + if (slips === 1) return ctx.hud.msg(`${text} This one’s free!`, 'bad'); + hearts--; + ctx.hud.hearts(hearts); + ctx.hud.msg(text, 'bad'); + if (hearts <= 0) end(false); + } + + function open(i) { + const c = cells[i]; + if (over || c.open || c.boom) return; + if (c.flag) return ctx.hud.msg('That square has a flag. Flag again to take it off.'); + if (c.mine) { + if (slips === 0) return slip(`Careful! ${fact(c.value)}: a multiple of ${n}, so it’s a mine. Flag it.`); + c.boom = true; + slip(`💥 ${fact(c.value)}: a multiple of ${n}!`); + return render(); + } + // open it, and spread through squares with no mines around them + const todo = [i]; + let count = 0; + while (todo.length) { + const k = todo.pop(); + const x = cells[k]; + if (x.open || x.mine || x.flag) continue; + x.open = true; + count++; + if (x.clue === 0) todo.push(...around(k)); + } + score += count * 5; + ctx.hud.score(score); + ctx.hud.msg(`${c.value} isn’t a multiple of ${n} ✓${c.clue ? ` ${c.clue} mine${c.clue > 1 ? 's touch' : ' touches'} it.` : ''}`, 'good'); + render(); + if (opened() === safeTotal) end(true); + } + + // flags are checked straight away, so a flag is an answer too + function flag(i) { + const c = cells[i]; + if (over || c.open || c.boom) return; + if (c.flag) { + c.flag = false; + return render(); + } + if (!c.mine) return slip(`${c.value} isn’t a multiple of ${n} (${neighbours(c.value)}). Open it!`); + c.flag = true; + score += 5; + ctx.hud.score(score); + ctx.hud.msg(`${fact(c.value)} 🚩`, 'good'); + render(); + } + + function end(won) { + over = true; + // show every mine: flagged for you on a win, as bombs otherwise + for (const c of cells) if (c.mine && !c.flag && !c.boom) c[won ? 'flag' : 'boom'] = true; + render(); + ctx.after(800, () => + ctx.finish({ + title: won ? 'Board cleared!' : 'Out of hearts!', + won, + lines: [`Multiples of ${n}: ${[...new Set(cells.filter((c) => c.mine).map((c) => c.value))].sort((a, b) => a - b).join(', ')}`, `Squares opened: ${opened()} of ${safeTotal}`, `Mistakes: ${slips}`, `Score: ${score}`], + }) + ); + } + + function render() { + cells.forEach((c, i) => { + const b = els[i]; + b.className = `ms-cell${c.open ? ' open' : ''}${c.flag ? ' flag' : ''}${c.boom ? ' boom' : ''}${keyboard && i === cursor ? ' cur' : ''}`; + if (c.open) b.innerHTML = `${c.value}${c.clue || ''}`; + else if (c.flag) b.innerHTML = `${c.value}🚩`; + else if (c.boom) b.innerHTML = `${c.value}💣`; + else b.textContent = c.value; + b.setAttribute('aria-label', c.open ? `${c.value}, ${c.clue} mines around` : c.flag ? `${c.value}, flagged` : `${c.value}, closed`); + }); + modeBtn.textContent = flagMode ? '🚩 Flag mode: ON' : '🚩 Flag mode: off'; + modeBtn.classList.toggle('btn-primary', flagMode); + ctx.hud.info(`${safeTotal - opened()} safe left`); + } + + // mouse/touch: tap opens (or flags in flag mode); right-click or long-press flags + let press = null; + let longPressed = false; + const cellOf = (e) => e.target.closest('.ms-cell'); + ctx.listen(board, 'click', (e) => { + const b = cellOf(e); + if (!b) return; + if (longPressed) return (longPressed = false); + const i = Number(b.dataset.i); + cursor = i; + if (flagMode) flag(i); + else open(i); + }); + ctx.listen(board, 'contextmenu', (e) => { + const b = cellOf(e); + if (!b) return; + e.preventDefault(); + flag(Number(b.dataset.i)); + }); + ctx.listen(board, 'pointerdown', (e) => { + const b = cellOf(e); + if (!b || e.pointerType === 'mouse') return; + longPressed = false; + press = ctx.after(450, () => { + longPressed = true; + flag(Number(b.dataset.i)); + }); + }); + for (const type of ['pointerup', 'pointerleave', 'pointercancel']) + ctx.listen(board, type, () => { + if (press) press(); + press = null; + }); + ctx.listen(modeBtn, 'click', () => { + flagMode = !flagMode; + render(); + }); + // keyboard: arrows move, Enter/Space open, F flags + ctx.listen(window, 'keydown', (e) => { + const r = Math.floor(cursor / N); + const c = cursor % N; + const moves = { ArrowLeft: [r, c - 1], ArrowRight: [r, c + 1], ArrowUp: [r - 1, c], ArrowDown: [r + 1, c] }; + if (moves[e.key]) { + const [nr, nc] = moves[e.key]; + if (nr >= 0 && nr < N && nc >= 0 && nc < N) cursor = nr * N + nc; + keyboard = true; + render(); + } else if (e.key === 'Enter' || e.key === ' ') open(cursor); + else if (e.key === 'f' || e.key === 'F') flag(cursor); + else return; + e.preventDefault(); + }); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + render(); + ctx.hud.msg(`Open squares that aren’t in the ×${n} table`); + + return { + state: () => ({ + n, + size: N, + hearts, + score, + slips, + over, + flagMode, + cursor, + safeLeft: safeTotal - opened(), + cells: cells.map((c) => ({ value: c.value, mine: c.mine, open: c.open, flag: c.flag, boom: c.boom, clue: c.open ? c.clue : null })), + }), + }; +} + +function addStyle() { + if (document.getElementById('ms-style')) return; + const s = document.createElement('style'); + s.id = 'ms-style'; + s.textContent = ` + .ms-n { color: var(--accent); font-size: 2.2rem; } + .ms-board { display: grid; grid-template-columns: repeat(7, 1fr); gap: 5px; width: min(100%, 440px); user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; } + .ms-cell { position: relative; aspect-ratio: 1; display: flex; align-items: center; justify-content: center; padding: 0; border: 2px solid var(--edge); border-radius: 10px; background: var(--gold-bg); color: var(--ink); font: inherit; font-weight: 900; font-size: clamp(0.95rem, 4.4vw, 1.35rem); cursor: pointer; box-shadow: 0 3px 0 var(--edge-shadow); touch-action: manipulation; } + .ms-cell small { position: absolute; top: 2px; left: 5px; font-size: 0.62rem; font-weight: 800; color: var(--muted); } + .ms-cell.open { background: var(--surface); box-shadow: none; border-color: var(--line); cursor: default; } + .ms-cell.open b { font-size: 1.4em; } + .ms-cell.flag { background: var(--good-bg); } + .ms-cell.boom { background: var(--bad-bg); } + .ms-cell.cur { outline: 3px dashed var(--accent); outline-offset: 1px; } + .ms-cell .c1 { color: #2f6fd6; } + .ms-cell .c2 { color: var(--good); } + .ms-cell .c3, .ms-cell .c4, .ms-cell .c5, .ms-cell .c6 { color: var(--bad); } + .ms-mode { min-width: 200px; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/nonogram.js b/apps/web/src/demos/games/nonogram.js new file mode 100644 index 0000000..25c6a2f --- /dev/null +++ b/apps/web/src/demos/games/nonogram.js @@ -0,0 +1,168 @@ +// Picture Products (like a Nonogram): every row and column clue is a fact, and +// its answer is the length of one block of filled squares. Solve the facts, +// fill the blocks, and a picture appears. +// Blocks of 4 and 6 can be times facts (2 × 3 → 6); the rest are divisions from +// the level's tables (15 ÷ 5 → 3), the other side of a times fact. + +const PICTURES = { + 5: [ + { name: 'a heart', color: '#e4572e', rows: ['.X.X.', 'XXXXX', 'XXXXX', '.XXX.', '..X..'] }, + { name: 'a tree', color: '#2a9d5c', rows: ['..X..', '.XXX.', 'XXXXX', '..X..', '.XXX.'] }, + { name: 'a house', color: '#c77d3a', rows: ['..X..', '.XXX.', 'XXXXX', '.XXX.', '.X.X.'] }, + { name: 'an arrow', color: '#3a7bd5', rows: ['..X..', '.XX..', 'XXXXX', '.XX..', '..X..'] }, + ], + 6: [ + { name: 'a cat', color: '#c77d3a', rows: ['X....X', 'XX..XX', 'XXXXXX', 'X.XX.X', 'XXXXXX', '.XXXX.'] }, + { name: 'a boat', color: '#3a7bd5', rows: ['..X...', '..XX..', '..XXX.', '..X...', 'XXXXXX', '.XXXX.'] }, + { name: 'a mushroom', color: '#d64533', rows: ['.XXXX.', 'XXXXXX', 'X.XX.X', '..XX..', '..XX..', '.XXXX.'] }, + { name: 'a smiley', color: '#e0a800', rows: ['.XXXX.', 'X.XX.X', 'XXXXXX', 'X.XX.X', 'XX..XX', '.XXXX.'] }, + ], +}; +const SIZE = { easy: 5, medium: 6, hard: 6 }; + +// the block lengths along one line of 0/1 cells +const runs = (line) => line.join('').split('0').filter(Boolean).map((s) => s.length); +const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]); + +export function mount(stage, ctx) { + const n = SIZE[ctx.level]; + const pic = ctx.pick(PICTURES[n]); + const solution = pic.rows.map((row) => [...row].map((ch) => (ch === 'X' ? 1 : 0))); + const grid = solution.map((row) => row.map(() => 0)); + const col = (g, c) => g.map((row) => row[c]); + + // one fact per block: a times fact when the length has one (2 × 3 → 6, always on + // Easy), otherwise a division from the level's tables (15 ÷ 5 → 3) + const clueFor = (len) => { + const pairs = []; + for (let a = 2; a < len; a++) if (len % a === 0) pairs.push([a, len / a]); + if (pairs.length && (ctx.level === 'easy' || ctx.rng() < 0.4)) { + const [a, b] = ctx.pick(pairs); + return { text: `${a} × ${b}`, answer: len }; + } + const t = ctx.pick(ctx.L.tables); + return { text: `${t * len} ÷ ${t}`, answer: len }; + }; + const compact = (f) => `${f.text.replace(/ /g, '')}`; // narrow enough for a phone + const rowClues = solution.map((row) => runs(row).map(clueFor)); + const colClues = solution[0].map((_, c) => runs(col(solution, c)).map(clueFor)); + const rowOK = Array(n).fill(false); + const colOK = Array(n).fill(false); + let score = 0; + let taps = 0; + let done = false; + + addStyle(); + const board = document.createElement('div'); + board.className = 'ng-board'; + board.style.setProperty('--n', n); + board.append(Object.assign(document.createElement('div'), { className: 'ng-corner' })); + const colEls = colClues.map((clues) => { + const d = document.createElement('div'); + d.className = 'ng-clue ng-col'; + d.innerHTML = clues.map(compact).join(''); + board.append(d); + return d; + }); + const rowEls = []; + const cells = []; + for (let r = 0; r < n; r++) { + const d = document.createElement('div'); + d.className = 'ng-clue ng-row'; + d.innerHTML = rowClues[r].map(compact).join(''); + board.append(d); + rowEls.push(d); + for (let c = 0; c < n; c++) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'ng-cell'; + b.dataset.r = r; + b.dataset.c = c; + b.setAttribute('aria-label', `Row ${r + 1}, column ${c + 1}`); + board.append(b); + cells.push(b); + } + } + stage.append(board); + + // What's wrong with a line, as a sentence that shows the facts (or null if it's fine so far). + function advice(name, line, clues) { + const want = clues.reduce((s, f) => s + f.answer, 0); + const have = line.reduce((s, x) => s + x, 0); + const facts = clues.map((f) => `${f.text} = ${f.answer}`).join(', then '); + if (have > want) return `${name}: ${facts}. That's ${want} square${want > 1 ? 's' : ''}, not ${have}`; + if (have === want && !same(runs(line), clues.map((f) => f.answer))) { + return clues.length > 1 ? `${name}: blocks of ${facts}, with gaps between` : `${name}: ${facts}, all in one block`; + } + return null; + } + + function update(r, c) { + const check = (name, line, clues, ok, i, el) => { + const now = same(runs(line), clues.map((f) => f.answer)); + if (now && !ok[i]) score += 10; + ok[i] = now; + el.classList.toggle('ok', now); + const tip = now ? null : advice(name, line, clues); + el.classList.toggle('bad', !!tip); + return { now, tip }; + }; + const row = check(`Row ${r + 1}`, grid[r], rowClues[r], rowOK, r, rowEls[r]); + const column = check(`Column ${c + 1}`, col(grid, c), colClues[c], colOK, c, colEls[c]); + const tip = row.tip || column.tip; + if (tip) ctx.hud.msg(tip, 'bad'); + else if (row.now || column.now) { + const clues = row.now ? rowClues[r] : colClues[c]; + ctx.hud.msg(`${row.now ? `Row ${r + 1}` : `Column ${c + 1}`} ✓ ${clues.map((f) => `${f.text} = ${f.answer}`).join(', ')}`, 'good'); + } else ctx.hud.msg(''); + ctx.hud.score(score); + ctx.hud.info(`${rowOK.filter(Boolean).length + colOK.filter(Boolean).length} of ${2 * n} lines right`); + if (rowOK.every(Boolean) && colOK.every(Boolean)) win(); + } + + function win() { + done = true; + board.classList.add('done'); + board.style.setProperty('--pic', pic.color); + ctx.hud.msg(`It's ${pic.name}! 🎉`, 'good'); + ctx.after(1400, () => ctx.finish({ title: `You drew ${pic.name}!`, won: true, lines: [`All ${2 * n} clues solved`, `${taps} taps`, `Score: ${score}`] })); + } + + ctx.listen(board, 'click', (e) => { + const b = e.target.closest('.ng-cell'); + if (!b || done) return; + const r = Number(b.dataset.r); + const c = Number(b.dataset.c); + grid[r][c] ^= 1; + taps++; + b.classList.toggle('on', !!grid[r][c]); + update(r, c); + }); + + ctx.hud.info(`0 of ${2 * n} lines right`); + ctx.hud.msg('Solve a clue, then fill that many squares in a block'); + + return { + state: () => ({ n, picture: pic.name, solution, grid: grid.map((r) => [...r]), rowClues, colClues, rowOK: [...rowOK], colOK: [...colOK], score, done }), + }; +} + +function addStyle() { + if (document.getElementById('nonogram-style')) return; + const s = document.createElement('style'); + s.id = 'nonogram-style'; + s.textContent = ` + .ng-board { --cell: min(56px, calc((100vw - 170px) / var(--n))); display: grid; grid-template-columns: auto repeat(var(--n), var(--cell)); grid-auto-rows: auto; gap: 3px; align-items: stretch; } + .ng-clue { display: flex; gap: 2px; font-size: clamp(0.66rem, 2.7vw, 0.95rem); font-weight: 800; color: var(--ink); background: var(--soft-2); border-radius: 6px; padding: 3px 4px; border: 2px solid transparent; } + .ng-clue span { white-space: nowrap; } + .ng-col { padding: 3px 0; flex-direction: column; justify-content: flex-end; align-items: center; text-align: center; } + .ng-row { flex-direction: row; justify-content: flex-end; align-items: center; gap: 6px; } + .ng-clue.ok { background: var(--good-bg); color: var(--good-ink); border-color: var(--good-line); } + .ng-clue.bad { background: var(--bad-bg); color: var(--bad); border-color: var(--bad); } + .ng-cell { width: var(--cell); aspect-ratio: 1; border: 2px solid var(--board-grid); border-radius: 6px; background: var(--surface); cursor: pointer; padding: 0; } + .ng-cell.on { background: var(--ink); border-color: var(--ink); } + .ng-board.done .ng-cell.on { background: var(--pic); border-color: var(--pic); transition: background 0.6s; } + .ng-board.done .ng-cell:not(.on) { opacity: 0.35; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/pacman.js b/apps/web/src/demos/games/pacman.js new file mode 100644 index 0000000..e9ba91f --- /dev/null +++ b/apps/web/src/demos/games/pacman.js @@ -0,0 +1,343 @@ +// Multiple Muncher (like Pac-Man): a maze of numbered dots. Eat only the +// multiples of the number at the top; wrong dots and ghosts cost a heart. +// Movement is cell to cell (smoothly drawn), with the next turn remembered, +// so it's easy to steer with keys, the on-screen pad or a tap in the maze. + +const MAP = [ + '#############', + '#.....#.....#', + '#.###.#.###.#', + '#...........#', + '#.#.##.##.#.#', + '#.#.......#.#', + '#.#.##.##.#.#', + '#...........#', + '#.###.#.###.#', + '#.....#.....#', + '#############', +]; +const ROWS = MAP.length; +const COLS = MAP[0].length; +const CELL = 40; +const TOP = 44; // header band with the number +const W = COLS * CELL; +const H = ROWS * CELL + TOP; +const START = { r: 9, c: 3 }; +const GHOSTS = [{ r: 5, c: 5 }, { r: 5, c: 7 }]; +const GHOST_COUNT = { easy: 1, medium: 2, hard: 2 }; +const SPEED = 3.6; // cells per second +const GHOST_SPEED = { easy: 1.6, medium: 2.1, hard: 2.6 }; +const CHASE = { easy: 0.25, medium: 0.4, hard: 0.55 }; // chance a ghost turns towards you +const GOOD = { easy: 8, medium: 10, hard: 10 }; // multiples per maze +const ROUNDS = 2; +const DIRS = { up: [-1, 0], down: [1, 0], left: [0, -1], right: [0, 1] }; +const KEYS = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }; + +export function mount(stage, ctx) { + addStyle(); + document.activeElement?.blur?.(); + const { canvas, ctx: g, point } = ctx.canvas(stage, W, H); + const open = (r, c) => MAP[r] && MAP[r][c] === '.'; + + let target = 0; + let dots = []; // { r, c, n, good } + let round = 0; + let score = 0; + let hearts = 3; + let right = 0; + let wrong = 0; + let over = false; + let safe = 0; // seconds the player can't be caught (after a hit) + let sleep = 0; // seconds before the ghosts move + const player = { r: START.r, c: START.c, d: null, t: 0, want: null, face: DIRS.right }; + let ghosts = []; + + // a new number, and a maze of its multiples mixed with near misses + function newRound() { + round++; + const prev = target; + const tables = ctx.L.tables.filter((t) => t !== prev); + target = ctx.pick(tables.length ? tables : ctx.L.tables); + const ks = ctx.shuffle(Array.from({ length: ctx.L.upTo }, (_, i) => i + 1)).slice(0, GOOD[ctx.level]); + const good = ks.map((k) => target * k); + const bad = new Set(); + for (const m of ctx.shuffle(good)) for (const d of [-1, 1, 2, -2]) if (m + d > 0 && (m + d) % target) bad.add(m + d); + while (bad.size < good.length) { + const v = ctx.randInt(1, target * ctx.L.upTo); + if (v % target) bad.add(v); + } + const cells = []; + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (open(r, c) && !(r === START.r && c === START.c) && !GHOSTS.some((s) => s.r === r && s.c === c)) cells.push({ r, c }); + } + } + // wrong dots go only where they never wall off part of the maze, so every + // multiple can be reached without eating a wrong one + const blocked = new Set(); + const wrongDots = []; + const spots = ctx.shuffle(cells); + const tryBlock = (p) => { + const k = p.r * COLS + p.c; + blocked.add(k); + if (connected(blocked)) return true; + blocked.delete(k); + return false; + }; + for (const n of ctx.shuffle([...bad]).slice(0, good.length)) { + const i = spots.findIndex(tryBlock); + if (i < 0) break; + wrongDots.push({ ...spots.splice(i, 1)[0], n, good: false }); + } + dots = [...good.map((n, i) => ({ ...spots[i], n, good: true })), ...wrongDots]; + ctx.hud.info(`Maze ${round}/${ROUNDS}`); + reset(); + } + function reset() { + Object.assign(player, { r: START.r, c: START.c, d: null, t: 0, want: null }); + ghosts = GHOSTS.slice(0, GHOST_COUNT[ctx.level]).map((s, i) => ({ r: s.r, c: s.c, d: null, t: 0, color: i ? '--good' : '--bad' })); + sleep = 1.5; + } + const left = () => dots.filter((d) => d.good).length; + // can every open cell not in `blocked` be reached from the start? + function connected(blocked) { + const seen = new Set([START.r * COLS + START.c]); + const todo = [[START.r, START.c]]; + while (todo.length) { + const [r, c] = todo.pop(); + for (const [dr, dc] of Object.values(DIRS)) { + const k = (r + dr) * COLS + c + dc; + if (open(r + dr, c + dc) && !blocked.has(k) && !seen.has(k)) seen.add(k) && todo.push([r + dr, c + dc]); + } + } + let total = 0; + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (open(r, c)) total++; + return seen.size === total - blocked.size; + } + + function eat() { + const i = dots.findIndex((d) => d.r === player.r && d.c === player.c); + if (i < 0) return; + const dot = dots[i]; + dots.splice(i, 1); + if (dot.good) { + right++; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${target} × ${dot.n / target} = ${dot.n} ✓`, 'good'); + if (!left()) { + if (round >= ROUNDS) return end(true, 'Maze munched!'); + score += 25; + ctx.hud.score(score); + ctx.toast(`🟡 All the ${target}s eaten!`, '+25 bonus points'); + newRound(); + } + } else { + wrong++; + // the two multiples it sits between, so the mistake teaches something + const k = Math.floor(dot.n / target); + const near = k > 0 ? `${target} × ${k} = ${target * k}, ${target} × ${k + 1} = ${target * (k + 1)}` : `${target} × 1 = ${target}`; + ctx.hud.msg(`${dot.n} is not a multiple of ${target} (${near})`, 'bad'); + hurt(); + } + } + function hurt() { + hearts--; + ctx.hud.hearts(hearts); + safe = 1.5; + if (hearts <= 0) end(false, 'Out of hearts!'); + } + function end(won, title) { + over = true; + draw(); + ctx.finish({ title, won, lines: [`Score: ${score}`, `Multiples eaten: ${right}`, `Wrong dots: ${wrong}`, `Last number: ${target}`] }); + } + + // steering: remember the wanted turn; reverse straight away + function steer(name) { + const d = DIRS[name]; + player.want = d; + if (player.d && player.t > 0 && d[0] === -player.d[0] && d[1] === -player.d[1]) { + player.r += player.d[0]; + player.c += player.d[1]; + player.d = d; + player.t = 1 - player.t; + } + } + const choosePlayer = () => { + const ok = (d) => d && open(player.r + d[0], player.c + d[1]); + if (ok(player.want)) return player.want; + return ok(player.d) ? player.d : null; + }; + function chooseGhost(gh) { + let opts = Object.values(DIRS).filter((d) => open(gh.r + d[0], gh.c + d[1])); + const back = gh.d && opts.filter((d) => !(d[0] === -gh.d[0] && d[1] === -gh.d[1])); + if (back && back.length) opts = back; + if (ctx.rng() < CHASE[ctx.level]) { + const dist = (d) => Math.abs(gh.r + d[0] - player.r) + Math.abs(gh.c + d[1] - player.c); + return opts.reduce((a, b) => (dist(b) < dist(a) ? b : a)); + } + return ctx.pick(opts); + } + // move along; at each cell, `arrive` runs and `choose` picks the next way + function advance(e, speed, dt, choose, arrive) { + if (!e.d) e.d = choose(e); + if (!e.d) return; + e.t += speed * dt; + while (e.t >= 1 && !over) { + e.r += e.d[0]; + e.c += e.d[1]; + e.t -= 1; + if (arrive) arrive(); + e.d = choose(e); + if (!e.d) e.t = 0; + } + } + const pos = (e) => ({ x: (e.c + (e.d ? e.d[1] * e.t : 0) + 0.5) * CELL, y: TOP + (e.r + (e.d ? e.d[0] * e.t : 0) + 0.5) * CELL }); + + ctx.listen(window, 'keydown', (e) => { + const name = KEYS[e.key] || KEYS[e.key.toLowerCase?.()]; + if (!name) return; + e.preventDefault(); + steer(name); + }); + // a tap in the maze steers towards it (the bigger of across / up-down) + ctx.listen(canvas, 'pointerdown', (e) => { + const p = point(e); + const me = pos(player); + const dx = p.x - me.x; + const dy = p.y - me.y; + steer(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up'); + }); + const pad = document.createElement('div'); + pad.className = 'pacman-pad'; + for (const [name, label] of [['up', '▲'], ['left', '◀'], ['down', '▼'], ['right', '▶']]) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn pacman-btn pacman-${name}`; + b.textContent = label; + b.setAttribute('aria-label', name); + b.tabIndex = -1; + ctx.listen(b, 'pointerdown', (e) => { + e.preventDefault(); + steer(name); + }); + pad.append(b); + } + stage.append(pad); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + newRound(); + ctx.hud.msg(`Eat the multiples of ${target}!`); + + ctx.loop((dt) => { + if (over) return; + safe = Math.max(0, safe - dt); + advance(player, SPEED, dt, choosePlayer, eat); + if (player.d) player.face = player.d; + if (over) return; + if (sleep > 0) sleep -= dt; + else for (const gh of ghosts) advance(gh, GHOST_SPEED[ctx.level], dt, chooseGhost); + // caught: a heart, and everyone back to the start + const me = pos(player); + if (!safe && ghosts.some((gh) => Math.hypot(pos(gh).x - me.x, pos(gh).y - me.y) < CELL * 0.7)) { + ctx.hud.msg('A ghost got you!', 'bad'); + hurt(); + if (over) return; + reset(); + } + draw(); + }); + + function draw() { + g.fillStyle = ctx.color('--surface'); + g.fillRect(0, 0, W, H); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillStyle = ctx.color('--ink'); + g.font = '900 22px system-ui, sans-serif'; + g.fillText(`Eat the multiples of ${target} (${left()} left)`, W / 2, TOP / 2 + 2); + for (let r = 0; r < ROWS; r++) { + for (let c = 0; c < COLS; c++) { + if (open(r, c)) continue; + g.fillStyle = ctx.color('--line'); + g.strokeStyle = ctx.color('--edge'); + g.lineWidth = 1; + g.fillRect(c * CELL + 2, TOP + r * CELL + 2, CELL - 4, CELL - 4); + g.strokeRect(c * CELL + 2.5, TOP + r * CELL + 2.5, CELL - 5, CELL - 5); + } + } + for (const d of dots) { + g.font = `800 ${d.n >= 100 ? 12 : 15}px system-ui, sans-serif`; // 3 digits must fit the dot + const x = (d.c + 0.5) * CELL; + const y = TOP + (d.r + 0.5) * CELL; + g.fillStyle = ctx.color('--gold-bg'); + g.strokeStyle = ctx.color('--edge'); + g.lineWidth = 1.5; + g.beginPath(); + g.arc(x, y, 16, 0, Math.PI * 2); + g.fill(); + g.stroke(); + g.fillStyle = ctx.color('--ink'); + g.fillText(String(d.n), x, y + 1); + } + for (const gh of ghosts) { + const { x, y } = pos(gh); + g.fillStyle = ctx.color(gh.color); + g.globalAlpha = sleep > 0 ? 0.5 : 1; + g.beginPath(); + g.arc(x, y - 3, 14, Math.PI, 0); + g.lineTo(x + 14, y + 14); + for (let i = 1; i <= 4; i++) g.lineTo(x + 14 - i * 7, y + (i % 2 ? 9 : 14)); + g.closePath(); + g.fill(); + g.fillStyle = '#fff'; + for (const ex of [-5, 5]) g.fillRect(x + ex - 2, y - 7, 5, 6); + g.globalAlpha = 1; + } + // the muncher, blinking while it can't be caught + if (safe && Math.floor(safe * 8) % 2) return; + const { x, y } = pos(player); + const a = Math.atan2(player.face[0], player.face[1]); + const mouth = 0.25 + 0.2 * Math.abs(Math.sin(performance.now() / 90)); + g.fillStyle = ctx.color('--star') || '#f5c518'; + g.beginPath(); + g.moveTo(x, y); + g.arc(x, y, 15, a + mouth, a + Math.PI * 2 - mouth); + g.closePath(); + g.fill(); + g.strokeStyle = ctx.color('--edge'); + g.lineWidth = 1.5; + g.stroke(); + } + + return { + state: () => ({ + target, + map: MAP, + player: { r: player.r, c: player.c, d: player.d, t: player.t, want: player.want, next: player.d ? { r: player.r + player.d[0], c: player.c + player.d[1] } : { r: player.r, c: player.c } }, + ghosts: ghosts.map((gh) => ({ r: gh.r, c: gh.c, d: gh.d })), + dots: dots.map((d) => ({ ...d })), + left: left(), + round, + rounds: ROUNDS, + score, + hearts, + safe, + }), + }; +} + +function addStyle() { + if (document.getElementById('pacman-style')) return; + const s = document.createElement('style'); + s.id = 'pacman-style'; + s.textContent = ` + .pacman-pad { display: grid; grid-template-columns: repeat(3, 64px); grid-template-areas: ". up ." "left down right"; gap: 8px; } + .pacman-btn { font-size: 1.4rem; padding: 10px 0; touch-action: none; user-select: none; } + /* the name is long for a phone header: shrink it while this demo is open */ + .pacman-up { grid-area: up; } .pacman-left { grid-area: left; } .pacman-down { grid-area: down; } .pacman-right { grid-area: right; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/platformer.js b/apps/web/src/demos/games/platformer.js new file mode 100644 index 0000000..fb1363f --- /dev/null +++ b/apps/web/src/demos/games/platformer.js @@ -0,0 +1,223 @@ +// Jump the Answer (like a platformer): each room asks a question and has a few +// floating platforms with numbers. Run and jump onto the answer. A wrong +// platform crumbles under you (a heart); the right one opens the next room. +// Platforms are one-way (you jump up through them), so a jump from right +// underneath always lands on top. + +const W = 640; +const H = 340; +const GROUND = 292; // top of the grass +const GRAVITY = 1500; +const JUMP = 650; // take-off speed: about 140 px high +const RUN = 220; +const PLATFORMS = { easy: 3, medium: 3, hard: 4 }; +const ROOMS = 8; +const PW = 28; // player size +const PH = 36; + +export function mount(stage, ctx) { + addStyle(); + document.activeElement?.blur?.(); + const { ctx: g } = ctx.canvas(stage, W, H); + const held = new Set(); // on-screen ◀ ▶ held down + + let q = null; + let plats = []; // { n, x (centre), top, w, state: 'ok' | 'crumble' | 'gone' | 'good', t } + let room = 0; + let score = 0; + let hearts = 3; + let right = 0; + let wrong = 0; + let phase = 'play'; // 'play' | 'done' (landed on the answer) | 'over' + const p = { x: 50, y: GROUND, vx: 0, vy: 0, onGround: true, on: null, face: 1 }; + + function newRoom() { + room++; + q = ctx.fact(); + const n = PLATFORMS[ctx.level]; + const nums = ctx.shuffle([q.answer, ...ctx.nearMisses(q.answer, n - 1, q)]); + const w = n > 3 ? 92 : 104; + const gap = (W - 170) / n; + let lastTop = 0; + plats = nums.map((num, i) => { + // heights vary, but neighbours differ so it looks like steps + let top = GROUND - ctx.randInt(62, 116); // a jump reaches about 140 px up + if (Math.abs(top - lastTop) < 20) top = top > GROUND - 89 ? top - 30 : top + 30; + lastTop = top; + return { n: num, x: 150 + gap * (i + 0.5), top, w, state: 'ok', t: 0 }; + }); + Object.assign(p, { x: 50, y: GROUND, vx: 0, vy: 0, onGround: true, on: null }); + phase = 'play'; + ctx.hud.info(`Room ${room}/${ROOMS}`); + ctx.hud.msg(`Land on ${q.text}`); + } + + function jump() { + if (phase !== 'play' || !p.onGround) return; + p.vy = -JUMP; + p.onGround = false; + p.on = null; + } + + function land(pl) { + if (pl.n === q.answer) { + right++; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${q.text} = ${q.answer} ✓`, 'good'); + pl.state = 'good'; + phase = 'done'; + ctx.after(900, () => { + if (room >= ROOMS) end(true, 'You jumped every answer!'); + else newRoom(); + }); + } else { + wrong++; + hearts--; + ctx.hud.hearts(hearts); + ctx.hud.msg(`${q.text} = ${q.answer}, not ${pl.n}`, 'bad'); + pl.state = 'crumble'; // holds you for a moment, then drops you + pl.t = 0.4; + if (hearts <= 0) ctx.after(500, () => end(false, 'Out of hearts!')); + } + } + function end(won, title) { + phase = 'over'; + draw(); + ctx.finish({ title, won, lines: [`Score: ${score}`, `Right landings: ${right}`, `Crumbles: ${wrong}`, won ? `All ${ROOMS} rooms!` : `${q.text} = ${q.answer}`] }); + } + + ctx.listen(window, 'keydown', (e) => { + const k = e.key; + if (k === 'ArrowUp' || k === ' ' || k === 'w' || k === 'W') { + e.preventDefault(); + if (!e.repeat) jump(); + } else if (k === 'ArrowLeft' || k === 'ArrowRight' || k === 'ArrowDown') e.preventDefault(); + }); + const pad = document.createElement('div'); + pad.className = 'platformer-pad'; + for (const [label, name, holdKey] of [['◀', 'Run left', 'left'], ['Jump', 'Jump', null], ['▶', 'Run right', 'right']]) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn platformer-btn${holdKey ? '' : ' btn-primary'}`; + b.textContent = label; + b.setAttribute('aria-label', name); + b.tabIndex = -1; + ctx.listen(b, 'pointerdown', (e) => { + e.preventDefault(); + if (holdKey) held.add(holdKey); + else jump(); + }); + for (const t of ['pointerup', 'pointerleave', 'pointercancel']) ctx.listen(b, t, () => holdKey && held.delete(holdKey)); + pad.append(b); + } + stage.append(pad); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + newRoom(); + + ctx.loop((dt) => { + if (phase === 'over') return; + const L = ctx.keys.has('ArrowLeft') || ctx.keys.has('a') || held.has('left'); + const R = ctx.keys.has('ArrowRight') || ctx.keys.has('d') || held.has('right'); + p.vx = phase === 'play' ? (R - L) * RUN : 0; + if (p.vx) p.face = Math.sign(p.vx); + p.x = Math.max(PW / 2, Math.min(W - PW / 2, p.x + p.vx * dt)); + // crumbling platforms give way + for (const pl of plats) { + if (pl.state !== 'crumble') continue; + pl.t -= dt; + if (pl.t <= 0) { + pl.state = 'gone'; + if (p.on === pl) Object.assign(p, { on: null, onGround: false }); + } + } + // walked off the edge of a platform + if (p.on && Math.abs(p.x - p.on.x) > p.on.w / 2 + PW / 2) Object.assign(p, { on: null, onGround: false }); + if (!p.onGround) { + const before = p.y; + p.vy += GRAVITY * dt; + p.y += p.vy * dt; + // one-way platforms: only land when coming down through the top + if (p.vy > 0) { + for (const pl of plats) { + if (pl.state === 'gone' || before > pl.top || p.y < pl.top || Math.abs(p.x - pl.x) > pl.w / 2 + PW / 2 - 4) continue; + Object.assign(p, { y: pl.top, vy: 0, onGround: true, on: pl }); + if (pl.state === 'ok') land(pl); + break; + } + } + if (p.y >= GROUND) Object.assign(p, { y: GROUND, vy: 0, onGround: true, on: null }); + } + draw(); + }); + + function draw() { + g.fillStyle = ctx.color('--surface'); + g.fillRect(0, 0, W, H); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + // the question + g.fillStyle = ctx.color('--ink'); + g.font = '900 34px system-ui, sans-serif'; + g.fillText(q ? `${q.text} = ?` : '', W / 2, 40); + // ground + g.fillStyle = ctx.color('--good'); + g.fillRect(0, GROUND, W, 10); + g.fillStyle = ctx.color('--soft-2'); + g.fillRect(0, GROUND + 10, W, H - GROUND - 10); + // platforms + g.font = '900 24px system-ui, sans-serif'; + for (const pl of plats) { + if (pl.state === 'gone') continue; + const shake = pl.state === 'crumble' ? (ctx.rng() - 0.5) * 6 : 0; + const x = pl.x - pl.w / 2 + shake; + g.globalAlpha = pl.state === 'crumble' ? Math.max(0.3, pl.t / 0.4) : 1; + g.fillStyle = pl.state === 'good' ? ctx.color('--good') : pl.state === 'crumble' ? ctx.color('--bad') : ctx.color('--gold-bg'); + g.strokeStyle = ctx.color('--edge'); + g.lineWidth = 2; + g.beginPath(); + g.roundRect(x, pl.top, pl.w, 30, 8); + g.fill(); + g.stroke(); + g.fillStyle = pl.state === 'ok' ? ctx.color('--ink') : '#fff'; + g.fillText(String(pl.n), x + pl.w / 2, pl.top + 16); + g.globalAlpha = 1; + } + // the runner + const x = p.x - PW / 2; + const y = p.y - PH; + g.fillStyle = ctx.color('--accent'); + g.beginPath(); + g.roundRect(x, y, PW, PH, 8); + g.fill(); + g.fillStyle = '#fff'; + g.fillRect(p.x + p.face * 5 - 3, y + 9, 6, 7); + } + + return { + state: () => ({ + question: q && q.text, + answer: q && q.answer, + phase, + player: { x: p.x, y: p.y, vx: p.vx, vy: p.vy, onGround: p.onGround, on: p.on ? p.on.n : null }, + platforms: plats.map((pl) => ({ n: pl.n, x: pl.x, top: pl.top, w: pl.w, state: pl.state })), + room, + rooms: ROOMS, + score, + hearts, + }), + }; +} + +function addStyle() { + if (document.getElementById('platformer-style')) return; + const s = document.createElement('style'); + s.id = 'platformer-style'; + s.textContent = ` + .platformer-pad { display: grid; grid-template-columns: 80px 120px 80px; gap: 10px; } + .platformer-btn { font-size: 1.4rem; font-weight: 900; padding: 12px 0; touch-action: none; user-select: none; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/racing.js b/apps/web/src/demos/games/racing.js new file mode 100644 index 0000000..1a802b8 --- /dev/null +++ b/apps/web/src/demos/games/racing.js @@ -0,0 +1,218 @@ +// Fact Racer (like a racing game): your car and three CPU cars race two laps +// of an oval. Your car creeps along by itself; every right answer is a boost, +// and a wrong answer stalls it for a moment. Beat the CPU cars to the flag. + +const W = 640; +const H = 340; +const LAPS = 2; +const CX = 320; +const CY = 170; +const HALF = 150; // half the straight's length +const R = 100; // centre-line turn radius +const LANES = [-39, -13, 13, 39]; // lane offsets from the centre line +const BASE = LAPS / 160; // laps per second with no answers at all +const BOOST = 0.1; // laps per right answer, spread over a second +// seconds each CPU car takes for the race +const CPU_TIMES = { easy: [62, 78, 95], medium: [58, 72, 88], hard: [58, 72, 88] }; +const CPU_LOOK = [ + { name: 'Blue', color: '#3b82f6' }, + { name: 'Purple', color: '#8b5cf6' }, + { name: 'Yellow', color: '#eab308' }, +]; + +// position and heading on the oval, s in laps (0 = the flag, mid top straight) +function spot(s, off) { + const P = 4 * HALF + 2 * Math.PI * R; + let d = ((s % 1) + 1) % 1 * P + HALF; // measured from the top straight's left end + d %= P; + const r = R + off; + const arc = Math.PI * R; + if (d < 2 * HALF) return { x: CX - HALF + d, y: CY - r, a: 0 }; + d -= 2 * HALF; + if (d < arc) { + const t = -Math.PI / 2 + d / R; + return { x: CX + HALF + Math.cos(t) * r, y: CY + Math.sin(t) * r, a: t + Math.PI / 2 }; + } + d -= arc; + if (d < 2 * HALF) return { x: CX + HALF - d, y: CY + r, a: Math.PI }; + d -= 2 * HALF; + const t = Math.PI / 2 + d / R; + return { x: CX - HALF + Math.cos(t) * r, y: CY + Math.sin(t) * r, a: t + Math.PI / 2 }; +} +const place = (n) => ['1st', '2nd', '3rd', '4th'][n - 1]; + +export function mount(stage, ctx) { + const { ctx: g } = ctx.canvas(stage, W, H); + const pad = ctx.keypad(stage, { onSubmit: answer }); + const me = { name: 'You', color: '', lane: 3, s: 0, boost: 0, stall: 0, done: 0 }; + const cars = [me, ...CPU_LOOK.map((c, i) => ({ ...c, lane: 2 - i, s: 0, speed: LAPS / CPU_TIMES[ctx.level][i], phase: ctx.rng() * 6, done: 0 }))]; + let phase = 'count'; // 'count' → 'race' → 'done' + let countdown = 3; + let time = 0; + let question = null; + let right = 0; + let wrong = 0; + let score = 0; + let finishers = 0; + + function ask() { + question = ctx.fact(); + pad.prompt(`${question.text} =`); + } + + function answer(v) { + if (phase !== 'race') return; + if (v === question.answer) { + right++; + score += 10; + me.boost = Math.min(3, me.boost + 1); + ctx.hud.msg(`${question.text} = ${question.answer} ✓ Boost!`, 'good'); + } else { + wrong++; + me.stall = 1.2; + ctx.hud.msg(`${question.text} = ${question.answer}, not ${v}`, 'bad'); + } + ctx.hud.score(score); + ask(); + } + + const myPlace = () => 1 + cars.filter((c) => c !== me && (c.done ? !me.done || c.done < me.done : c.s > me.s)).length; + + function end() { + phase = 'done'; + pad.enable(false); + const p = me.done ? myPlace() : 4; + ctx.after(900, () => + ctx.finish({ + title: p === 1 ? 'You win the race! 🏁' : `You came ${place(p)}`, + won: p === 1, + lines: [me.done ? `Race time: ${me.done.toFixed(1)} s` : 'All the CPU cars finished first.', `Boosts (right answers): ${right}`, `Wrong answers: ${wrong}`, `Score: ${score}`], + }) + ); + } + + ctx.loop((dt) => { + if (phase === 'count') { + countdown -= dt; + if (countdown <= 0) { + phase = 'race'; + pad.enable(true); + ctx.hud.msg('Go! Answer to boost', 'good'); + } + } else if (phase === 'race') { + time += dt; + for (const c of cars) { + if (c.done) continue; + if (c === me) { + const v = me.stall > 0 ? BASE * 0.3 : BASE + (me.boost > 0 ? BOOST : 0); + me.s += v * dt; + me.stall = Math.max(0, me.stall - dt); + me.boost = Math.max(0, me.boost - dt); + } else c.s += c.speed * (1 + 0.15 * Math.sin(time / 3 + c.phase)) * dt; // a little surging + if (c.s >= LAPS) { + c.s = LAPS; + c.done = time; + finishers++; + if (c !== me) ctx.hud.msg(`${c.name} finished ${place(finishers)}`); + } + } + if (me.done || finishers === cars.length - 1) end(); + ctx.hud.info(`Lap ${Math.min(LAPS, Math.floor(me.s) + 1)} of ${LAPS} · ${place(myPlace())}`); + } + draw(); + }); + + function draw() { + const c = (n, fb) => ctx.color(n) || fb; + g.fillStyle = c('--good-bg', '#e8f6ec'); + g.fillRect(0, 0, W, H); + // the track: a thick oval, lane lines, the flag line + const oval = (r) => { + g.beginPath(); + g.arc(CX + HALF, CY, r, -Math.PI / 2, Math.PI / 2); + g.arc(CX - HALF, CY, r, Math.PI / 2, Math.PI * 1.5); + g.closePath(); + }; + g.strokeStyle = c('--soft-2', '#e6dfd0'); + g.lineWidth = 108; + oval(R); + g.stroke(); + g.strokeStyle = c('--edge', '#333'); + g.lineWidth = 2; + oval(R - 54); + g.stroke(); + oval(R + 54); + g.stroke(); + g.setLineDash([8, 10]); + g.strokeStyle = c('--muted', '#999'); + for (const off of [-26, 0, 26]) { + oval(R + off); + g.stroke(); + } + g.setLineDash([]); + for (let i = 0; i < 9; i++) { + for (let j = 0; j < 2; j++) { + g.fillStyle = (i + j) % 2 ? '#fff' : '#222'; + g.fillRect(CX - 6 + j * 6, CY - R - 54 + i * 12, 6, 12); + } + } + // the cars, each in its lane + for (const car of cars) { + const p = spot(car.s, LANES[car.lane]); + g.save(); + g.translate(p.x, p.y); + g.rotate(p.a); + if (car === me && me.boost > 0) { + g.fillStyle = '#f97316'; + g.beginPath(); + g.moveTo(-18, -6); + g.lineTo(-32 - ctx.rng() * 8, 0); + g.lineTo(-18, 6); + g.fill(); + } + g.fillStyle = car === me ? c('--accent', '#e4572e') : car.color; + g.strokeStyle = c('--edge', '#333'); + g.beginPath(); + g.roundRect(-18, -10, 36, 20, 6); + g.fill(); + g.stroke(); + g.fillStyle = 'rgba(255,255,255,0.7)'; + g.fillRect(4, -7, 8, 14); + g.restore(); + if (car === me) { + g.fillStyle = c('--ink', '#222'); + g.font = '900 13px system-ui, sans-serif'; + g.textAlign = 'center'; + g.fillText('YOU', p.x, p.y - 16); + } + } + // in the infield: countdown or place + g.fillStyle = c('--ink', '#222'); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = '900 34px system-ui, sans-serif'; + const mid = phase === 'count' ? String(Math.ceil(countdown)) : phase === 'race' && time < 1 ? 'GO!' : place(myPlace()); + g.fillText(mid, CX, CY - 8); + g.font = '800 15px system-ui, sans-serif'; + g.fillText(`Lap ${Math.min(LAPS, Math.floor(me.s) + 1)} of ${LAPS}`, CX, CY + 22); + } + + pad.enable(false); + ask(); + ctx.hud.msg('Get ready…'); + ctx.hud.score(0); + + return { + state: () => ({ + phase, + question: { ...question }, + cars: cars.map((x) => ({ name: x.name, laps: x.s, done: x.done })), + place: myPlace(), + boost: me.boost, + stall: me.stall, + right, + wrong, + score, + }), + }; +} diff --git a/apps/web/src/demos/games/rhythm.js b/apps/web/src/demos/games/rhythm.js new file mode 100644 index 0000000..3ec7d61 --- /dev/null +++ b/apps/web/src/demos/games/rhythm.js @@ -0,0 +1,233 @@ +// Skip-Count Beats (like a rhythm game): a row of numbers falls on every beat. +// Tap the lane holding the next number in the skip-count (4, 8, 12, …) as the +// row reaches the line. Two tables per game; a miss shows the right fact and +// the count carries on, so the beat never stops. + +const W = 480; +const H = 400; +const HIT_Y = H - 64; // the line +const TOP = 74; // rows appear here (the count is shown above) +const FALL_BEATS = 3; // beats from appearing to reaching the line +const BEAT = { easy: 1.6, medium: 1.35, hard: 1.15 }; // seconds per beat +const LANES = { easy: 3, medium: 4, hard: 4 }; +const KEYS = { 1: 0, 2: 1, 3: 2, 4: 3, d: 0, f: 1, j: 2, k: 3 }; + +export function mount(stage, ctx) { + const beat = BEAT[ctx.level]; + const nLanes = LANES[ctx.level]; + const laneW = W / nLanes; + const speed = (HIT_Y - TOP) / (FALL_BEATS * beat); // px per second + const spacing = speed * beat; // px between rows + const { canvas, ctx: g, point } = ctx.canvas(stage, W, H); + const pads = document.createElement('div'); + pads.className = 'demo-choices'; + for (let i = 0; i < nLanes; i++) { + pads.insertAdjacentHTML('beforeend', ``); + pads.lastElementChild.style.minWidth = `${Math.floor(300 / nLanes)}px`; // (set from script: the page's CSP blocks style attributes) + } + stage.append(pads); + + // the whole song: two tables, each counted from ×1 up to the level's top + const tables = ctx.shuffle(ctx.L.tables.filter((n) => n > 1)).slice(0, 2); + const song = []; + for (const n of tables) for (let k = 1; k <= ctx.L.upTo; k++) song.push({ n, k, target: n * k }); + + const decoys = (n, target) => { + const out = []; + for (const v of ctx.shuffle([target + 1, target - 1, target + n, target - n, target + 2, target - 2, target + 10])) { + if (v > 0 && v !== target && !out.includes(v)) out.push(v); + } + return out; + }; + + let rows = []; // on screen: { i, n, k, target, values, lane, y, state } + let spawned = 0; + let clock = 0; // seconds to the next beat + let pulse = 0; + let hits = 0; + let misses = 0; + let streak = 0; + let best = 0; + let score = 0; + let done = false; + let banner = { text: `Count by ${tables[0]}s!`, t: 2.5 }; + const said = []; // numbers counted so far in this table (shown at the top) + + function spawn() { + const s = song[spawned]; + const lane = ctx.randInt(0, nLanes - 1); + const d = decoys(s.n, s.target); + const values = []; + for (let i = 0; i < nLanes; i++) values.push(i === lane ? s.target : d.shift()); + rows.push({ i: spawned, ...s, values, lane, y: TOP, state: 'falling' }); + spawned++; + hud(); + } + + // the next row to answer, if it's close enough to the line (up to a beat early) + const current = () => rows.find((r) => r.state === 'falling' && r.y >= HIT_Y - spacing); + + function counted(r) { + if (r.k === 1) said.length = 0; + said.push(r.target); + } + + function tap(lane) { + if (done) return; + const r = current(); + if (!r) return ctx.hud.msg('Wait for the row to reach the line'); + counted(r); + if (lane === r.lane) { + r.state = 'hit'; + hits++; + streak++; + best = Math.max(best, streak); + const perfect = Math.abs(r.y - HIT_Y) < spacing * 0.25; + score += (perfect ? 15 : 10) + Math.min(streak, 10); + ctx.hud.msg(`${perfect ? 'Perfect!' : 'Good!'} ${r.n} × ${r.k} = ${r.target}`, 'good'); + } else { + r.state = 'wrong'; + r.picked = lane; + misses++; + streak = 0; + ctx.hud.msg(`${r.n} × ${r.k} = ${r.target} comes next, not ${r.values[lane]}`, 'bad'); + } + hud(); + } + + function hud() { + ctx.hud.score(score); + ctx.hud.info(streak > 1 ? `Streak ${streak} 🔥` : `Count by ${(current() || rows.find((r) => r.state === 'falling') || song[Math.min(spawned, song.length - 1)]).n}s`); + } + + ctx.listen(canvas, 'pointerdown', (e) => tap(Math.min(nLanes - 1, Math.floor(point(e).x / laneW)))); + ctx.listen(pads, 'pointerdown', (e) => { + const b = e.target.closest('[data-lane]'); + if (!b) return; + e.preventDefault(); + tap(Number(b.dataset.lane)); + }); + ctx.listen(window, 'keydown', (e) => { + const lane = KEYS[e.key.toLowerCase()]; + if (lane === undefined || lane >= nLanes || e.repeat) return; + e.preventDefault(); + tap(lane); + }); + + ctx.loop((dt) => { + if (!done) { + clock -= dt; + if (clock <= 0) { + clock += beat; + pulse = 1; + // a short rest between the two tables, with a banner + const s = song[spawned]; + if (s && s.k === 1 && spawned > 0 && !s.rested) { + s.rested = true; + banner = { text: `Now count by ${s.n}s!`, t: beat * 2 }; + } else if (s) spawn(); + } + for (const r of rows) r.y += speed * dt; + for (const r of rows) { + if (r.state === 'falling' && r.y > HIT_Y + spacing * 0.5) { + r.state = 'missed'; + counted(r); + misses++; + streak = 0; + ctx.hud.msg(`Missed! ${r.n} × ${r.k} = ${r.target} came next`, 'bad'); + hud(); + } + } + rows = rows.filter((r) => r.y < H + 40); + if (spawned === song.length && rows.every((r) => r.state !== 'falling')) finish(); + } + pulse = Math.max(0, pulse - dt * 3); + if (banner) banner.t -= dt; + if (banner && banner.t <= 0) banner = null; + draw(); + }); + + function finish() { + done = true; + const won = hits >= song.length * 0.7; + ctx.after(600, () => + ctx.finish({ + title: won ? 'Great counting! 🥁' : 'Keep practising the beat', + won, + lines: [`Counted by ${tables.join(' and ')}`, `On the beat: ${hits} of ${song.length}`, `Best streak: ${best}`, `Score: ${score}`], + }) + ); + } + + function draw() { + const c = (n, fb) => ctx.color(n) || fb; + g.fillStyle = c('--surface', '#fff'); + g.fillRect(0, 0, W, H); + for (let i = 0; i < nLanes; i++) { + g.fillStyle = i % 2 ? c('--soft', '#f4efe4') : c('--surface', '#fff'); + g.fillRect(i * laneW, 52, laneW, H); + } + // the count so far, and the gap to fill + g.fillStyle = c('--ink', '#222'); + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.font = '900 22px system-ui, sans-serif'; + const r0 = current() || rows.find((r) => r.state === 'falling'); + const n = r0 ? r0.n : tables[0]; + g.fillText(banner ? banner.text : `${said.slice(-4).join(', ')}${said.length ? ', ' : ''}?`, W / 2, 16); + g.font = '800 13px system-ui, sans-serif'; + g.fillStyle = c('--muted', '#777'); + g.fillText(`counting by ${n}s`, W / 2, 38); + // the line, pulsing on the beat + g.fillStyle = c('--accent', '#e4572e'); + g.globalAlpha = 0.35 + pulse * 0.65; + g.fillRect(0, HIT_Y - 3 - pulse * 3, W, 6 + pulse * 6); + g.globalAlpha = 1; + // the falling rows + g.font = '900 22px system-ui, sans-serif'; + for (const r of rows) { + r.values.forEach((v, i) => { + const x = i * laneW + laneW / 2; + let fill = c('--paper', '#fff'); + if (r.state === 'hit' && i === r.lane) fill = c('--good', '#2e7d32'); + else if (r.state !== 'falling' && r.state !== 'hit' && i === r.lane) fill = c('--good-bg', '#dff3e4'); + else if (r.state === 'wrong' && i === r.picked) fill = c('--bad', '#c62828'); + g.globalAlpha = r.state === 'falling' || i === r.lane || i === r.picked ? 1 : 0.35; + g.fillStyle = fill; + g.strokeStyle = c('--edge', '#333'); + g.lineWidth = 2; + g.beginPath(); + g.roundRect(x - 36, r.y - 20, 72, 40, 12); + g.fill(); + g.stroke(); + g.fillStyle = (r.state === 'hit' && i === r.lane) || (r.state === 'wrong' && i === r.picked) ? '#fff' : c('--ink', '#222'); + g.fillText(String(v), x, r.y + 1); + }); + g.globalAlpha = 1; + } + g.font = '800 14px system-ui, sans-serif'; + g.fillStyle = c('--muted', '#777'); + for (let i = 0; i < nLanes; i++) g.fillText(`${i + 1}`, i * laneW + laneW / 2, H - 20); + } + + hud(); + ctx.hud.msg('Tap the lane with the next number'); + + return { + state: () => ({ + lanes: nLanes, + tables, + hitY: HIT_Y, + spacing, + rows: rows.map((r) => ({ i: r.i, n: r.n, k: r.k, target: r.target, values: [...r.values], lane: r.lane, y: r.y, state: r.state })), + current: current() ? current().i : null, + total: song.length, + spawned, + hits, + misses, + streak, + score, + done, + }), + }; +} diff --git a/apps/web/src/demos/games/tangram.js b/apps/web/src/demos/games/tangram.js new file mode 100644 index 0000000..520a523 --- /dev/null +++ b/apps/web/src/demos/games/tangram.js @@ -0,0 +1,196 @@ +// Area Puzzle (like tangrams / pentominoes): a shape on a grid is split into +// spaces, each showing its area. The tray holds rectangles named by their sides +// (3 × 4). Work out each rectangle's area and put it in the space it fills exactly: +// the right area AND the right shape (12 could be 3 × 4 or 2 × 6). + +const BOX = { easy: [10, 4], medium: [8, 6], hard: [10, 6] }; // shape's bounding box: columns × rows +const MAX_SIDE = { easy: 5, medium: 6, hard: 6 }; +const MAX_AREA = { easy: 20, medium: 24, hard: 30 }; +const ROUNDS = 2; +const COLORS = ['#e4572e', '#2a9d5c', '#3a7bd5', '#e0a800', '#9b59b6', '#16a0a0', '#d6336c', '#c77d3a']; + +export function mount(stage, ctx) { + const [W, H] = BOX[ctx.level]; + let round = 0; + let spaces = []; + let tray = []; + let selected = null; // tray index + let score = 0; + let mistakes = 0; + let done = false; + + addStyle(); + const board = document.createElement('div'); + board.className = 'tg-board'; + board.style.setProperty('--w', W); + board.style.setProperty('--h', H); + const trayEl = document.createElement('div'); + trayEl.className = 'tg-tray'; + stage.append(board, trayEl); + + // cut a rectangle in two until every piece is small enough (and sometimes a bit more) + function cut(rect, out) { + const { w, h } = rect; + const big = w > MAX_SIDE[ctx.level] || h > MAX_SIDE[ctx.level] || w * h > MAX_AREA[ctx.level]; + const canW = w >= 4; // splitting leaves both halves at least 2 wide + const canH = h >= 4; + if ((!big && (w * h <= 6 || ctx.rng() < 0.5)) || (!canW && !canH)) return out.push(rect); + const alongW = canW && (!canH || w > h || (w === h && ctx.rng() < 0.5)); + if (alongW) { + const k = ctx.randInt(2, w - 2); + cut({ ...rect, w: k }, out); + cut({ ...rect, c: rect.c + k, w: w - k }, out); + } else { + const k = ctx.randInt(2, h - 2); + cut({ ...rect, h: k }, out); + cut({ ...rect, r: rect.r + k, h: h - k }, out); + } + return out; + } + + function newRound() { + // best of many cuts: 4–8 pieces, lots of different sizes, sides from the level's + // tables, and ideally two pieces with the same area but different shapes + // (so the area alone isn't enough) + const rate = (rs) => { + const inTables = rs.filter((q) => ctx.L.tables.includes(q.w) || ctx.L.tables.includes(q.h)).length; + const dims = new Set(rs.map((q) => `${Math.min(q.w, q.h)}x${Math.max(q.w, q.h)}`)); + const areas = new Set(rs.map((q) => q.w * q.h)); + const twins = dims.size > areas.size ? 3 : 0; + return dims.size * 3 - rs.length + twins + inTables - (rs.length < 4 || rs.length > 8 ? 20 : 0); + }; + let rects = null; + for (let tries = 0; tries < 80; tries++) { + const rs = cut({ r: 0, c: 0, w: W, h: H }, []); + if (!rects || rate(rs) > rate(rects)) rects = rs; + } + // drop a corner piece now and then so the shape isn't always a plain rectangle + if (rects.length >= 5 && ctx.rng() < 0.6) { + const corners = rects.filter((q) => (q.r === 0 || q.r + q.h === H) && (q.c === 0 || q.c + q.w === W)); + const gone = ctx.pick(corners); + rects = rects.filter((q) => q !== gone); + } + spaces = rects.map((q, i) => ({ ...q, area: q.w * q.h, filled: false, color: COLORS[i % COLORS.length] })); + // tray pieces: sides as a fact, in a random turn (3 × 4 or 4 × 3 fit the same space) + tray = ctx.shuffle(spaces.map((q) => (ctx.rng() < 0.5 ? { a: q.h, b: q.w } : { a: q.w, b: q.h }))).map((p) => ({ ...p, used: false })); + selected = null; + render(); + ctx.hud.info(`Shape ${round + 1} of ${ROUNDS}`); + ctx.hud.msg('Pick a rectangle, then tap its space'); + } + + function render() { + board.replaceChildren( + ...spaces.map((q, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `tg-space${q.filled ? ' filled' : ''}`; + b.dataset.i = i; + b.style.gridArea = `${q.r + 1} / ${q.c + 1} / span ${q.h} / span ${q.w}`; + if (q.filled) b.style.background = q.color; + b.innerHTML = `${q.filled ? q.label : q.area}`; + b.setAttribute('aria-label', q.filled ? `Filled, ${q.area}` : `Space of area ${q.area}`); + return b; + }) + ); + trayEl.replaceChildren( + ...tray.map((p, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = `btn tg-piece${i === selected ? ' sel' : ''}`; + b.dataset.i = i; + b.disabled = p.used; + b.textContent = `${p.a} × ${p.b}`; + return b; + }) + ); + } + + function place(i) { + const q = spaces[i]; + if (q.filled || done) return; + if (selected === null) return ctx.hud.msg('First pick a rectangle from the tray'); + const p = tray[selected]; + const area = p.a * p.b; + const fits = (p.a === q.w && p.b === q.h) || (p.a === q.h && p.b === q.w); + if (area !== q.area) { + mistakes++; + ctx.hud.msg(`${p.a} × ${p.b} = ${area}, not ${q.area}`, 'bad'); + return; + } + if (!fits) { + mistakes++; + ctx.hud.msg(`${p.a} × ${p.b} = ${area} ✓ but this space is ${q.h} × ${q.w}. Try another space!`, 'bad'); + return; + } + q.filled = true; + q.label = `${p.a} × ${p.b} = ${area}`; + p.used = true; + selected = null; + score += 10; + ctx.hud.score(score); + ctx.hud.msg(`${p.a} × ${p.b} = ${area} ✓`, 'good'); + render(); + if (spaces.every((s) => s.filled)) { + const total = spaces.reduce((s, x) => s + x.area, 0); + round++; + ctx.toast('🧩 Shape filled!', `${spaces.map((s) => s.area).join(' + ')} = ${total} squares`); + if (round < ROUNDS) ctx.after(1200, newRound); + else { + done = true; + ctx.after(1200, () => + ctx.finish({ title: 'Every shape filled!', won: true, lines: [`${ROUNDS} shapes, no gaps`, mistakes ? `${mistakes} piece${mistakes > 1 ? 's' : ''} didn't fit the first time` : 'Every piece fit first time!', `Score: ${score}`] }) + ); + } + } + } + + ctx.listen(trayEl, 'click', (e) => { + const b = e.target.closest('.tg-piece'); + if (!b || done) return; + selected = Number(b.dataset.i) === selected ? null : Number(b.dataset.i); + if (selected !== null) ctx.hud.msg(`${tray[selected].a} × ${tray[selected].b}: tap the space it fills`); + render(); + }); + ctx.listen(board, 'click', (e) => { + const b = e.target.closest('.tg-space'); + if (b) place(Number(b.dataset.i)); + }); + + newRound(); + + return { + state: () => ({ + round, + rounds: ROUNDS, + spaces: spaces.map(({ r, c, w, h, area, filled }) => ({ r, c, w, h, area, filled })), + tray: tray.map((p) => ({ ...p })), + selected, + score, + mistakes, + done, + }), + }; +} + +function addStyle() { + if (document.getElementById('tangram-style')) return; + const s = document.createElement('style'); + s.id = 'tangram-style'; + s.textContent = ` + .tg-board { --cell: min(52px, calc((100vw - 48px) / var(--w))); display: grid; grid-template-columns: repeat(var(--w), var(--cell)); grid-template-rows: repeat(var(--h), var(--cell)); gap: 0; } + .tg-space { margin: 2px; border: 2px dashed var(--edge); border-radius: 8px; cursor: pointer; font: inherit; font-weight: 900; font-size: clamp(1rem, 4vw, 1.4rem); color: var(--ink); padding: 0; + background-color: var(--soft-2); + background-image: linear-gradient(var(--board-grid) 1px, transparent 1px), linear-gradient(90deg, var(--board-grid) 1px, transparent 1px); + background-size: var(--cell) var(--cell); background-position: -4px -4px; } /* 4px = margin + border, so lines sit on the cells */ + .tg-space:hover:not(.filled) { border-color: var(--accent); } + .tg-space.filled { border: 2px solid var(--edge); color: #fff; cursor: default; background-image: none; font-size: clamp(0.7rem, 2.6vw, 1rem); } + .tg-space span { background: var(--surface); border-radius: 6px; padding: 0 6px; } + .tg-space.filled span { background: rgba(0, 0, 0, 0.25); } + .tg-tray { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; max-width: 560px; } + .tg-piece { font-size: 1.2rem; font-weight: 900; min-width: 76px; } + .tg-piece.sel { background: var(--accent); color: var(--accent-ink); } + .tg-piece:disabled { visibility: hidden; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/tetris.js b/apps/web/src/demos/games/tetris.js new file mode 100644 index 0000000..6ade260 --- /dev/null +++ b/apps/web/src/demos/games/tetris.js @@ -0,0 +1,293 @@ +// Times Tetris (like Tetris): rectangles fall; when one lands, say its area +// (rows × columns) to lock it in. Wrong: it crumbles away. Full rows clear. +// The falling piece is steered with keys or on-screen buttons; while it waits +// for its area the buttons swap for a number pad (so it fits a phone). + +const COLS = 12; +const ROWS = 18; +const CELL = 24; +const W = COLS * CELL; +const H = ROWS * CELL; +const STEP = { easy: 0.9, medium: 0.7, hard: 0.55 }; // seconds per row +const GOAL = { easy: 8, medium: 10, hard: 12 }; // pieces to lock in +const COLORS = ['--accent', '--good', '--gold-line', '--learning', '--star']; + +export function mount(stage, ctx) { + addStyle(); + document.activeElement?.blur?.(); // so Space doesn't press a focused button + const row = document.createElement('div'); + row.className = 'demo-row tetris-row'; + stage.append(row); + const { ctx: g } = ctx.canvas(row, W, H); + const side = document.createElement('div'); + side.className = 'tetris-side'; + row.append(side); + + // move pad (while falling) and number pad (while landed): only one shows + const moves = document.createElement('div'); + moves.className = 'tetris-moves'; + side.append(moves); + const held = new Set(); // on-screen buttons held down + const button = (label, title, onPress, holdKey) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'btn tetris-btn'; + b.textContent = label; + b.title = title; + b.setAttribute('aria-label', title); + b.tabIndex = -1; + ctx.listen(b, 'pointerdown', (e) => { + e.preventDefault(); + if (onPress) onPress(); + if (holdKey) held.add(holdKey); + }); + for (const t of ['pointerup', 'pointerleave', 'pointercancel']) ctx.listen(b, t, () => holdKey && held.delete(holdKey)); + moves.append(b); + }; + button('◀', 'Move left', () => move(-1)); + button('⟳', 'Turn', () => rotate()); + button('▶', 'Move right', () => move(1)); + button('▼', 'Drop faster', null, 'down'); + const pad = ctx.keypad(side, { onSubmit: answer }); + + const grid = Array.from({ length: ROWS }, () => Array(COLS).fill(null)); + let piece = null; // { x, y, w, h, color } + let phase = 'fall'; // 'fall' | 'answer' | 'crumble' | 'over' + let acc = 0; + let score = 0; + let hearts = 3; + let locked = 0; + let cleared = 0; + let right = 0; + let wrong = 0; + let bits = []; // crumbs of a wrong piece + let colorIx = 0; + + // a rectangle from the level's facts that fits the well: at most 3 rows, area ≤ 24, + // no thin 1 × n strips except long bars (they help clear rows) + function newPiece() { + let a = 2; + let b = 3; + for (let i = 0; i < 300; i++) { + const f = ctx.fact(); + const lo = Math.min(f.a, f.b); + const hi = Math.max(f.a, f.b); + if (lo <= 3 && hi <= COLS && f.answer <= 24 && (lo > 1 || hi >= 10)) { + [a, b] = [lo, hi]; + break; + } + } + const [h, w] = ctx.rng() < 0.3 && b <= 6 ? [b, a] : [a, b]; + return { x: Math.floor((COLS - w) / 2), y: 0, w, h, color: COLORS[colorIx++ % COLORS.length] }; + } + const fits = (p, x, y, w = p.w, h = p.h) => { + if (x < 0 || x + w > COLS || y + h > ROWS) return false; + for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) if (r >= 0 && grid[r][c]) return false; + return true; + }; + + function spawn() { + piece = newPiece(); + acc = 0; + if (!fits(piece, piece.x, piece.y)) return end(false, 'The stack reached the top!'); + phase = 'fall'; + showPad(false); + ctx.hud.info(`Blocks ${locked}/${GOAL[ctx.level]}`); + } + function showPad(on) { + moves.hidden = on; + pad.el.hidden = !on; + pad.enable(on); + pad.clear(); + if (on) pad.prompt(`${piece.h} × ${piece.w} =`); + } + + function move(dx) { + if (phase === 'fall' && fits(piece, piece.x + dx, piece.y)) piece.x += dx; + } + // turn: swap rows and columns, nudging it back inside the well if needed + function rotate() { + if (phase !== 'fall') return; + const w = piece.h; + const h = piece.w; + for (const x of [piece.x, piece.x - 1, piece.x - 2, COLS - w]) { + if (fits(piece, x, piece.y, w, h)) return Object.assign(piece, { x, w, h }); + } + } + function fall() { + if (fits(piece, piece.x, piece.y + 1)) piece.y++; + else land(); + } + function hardDrop() { + if (phase !== 'fall') return; + while (fits(piece, piece.x, piece.y + 1)) piece.y++; + land(); + } + function land() { + phase = 'answer'; + showPad(true); + ctx.hud.msg(`Landed! What is ${piece.h} × ${piece.w}?`); + } + + function answer(v) { + if (phase !== 'answer') return; + const area = piece.w * piece.h; + if (v === area) { + right++; + locked++; + for (let r = piece.y; r < piece.y + piece.h; r++) for (let c = piece.x; c < piece.x + piece.w; c++) grid[r][c] = piece.color; + score += area; + const rows = clearRows(); + if (rows) { + cleared += rows; + score += 50 * rows; + ctx.toast(`🧱 ${rows} row${rows > 1 ? 's' : ''} cleared!`, `+${50 * rows} points`); + } + ctx.hud.score(score); + ctx.hud.msg(`${piece.h} × ${piece.w} = ${area} ✓`, 'good'); + piece = null; + if (locked >= GOAL[ctx.level]) return end(true, 'You built it!'); + spawn(); + } else { + wrong++; + hearts--; + ctx.hud.hearts(hearts); + ctx.hud.msg(`${piece.h} × ${piece.w} = ${area}, not ${v}`, 'bad'); + crumble(); + } + } + // the wrong piece breaks into falling crumbs, then the next piece comes + function crumble() { + phase = 'crumble'; + showPad(false); + moves.hidden = true; + bits = []; + for (let r = 0; r < piece.h; r++) { + for (let c = 0; c < piece.w; c++) { + bits.push({ x: (piece.x + c) * CELL, y: (piece.y + r) * CELL, vx: (ctx.rng() - 0.5) * 120, vy: -ctx.rng() * 120, color: piece.color }); + } + } + piece = null; + ctx.after(900, () => { + bits = []; + if (hearts <= 0) end(false, 'Out of hearts!'); + else spawn(); + }); + } + function clearRows() { + let n = 0; + for (let r = ROWS - 1; r >= 0; r--) { + if (grid[r].every(Boolean)) { + grid.splice(r, 1); + grid.unshift(Array(COLS).fill(null)); + n++; + r++; // check the row that moved down into this spot + } + } + return n; + } + function end(won, title) { + phase = 'over'; + draw(); + ctx.finish({ title, won, lines: [`Score: ${score}`, `Blocks locked in: ${locked}`, `Rows cleared: ${cleared}`, `Areas right: ${right}, wrong: ${wrong}`] }); + } + + ctx.listen(window, 'keydown', (e) => { + const k = e.key; + if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', ' '].includes(k)) return; + e.preventDefault(); + if (k === 'ArrowLeft') move(-1); + else if (k === 'ArrowRight') move(1); + else if (k === 'ArrowUp') rotate(); + else if (k === ' ') hardDrop(); + }); + + ctx.hud.hearts(hearts); + ctx.hud.score(0); + ctx.hud.msg('Steer the block, then say its area'); + spawn(); + + ctx.loop((dt) => { + if (phase === 'fall') { + const fast = ctx.keys.has('ArrowDown') || held.has('down'); + acc += dt; + const step = fast ? 0.05 : STEP[ctx.level]; + if (acc >= step) { + acc = 0; + fall(); + } + } + for (const b of bits) { + b.vy += 900 * dt; + b.x += b.vx * dt; + b.y += b.vy * dt; + } + draw(); + }); + + function cell(x, y, color) { + g.fillStyle = ctx.color(color); + g.fillRect(x + 1, y + 1, CELL - 2, CELL - 2); + } + function draw() { + g.fillStyle = ctx.color('--surface'); + g.fillRect(0, 0, W, H); + // faint grid so the rows and columns can be counted + g.strokeStyle = ctx.color('--line') || ctx.color('--soft-2'); + g.lineWidth = 1; + for (let c = 1; c < COLS; c++) g.strokeRect(c * CELL, 0, 0, H); + for (let r = 1; r < ROWS; r++) g.strokeRect(0, r * CELL, W, 0); + for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) if (grid[r][c]) cell(c * CELL, r * CELL, grid[r][c]); + for (const b of bits) cell(b.x, b.y, b.color); + if (!piece) return; + const px = piece.x * CELL; + const py = piece.y * CELL; + for (let r = 0; r < piece.h; r++) for (let c = 0; c < piece.w; c++) cell(px + c * CELL, py + r * CELL, piece.color); + g.strokeStyle = ctx.color('--ink'); + g.lineWidth = phase === 'answer' ? 3 : 2; + g.strokeRect(px + 1, py + 1, piece.w * CELL - 2, piece.h * CELL - 2); + // the question on the piece itself when it has room, else just above it + g.font = '900 16px system-ui, sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + const label = phase === 'answer' ? `${piece.h} × ${piece.w} = ?` : `${piece.h} × ${piece.w}`; + const lw = g.measureText(label).width + 12; + const ly = piece.h * CELL >= 24 && piece.w * CELL >= lw ? py + (piece.h * CELL) / 2 : Math.max(12, py - 12); + g.fillStyle = ctx.color('--paper'); + g.fillRect(px + (piece.w * CELL) / 2 - lw / 2, ly - 11, lw, 22); + g.fillStyle = ctx.color('--ink'); + g.fillText(label, px + (piece.w * CELL) / 2, ly + 1); + } + + return { + state: () => ({ + phase, + piece: piece && { ...piece, area: piece.w * piece.h, question: `${piece.h} × ${piece.w}` }, + answer: piece ? piece.w * piece.h : null, + grid: grid.map((r) => r.map(Boolean)), + score, + hearts, + locked, + goal: GOAL[ctx.level], + cleared, + }), + }; +} + +function addStyle() { + if (document.getElementById('tetris-style')) return; + const s = document.createElement('style'); + s.id = 'tetris-style'; + s.textContent = ` + .tetris-row { align-items: center; } + .tetris-row .demo-canvas { flex: 0 1 ${W}px; min-width: 0; } + .tetris-side { display: flex; flex-direction: column; align-items: center; gap: 10px; min-width: 200px; } + .tetris-moves { display: grid; grid-template-columns: repeat(3, 64px); gap: 8px; } + .tetris-moves[hidden], .tetris-side .demo-keypad[hidden] { display: none; } + .tetris-btn { font-size: 1.5rem; padding: 10px 0; touch-action: none; user-select: none; } + .tetris-btn:last-child { grid-column: 1 / -1; } + /* phones: shrink the well so the number pad fits under it without scrolling */ + @media (max-width: 560px) { .tetris-row .demo-canvas { width: auto; height: clamp(280px, calc(100svh - 450px), ${H}px); } } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/towers.js b/apps/web/src/demos/games/towers.js new file mode 100644 index 0000000..eaa88e4 --- /dev/null +++ b/apps/web/src/demos/games/towers.js @@ -0,0 +1,242 @@ +// Fact Towers (like tower defense): monsters walk the path, each showing its +// hit points. Answer the fact to fire a tower; the shot does as much damage as +// the answer, so bigger answers hit harder. Don't let 10 monsters through. + +const W = 640; +const H = 310; +const PATH = [[-20, 60], [520, 60], [520, 160], [120, 160], [120, 260], [660, 260]]; +const TOWERS = [[250, 112], [400, 212], [590, 160]]; +const MAX_ESCAPED = 10; +const PACE = { + easy: { total: 12, every: 8, walk: 34, hp: [10, 45] }, + medium: { total: 14, every: 7, walk: 30, hp: [12, 40] }, + hard: { total: 16, every: 6, walk: 26, hp: [25, 80] }, +}; + +// the path as segments, so a monster's progress (0–1) maps to a point +const SEGS = PATH.slice(1).map(([x, y], i) => ({ x0: PATH[i][0], y0: PATH[i][1], x1: x, y1: y, len: Math.hypot(x - PATH[i][0], y - PATH[i][1]) })); +const LEN = SEGS.reduce((s, g) => s + g.len, 0); +function at(p) { + let d = p * LEN; + for (const s of SEGS) { + if (d <= s.len) return { x: s.x0 + ((s.x1 - s.x0) * d) / s.len, y: s.y0 + ((s.y1 - s.y0) * d) / s.len }; + d -= s.len; + } + return { x: PATH.at(-1)[0], y: PATH.at(-1)[1] }; +} + +export function mount(stage, ctx) { + const pace = PACE[ctx.level]; + const { ctx: g } = ctx.canvas(stage, W, H); + const pad = ctx.keypad(stage, { onSubmit: answer }); + + let monsters = []; + let shots = []; // flying: { x, y, target, dmg } + let pops = []; // floating damage numbers + let question = null; + let spawned = 0; + let killed = 0; + let escaped = 0; + let score = 0; + let right = 0; + let wrong = 0; + let nextId = 1; + let spawnIn = 2; // seconds to the next monster + let flash = null; // { tower, t } muzzle flash + let done = false; + + function ask() { + question = ctx.fact(); + pad.prompt(`${question.text} =`); + } + + function spawn() { + const [lo, hi] = pace.hp; + // later monsters are a little tougher + const hp = Math.round(ctx.randInt(lo, hi) * (1 + spawned * 0.04)); + monsters.push({ id: nextId++, hp, maxHp: hp, p: 0, wobble: ctx.rng() * 6 }); + spawned++; + } + + // the monster furthest along that isn't already going to die from shots in flight + function target() { + const alive = monsters.filter((m) => m.hp > 0).sort((a, b) => b.p - a.p); + const incoming = (m) => shots.filter((s) => s.target === m).reduce((t, s) => t + s.dmg, 0); + return alive.find((m) => m.hp - incoming(m) > 0) || alive[0] || null; + } + + function answer(v) { + if (done) return; + const q = question; + if (v !== q.answer) { + wrong++; + ctx.hud.msg(`${q.text} = ${q.answer}, not ${v}`, 'bad'); + return ask(); + } + right++; + const m = target(); + if (!m) { + ctx.hud.msg(`${q.text} = ${q.answer} ✓ (no monsters yet)`, 'good'); + score += 5; + ctx.hud.score(score); + return ask(); + } + // the tower closest to the monster fires + const pos = at(m.p); + const t = TOWERS.reduce((best, tw) => (Math.hypot(tw[0] - pos.x, tw[1] - pos.y) < Math.hypot(best[0] - pos.x, best[1] - pos.y) ? tw : best)); + shots.push({ x: t[0], y: t[1] - 18, target: m, dmg: q.answer }); + flash = { tower: t, t: 0.2 }; + ctx.hud.msg(`${q.text} = ${q.answer} ✓ Fire! ${q.answer} damage`, 'good'); + ask(); + } + + function hud() { + ctx.hud.info(`Through ${escaped}/${MAX_ESCAPED}`); + ctx.hud.score(score); + } + + function end(won) { + done = true; + pad.enable(false); + ctx.after(700, () => + ctx.finish({ + title: won ? 'The castle is safe!' : 'Too many got through', + won, + lines: [`Monsters stopped: ${killed} of ${pace.total}`, `Got through: ${escaped}`, `Facts right: ${right}, wrong: ${wrong}`, `Score: ${score}`], + }) + ); + } + + ctx.loop((dt) => { + if (!done) { + spawnIn -= dt; + if (spawnIn <= 0 && spawned < pace.total) { + spawn(); + spawnIn = pace.every; + } + for (const m of monsters) { + m.p += dt / pace.walk; + if (m.p >= 1 && m.hp > 0) { + m.hp = 0; + m.escaped = true; + escaped++; + ctx.hud.msg('A monster got through!', 'bad'); + } + } + // shots fly fast at their monster; damage lands on arrival + for (const s of shots) { + const to = at(s.target.p); + const d = Math.hypot(to.x - s.x, to.y - s.y); + const step = 700 * dt; + if (d <= step || s.target.hp <= 0) { + s.hit = true; + if (s.target.hp > 0) { + s.target.hp -= s.dmg; + pops.push({ x: to.x, y: to.y - 20, text: `-${s.dmg}`, t: 0.9 }); + if (s.target.hp <= 0) { + killed++; + score += 10 + Math.round(s.target.maxHp / 5); + } + } + } else { + s.x += ((to.x - s.x) / d) * step; + s.y += ((to.y - s.y) / d) * step; + } + } + shots = shots.filter((s) => !s.hit); + monsters = monsters.filter((m) => m.hp > 0); + hud(); + if (escaped >= MAX_ESCAPED) end(false); + else if (spawned === pace.total && !monsters.length && !shots.length) end(true); + } + for (const p of pops) p.t -= dt; + pops = pops.filter((p) => p.t > 0); + if (flash && (flash.t -= dt) <= 0) flash = null; + draw(); + }); + + function draw() { + const c = (n, fb) => ctx.color(n) || fb; + g.fillStyle = c('--good-bg', '#e8f6ec'); + g.fillRect(0, 0, W, H); + // the path + g.lineCap = 'round'; + g.lineJoin = 'round'; + g.strokeStyle = c('--soft-2', '#e6dfd0'); + g.lineWidth = 44; + g.beginPath(); + PATH.forEach(([x, y], i) => (i ? g.lineTo(x, y) : g.moveTo(x, y))); + g.stroke(); + // castle at the exit + g.font = '34px system-ui, sans-serif'; + g.textAlign = 'center'; + g.textBaseline = 'middle'; + g.fillText('🏰', W - 24, 214); + g.font = '800 16px system-ui, sans-serif'; + g.textAlign = 'left'; + g.fillStyle = c('--ink', '#222'); + g.fillText(`Monster ${Math.min(spawned, pace.total)} of ${pace.total}`, 12, H - 16); + g.textAlign = 'center'; + // towers + for (const [x, y] of TOWERS) { + g.fillStyle = flash && flash.tower[0] === x && flash.tower[1] === y ? c('--gold-bg', '#ffe9a8') : c('--paper', '#fff'); + g.strokeStyle = c('--edge', '#333'); + g.lineWidth = 2; + g.beginPath(); + g.roundRect(x - 18, y - 18, 36, 36, 6); + g.fill(); + g.stroke(); + g.fillStyle = c('--accent', '#e4572e'); + g.fillRect(x - 5, y - 30, 10, 14); + } + // monsters, with their hit points + for (const m of monsters) { + const { x, y } = at(m.p); + const bob = Math.sin(m.p * 120 + m.wobble) * 2; + g.fillStyle = c('--bad', '#c62828'); + g.beginPath(); + g.arc(x, y + bob, 21, 0, Math.PI * 2); + g.fill(); + g.fillStyle = '#fff'; + g.font = '900 19px system-ui, sans-serif'; + g.fillText(String(m.hp), x, y + bob + 1); + // health bar + g.fillStyle = c('--edge', '#333'); + g.fillRect(x - 18, y - 32, 36, 5); + g.fillStyle = c('--good', '#2e7d32'); + g.fillRect(x - 18, y - 32, (36 * m.hp) / m.maxHp, 5); + } + // shots + g.fillStyle = c('--accent', '#e4572e'); + for (const s of shots) { + g.beginPath(); + g.arc(s.x, s.y, 6, 0, Math.PI * 2); + g.fill(); + } + g.font = '900 22px system-ui, sans-serif'; + for (const p of pops) { + g.fillStyle = c('--ink', '#222'); + g.globalAlpha = Math.min(1, p.t * 2); + g.fillText(p.text, p.x, p.y - (0.9 - p.t) * 30); + g.globalAlpha = 1; + } + } + + ask(); + hud(); + ctx.hud.msg('Answer to fire: the answer is the damage'); + + return { + state: () => ({ + question: { ...question }, + monsters: monsters.map((m) => ({ id: m.id, hp: m.hp, maxHp: m.maxHp, progress: m.p })), + shots: shots.length, + spawned, + total: pace.total, + killed, + escaped, + score, + done, + }), + }; +} diff --git a/apps/web/src/demos/games/twenty48.js b/apps/web/src/demos/games/twenty48.js new file mode 100644 index 0000000..bd6c5c8 --- /dev/null +++ b/apps/web/src/demos/games/twenty48.js @@ -0,0 +1,216 @@ +// 2048 ×2 (like 2048): slide the tiles; two equal tiles merge into their +// double. When a move makes a big merge, the child says the double (pick it +// or type it) before the tile shows. Tiles start at 2, 3 or 7 by level, so +// it's doubling practice, not just powers of two. + +const N = 4; +const BASE = { easy: 2, medium: 3, hard: 7 }; +const DIRS = { ArrowLeft: 'left', ArrowRight: 'right', ArrowUp: 'up', ArrowDown: 'down', a: 'left', d: 'right', w: 'up', s: 'down' }; + +export function mount(stage, ctx) { + addStyle(); + const base = BASE[ctx.level]; + const goal = base * 32; // 5 doublings: long enough to practise, short enough for a demo + const ask = base * 4; // merges this big or bigger ask for the double + let grid = Array(N * N).fill(0); + let pending = null; // { at, value, answer, choices, gain } + let typed = ''; + let score = 0; + let right = 0; + let wrong = 0; + let over = false; + + const head = document.createElement('div'); + head.className = 'demo-big t48-head'; + head.textContent = `Make ${goal}!`; + const board = document.createElement('div'); + board.className = 't48-board'; + const cells = Array.from({ length: N * N }, () => board.appendChild(document.createElement('div'))); + const askEl = document.createElement('div'); + askEl.className = 't48-ask'; + stage.append(head, board, askEl); + + function spawn() { + const empty = grid.map((v, i) => (v ? -1 : i)).filter((i) => i >= 0); + if (empty.length) grid[ctx.pick(empty)] = ctx.rng() < 0.1 ? base * 2 : base; + } + // indexes of each line, read in the direction the tiles slide + function lines(dir) { + const out = []; + for (let a = 0; a < N; a++) { + const line = []; + for (let b = 0; b < N; b++) { + if (dir === 'left') line.push(a * N + b); + if (dir === 'right') line.push(a * N + (N - 1 - b)); + if (dir === 'up') line.push(b * N + a); + if (dir === 'down') line.push((N - 1 - b) * N + a); + } + out.push(line); + } + return out; + } + // slide + merge; returns the new grid and the merges ({ at, value: the new tile }) + function slide(dir) { + const next = Array(N * N).fill(0); + const merges = []; + for (const line of lines(dir)) { + const vals = line.map((i) => grid[i]).filter(Boolean); + const outVals = []; + for (let k = 0; k < vals.length; k++) { + if (vals[k] === vals[k + 1]) { + outVals.push(vals[k] * 2); + merges.push({ at: line[outVals.length - 1], value: vals[k] * 2 }); + k++; + } else outVals.push(vals[k]); + } + outVals.forEach((v, k) => (next[line[k]] = v)); + } + return { next, merges, moved: next.some((v, i) => v !== grid[i]) }; + } + const canMove = () => Object.values(DIRS).some((d) => slide(d).moved); + + function move(dir) { + if (over || pending) return; + const { next, merges, moved } = slide(dir); + if (!moved) return; + grid = next; + const gain = merges.reduce((s, m) => s + m.value, 0); + const big = merges.reduce((m, x) => (!m || x.value > m.value ? x : m), null); + if (big && big.value >= ask) { + const half = big.value / 2; + const answer = big.value; + const wrongs = ctx.shuffle([answer + 2, answer - 2, answer + 10, answer - 10, half * 3].filter((v) => v > 0 && v !== answer)).slice(0, 2); + pending = { at: big.at, half, answer, choices: ctx.shuffle([answer, ...wrongs]), gain }; + typed = ''; + ctx.hud.msg(`Two ${half}s merge: what’s double ${half}?`); + } else { + addScore(gain); + after(); + } + render(); + } + + function answer(v) { + if (!pending) return; + const p = pending; + pending = null; + if (v === p.answer) { + right++; + addScore(p.gain); + ctx.hud.msg(`${p.half} + ${p.half} = ${p.answer} ✓`, 'good'); + } else { + wrong++; + ctx.hud.msg(`Double ${p.half} is ${p.half} + ${p.half} = ${p.answer}, not ${v}`, 'bad'); + } + after(); + render(); + } + const addScore = (n) => ctx.hud.score((score += n)); + + // after each finished move: new tile, then check for the goal or a full board + function after() { + spawn(); + const best = Math.max(...grid); + if (best >= goal) return end(true); + if (!canMove()) end(false); + } + function end(won) { + over = true; + const best = Math.max(...grid); + ctx.after(700, () => + ctx.finish({ + title: won ? `You made ${goal}!` : 'No more moves', + won, + lines: [`Biggest tile: ${best}`, `Doubles right: ${right} of ${right + wrong}`, `Score: ${score}`], + }) + ); + } + + function render() { + grid.forEach((v, i) => { + const c = cells[i]; + const q = pending && pending.at === i; + c.className = `t48-cell${v ? ' tile' : ''}${q ? ' ask' : ''}`; + c.textContent = q ? '?' : v || ''; + // colour deepens with every doubling above the base + const k = v ? Math.min(5, Math.round(Math.log2(v / base))) : 0; + c.style.setProperty('--k', k); + c.dataset.k = k; + }); + if (pending) { + askEl.innerHTML = `
    ${pending.half} + ${pending.half} =${typed || '?'}
    `; + ctx.choices(askEl.appendChild(document.createElement('div')), pending.choices, (v) => answer(v)); + } else askEl.innerHTML = '

    Slide with ← → ↑ ↓ or swipe the board

    '; + // (the tile being asked about doesn't count yet: it would give the answer away) + const shown = grid.filter((v, i) => !(pending && pending.at === i)); + ctx.hud.info(`Best: ${Math.max(...shown)} · goal ${goal}`); + } + + ctx.listen(window, 'keydown', (e) => { + if (pending) { + if (/^\d$/.test(e.key) && typed.length < 4) typed += e.key; + else if (e.key === 'Backspace') typed = typed.slice(0, -1); + else if (e.key === 'Enter' && typed) return answer(Number(typed)); + else return; + e.preventDefault(); + return render(); + } + const dir = DIRS[e.key]; + if (dir) { + e.preventDefault(); + move(dir); + } + }); + // swipes (touch or mouse drag) on the board + let start = null; + ctx.listen(board, 'pointerdown', (e) => { + start = { x: e.clientX, y: e.clientY }; + }); + ctx.listen(window, 'pointerup', (e) => { + if (!start) return; + const dx = e.clientX - start.x; + const dy = e.clientY - start.y; + start = null; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 24) return; + move(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up'); + }); + + spawn(); + spawn(); + render(); + ctx.hud.score(0); + ctx.hud.msg('Arrow keys or swipe to slide'); + + return { + state: () => ({ + grid: [...grid], + base, + goal, + best: Math.max(...grid), + pending: pending && { half: pending.half, answer: pending.answer, choices: [...pending.choices] }, + score, + right, + wrong, + over, + }), + }; +} + +function addStyle() { + if (document.getElementById('t48-style')) return; + const s = document.createElement('style'); + s.id = 't48-style'; + s.textContent = ` + .t48-head { margin: 0; } + .t48-board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; width: min(100%, 360px); padding: 8px; border: 2px solid var(--edge); border-radius: 14px; background: var(--soft-2); touch-action: none; user-select: none; -webkit-user-select: none; } + .t48-cell { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 10px; background: var(--surface); font-weight: 900; font-size: clamp(1.2rem, 6vw, 1.9rem); font-variant-numeric: tabular-nums; } + .t48-cell.tile { border: 2px solid var(--edge); color: var(--ink); background: color-mix(in srgb, var(--accent) calc(var(--k) * 18%), var(--gold-bg)); } + .t48-cell.tile[data-k="3"], .t48-cell.tile[data-k="4"], .t48-cell.tile[data-k="5"] { color: #fff; } + .t48-cell.ask { background: var(--paper); color: var(--accent); outline: 3px solid var(--accent); animation: t48-pulse 0.8s ease-in-out infinite alternate; } + @keyframes t48-pulse { to { transform: scale(1.06); } } + .t48-ask { display: flex; flex-direction: column; align-items: center; gap: 8px; } + .t48-ask { min-height: 110px; } + .t48-hint { margin: 0; color: var(--muted); font-weight: 700; } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/war.js b/apps/web/src/demos/games/war.js new file mode 100644 index 0000000..8659d98 --- /dev/null +++ b/apps/web/src/demos/games/war.js @@ -0,0 +1,164 @@ +// Product War (like War / Top Trumps): you and the CPU each flip two cards. +// Say your product, then say whose product is bigger. The bigger product wins +// all four cards (a tie leaves them in the pot for the next round). +// Ten rounds; most cards wins. + +const ROUNDS = 10; + +export function mount(stage, ctx) { + addStyle(); + const table = document.createElement('div'); + table.className = 'war-table'; + table.innerHTML = ` +
    You
    +
    vs
    +
    CPU
    `; + const panel = document.createElement('div'); + panel.className = 'war-panel'; + const askBox = document.createElement('div'); + const callBox = document.createElement('div'); + callBox.className = 'war-call'; + panel.append(askBox, callBox); + stage.append(table, panel); + const $ = (sel) => table.querySelector(sel); + + let round = 0; + let phase = 'answer'; // 'answer' (type your product) → 'compare' (who wins?) → 'reveal' + let me = null; + let cpu = null; + let myCards = 0; + let cpuCards = 0; + let pot = 0; + let score = 0; + let rightProducts = 0; + let rightCalls = 0; + + const pad = ctx.keypad(askBox, { onSubmit: answer }); + + // one hand: a fact from the level's tables, so both cards are in range + const deal = () => { + const f = ctx.fact(); + return { a: f.a, b: f.b, answer: f.answer }; + }; + const cardHtml = (n, hidden) => `${hidden ? '?' : n}`; + + function render() { + $('.war-cards[data-who="me"]').innerHTML = me ? cardHtml(me.a) + cardHtml(me.b) : ''; + // the CPU's cards stay face down until you've said your product + $('.war-cards[data-who="cpu"]').innerHTML = cpu ? cardHtml(cpu.a, phase === 'answer') + cardHtml(cpu.b, phase === 'answer') : ''; + $('.war-product[data-who="me"]').textContent = phase === 'answer' ? `${me.a} × ${me.b} = ?` : `${me.a} × ${me.b} = ${me.answer}`; + $('.war-product[data-who="cpu"]').textContent = phase === 'answer' ? '' : `${cpu.a} × ${cpu.b} = ${cpu.answer}`; + $('.war-count[data-who="me"]').textContent = `${myCards} cards`; + $('.war-count[data-who="cpu"]').textContent = `${cpuCards} cards`; + $('.war-pot').textContent = pot ? `pot: ${pot}` : ''; + ctx.hud.info(`Round ${Math.min(round, ROUNDS)} of ${ROUNDS}`); + ctx.hud.score(score); + } + + function nextRound() { + if (round >= ROUNDS) return end(); + round++; + me = deal(); + cpu = deal(); + phase = 'answer'; + pad.enable(true); + pad.el.hidden = false; + pad.prompt(`${me.a} × ${me.b} =`); + callBox.replaceChildren(); + render(); + } + + // step 1: your product + function answer(v) { + if (phase !== 'answer') return; + if (v === me.answer) { + score += 10; + rightProducts++; + ctx.hud.msg(`${me.a} × ${me.b} = ${me.answer} ✓`, 'good'); + } else ctx.hud.msg(`${me.a} × ${me.b} = ${me.answer}, not ${v}`, 'bad'); + phase = 'compare'; + pad.enable(false); + pad.el.hidden = true; + const box = ctx.choices(callBox, ['Mine', 'Same', 'CPU'], (v2) => call(v2)); + box.insertAdjacentHTML('afterbegin', '

    Whose product is bigger? (keys: ← mine, ↓ same, → CPU)

    '); + render(); + } + + // step 2: who wins the round? (the cards go to the real winner either way) + function call(pickWho) { + if (phase !== 'compare') return; + phase = 'reveal'; + const truth = me.answer > cpu.answer ? 'Mine' : me.answer < cpu.answer ? 'CPU' : 'Same'; + const sign = me.answer > cpu.answer ? '>' : me.answer < cpu.answer ? '<' : '='; + const why = `${me.answer} ${sign} ${cpu.answer}`; + if (pickWho === truth) { + score += 5; + rightCalls++; + } + pot += 4; + let result; + if (truth === 'Mine') { + myCards += pot; + result = `You win ${pot} cards!`; + pot = 0; + } else if (truth === 'CPU') { + cpuCards += pot; + result = `The CPU wins ${pot} cards.`; + pot = 0; + } else result = 'A tie! The cards stay in the pot.'; + ctx.hud.msg(pickWho === truth ? `${why} ✓ ${result}` : `Look again: ${why}. ${result}`, pickWho === truth ? 'good' : 'bad'); + callBox.replaceChildren(); + render(); + ctx.after(1600, nextRound); + } + + ctx.listen(window, 'keydown', (e) => { + if (phase !== 'compare') return; + const k = { ArrowLeft: 'Mine', ArrowRight: 'CPU', ArrowDown: 'Same', ArrowUp: 'Same', '=': 'Same' }[e.key]; + if (!k) return; + e.preventDefault(); + call(k); + }); + + function end() { + phase = 'done'; + const won = myCards > cpuCards; + ctx.finish({ + title: won ? 'You win the war!' : myCards === cpuCards ? 'A draw!' : 'The CPU wins this time', + won, + lines: [`Cards: you ${myCards}, CPU ${cpuCards}`, `Products right: ${rightProducts} of ${ROUNDS}`, `Bigger-or-smaller right: ${rightCalls} of ${ROUNDS}`, `Score: ${score}`], + }); + } + + ctx.hud.msg('Type your product, then say who wins'); + nextRound(); + + return { + state: () => ({ round, rounds: ROUNDS, phase, me: me && { ...me }, cpu: phase === 'answer' ? null : cpu && { ...cpu }, cpuHidden: cpu && { ...cpu }, myCards, cpuCards, pot, score }), + }; +} + +function addStyle() { + if (document.getElementById('war-style')) return; + const s = document.createElement('style'); + s.id = 'war-style'; + s.textContent = ` + .war-table { display: flex; align-items: center; justify-content: center; gap: 16px; flex-wrap: wrap; width: 100%; } + .war-side { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 12px 16px; border: 2px solid var(--edge); border-radius: 14px; background: var(--paper); min-width: 200px; } + .war-side.cpu { background: var(--soft); } + .war-label { font-weight: 900; font-size: 1.1rem; } + .war-label small { color: var(--muted); font-weight: 700; margin-left: 4px; } + .war-cards { display: flex; gap: 10px; } + .war-card { display: flex; align-items: center; justify-content: center; width: 70px; height: 96px; border: 2px solid var(--edge); border-radius: 10px; background: var(--surface); color: var(--ink); font-size: 2.2rem; font-weight: 900; box-shadow: 0 4px 0 var(--edge-shadow); } + .war-card.back { background: var(--accent); color: #fff; } + .war-product { font-size: 1.3rem; font-weight: 900; min-height: 1.4em; } + .war-vs { display: flex; flex-direction: column; align-items: center; font-weight: 900; font-size: 1.4rem; color: var(--muted); } + .war-vs small { font-size: 0.85rem; } + .war-panel { display: flex; flex-direction: column; align-items: center; min-height: 240px; } + .war-q { width: 100%; text-align: center; margin: 4px 0; font-weight: 800; } + .war-q small { color: var(--muted); } + .war-call .demo-choices .btn { min-width: 90px; } + @media (max-width: 560px) { .war-side { min-width: 0; padding: 8px 10px; } .war-card { width: 54px; height: 74px; font-size: 1.7rem; } .war-table { gap: 8px; } } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/games/whack.js b/apps/web/src/demos/games/whack.js new file mode 100644 index 0000000..349b413 --- /dev/null +++ b/apps/web/src/demos/games/whack.js @@ -0,0 +1,186 @@ +// Whack-a-Multiple (like Whack-a-Mole): moles pop up carrying numbers; whack +// only the multiples of the number shown. 30 seconds on the clock. +// DOM-based: nine holes in a grid, keys 1–9 whack the matching hole. + +const SECONDS = 30; +const PACE = { easy: { every: 1100, stay: 1900 }, medium: { every: 950, stay: 1600 }, hard: { every: 800, stay: 1350 } }; + +export function mount(stage, ctx) { + addStyle(); + const pace = PACE[ctx.level]; + // the times table for this round (×1 would make every number a multiple) + const n = ctx.pick(ctx.L.tables.filter((t) => t > 1)); + const top = n * ctx.L.upTo; + + const head = document.createElement('div'); + head.className = 'demo-big whack-head'; + head.innerHTML = `Whack the multiples of ${n}`; + const grid = document.createElement('div'); + grid.className = 'whack-grid'; + const holes = []; + for (let i = 0; i < 9; i++) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'whack-hole'; + b.dataset.i = i; + b.innerHTML = `${i + 1}`; + b.setAttribute('aria-label', `Hole ${i + 1}, empty`); + grid.append(b); + holes.push({ el: b, mole: null }); // mole: { value, multiple, until, hit } + } + stage.append(head, grid); + + let timeLeft = SECONDS; + let score = 0; + let hits = 0; + let wrong = 0; + let missed = 0; + let over = false; + let quietUntil = 0; // don't let a 'missed' note hide a fresh right/wrong message + const say = (text, kind) => { + ctx.hud.msg(text, kind); + quietUntil = performance.now() + 1500; + }; + + // a number for a mole: about half are multiples, the rest are near them + function moleValue() { + if (ctx.rng() < 0.5) return n * ctx.randInt(1, ctx.L.upTo); + let v; + do v = ctx.randInt(2, top); + while (v % n === 0); + return v; + } + const times = (v) => `${n} × ${v / n} = ${v}`; + // the multiples either side, so a wrong whack still teaches something + const between = (v) => { + const lo = Math.floor(v / n); + return lo < 1 ? `the ×${n} table starts at ${n}` : `${times(lo * n)}, ${times((lo + 1) * n)}`; + }; + + function show(i) { + const h = holes[i]; + const value = moleValue(); + h.mole = { value, multiple: value % n === 0, until: performance.now() + pace.stay, hit: false }; + h.el.classList.add('up'); + h.el.classList.remove('good', 'bad'); + h.el.querySelector('.whack-mole').textContent = value; + h.el.setAttribute('aria-label', `Hole ${i + 1}, mole ${value}`); + } + function hide(i) { + const h = holes[i]; + h.mole = null; + h.el.classList.remove('up'); + h.el.setAttribute('aria-label', `Hole ${i + 1}, empty`); + } + + function whack(i) { + const h = holes[i]; + if (over || !h.mole || h.mole.hit) return; + h.mole.hit = true; + const v = h.mole.value; + if (h.mole.multiple) { + hits++; + score += 10; + ctx.hud.score(score); + say(`${times(v)} ✓`, 'good'); + h.el.classList.add('good'); + } else { + wrong++; + say(`${v} isn’t in the ×${n} table (${between(v)})`, 'bad'); + h.el.classList.add('bad'); + } + // stay a moment so the child sees what happened + h.mole.until = performance.now() + 450; + } + + ctx.listen(grid, 'pointerdown', (e) => { + const b = e.target.closest('.whack-hole'); + if (!b) return; + e.preventDefault(); + whack(Number(b.dataset.i)); + }); + // keyboard: Enter/Space on a focused hole, or keys 1–9 + ctx.listen(grid, 'click', (e) => { + const b = e.target.closest('.whack-hole'); + if (b && e.detail === 0) whack(Number(b.dataset.i)); + }); + ctx.listen(window, 'keydown', (e) => { + if (/^[1-9]$/.test(e.key)) { + e.preventDefault(); + whack(Number(e.key) - 1); + } + }); + + // pop a mole into a random empty hole (sometimes two at once when time is short) + const pop = () => { + const empty = holes.map((h, i) => (h.mole ? -1 : i)).filter((i) => i >= 0); + if (empty.length) show(ctx.pick(empty)); + }; + ctx.every(pace.every, () => { + if (over) return; + pop(); + if (timeLeft <= 10 && ctx.rng() < 0.4) pop(); + }); + ctx.after(300, pop); + + // moles that time out: a missed multiple is shown, but costs nothing + ctx.loop(() => { + const now = performance.now(); + holes.forEach((h, i) => { + if (!h.mole || now < h.mole.until) return; + if (h.mole.multiple && !h.mole.hit) { + missed++; + if (now > quietUntil) ctx.hud.msg(`Missed one: ${times(h.mole.value)}`, ''); + } + hide(i); + }); + }); + + const tick = () => ctx.hud.info(`⏱ ${timeLeft}s · multiples of ${n}`); + tick(); + ctx.every(1000, () => { + if (over) return; + timeLeft--; + tick(); + if (timeLeft > 0) return; + over = true; + ctx.after(500, () => + ctx.finish({ + title: hits >= 10 ? 'Mole master!' : 'Time’s up!', + won: hits >= 10, + lines: [`You whacked ${hits} multiples of ${n}`, `Oops whacks: ${wrong}`, `Multiples that got away: ${missed}`, `Score: ${score}`], + }) + ); + }); + ctx.hud.msg(`Tap only numbers in the ×${n} table`); + + return { + state: () => ({ + n, + timeLeft, + score, + hits, + wrong, + missed, + over, + moles: holes.map((h, i) => (h.mole && !h.mole.hit ? { hole: i, value: h.mole.value, multiple: h.mole.multiple } : null)).filter(Boolean), + }), + }; +} + +function addStyle() { + if (document.getElementById('whack-style')) return; + const s = document.createElement('style'); + s.id = 'whack-style'; + s.textContent = ` + .whack-head .whack-n { color: var(--accent); font-size: 2.2rem; } + .whack-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 150px)); gap: 12px; width: 100%; justify-content: center; } + .whack-hole { position: relative; aspect-ratio: 1; border: 2px solid var(--edge); border-radius: 50% 50% 16px 16px; background: radial-gradient(ellipse at 50% 78%, var(--edge) 0 34%, var(--soft-2) 35%); cursor: pointer; overflow: hidden; font: inherit; touch-action: manipulation; padding: 0; } + .whack-mole { position: absolute; left: 18%; right: 18%; bottom: 14%; height: 62%; display: flex; align-items: center; justify-content: center; border-radius: 48% 48% 20% 20%; background: var(--gold-bg); border: 2px solid var(--edge); color: var(--ink); font-weight: 900; font-size: clamp(1.3rem, 6vw, 2rem); transform: translateY(120%); transition: transform 0.12s ease-out; } + .whack-hole.up .whack-mole { transform: translateY(0); } + .whack-hole.good .whack-mole { background: var(--good); color: #fff; } + .whack-hole.bad .whack-mole { background: var(--bad); color: #fff; } + .whack-key { position: absolute; bottom: 4px; left: 8px; font-weight: 800; color: var(--muted); } + `; + document.head.append(s); +} diff --git a/apps/web/src/demos/kit.js b/apps/web/src/demos/kit.js new file mode 100644 index 0000000..cddace7 --- /dev/null +++ b/apps/web/src/demos/kit.js @@ -0,0 +1,277 @@ +// What every demo gets. A demo is a module in ./games/.js: +// +// export function mount(stage, ctx) { +// // build the game inside `stage` (an empty
    ), using ctx for everything +// // that needs cleaning up (listeners, timers, loops) +// return { state: () => ({ … }) }; // optional: for automated checks +// } +// +// ctx: +// level 'easy' | 'medium' | 'hard' +// L LEVELS[level]: { tables, upTo, label } +// fact() a times-table fact for the level: { a, b, answer, text: '6 × 7' } +// nearMisses(answer, n, fact?) n wrong answers close to the answer +// hud.score(n) / hud.hearts(n) / hud.msg(text, 'good'|'bad'|'') / hud.info(text) +// finish({ title, lines: [] }) ends the demo and shows the results (Play again / All demos) +// listen(target, type, fn, opts) addEventListener, removed when the demo closes +// loop(fn(dt, t)) requestAnimationFrame loop (dt in seconds, capped); returns stop() +// every(ms, fn) / after(ms, fn) timers, cleared when the demo closes +// keys a Set of the keys held down right now (e.key) +// keypad(container, { onSubmit, prompt }) number pad + typing; see below +// choices(container, values, onPick) answer buttons +// canvas(container, w, h) a sharp w × h (CSS pixels) that scales down to fit +// color(name) a theme colour, e.g. color('--accent') +// rng, pick, shuffle, randInt, isPrime, factorPair +// toast(title, detail), confetti(el?), celebrate() (from @blockout/ui/rewards) +import { toast, confettiFrom, confettiFullScreen } from '@blockout/ui/rewards'; + +export const LEVELS = { + easy: { label: 'Easy', sub: '×2, ×5, ×10', tables: [2, 5, 10], upTo: 10 }, + medium: { label: 'Medium', sub: '×1 to ×6', tables: [2, 3, 4, 5, 6], upTo: 10 }, + hard: { label: 'Hard', sub: '×2 to ×12', tables: [2, 3, 4, 5, 6, 7, 8, 9, 11, 12], upTo: 12 }, +}; + +export const randInt = (rng, lo, hi) => lo + Math.floor(rng() * (hi - lo + 1)); +export const pick = (rng, list) => list[Math.floor(rng() * list.length)]; +export function shuffle(rng, list) { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} +export function isPrime(n) { + if (n < 2) return false; + for (let d = 2; d * d <= n; d++) if (n % d === 0) return false; + return true; +} +// two factors, neither 1, as close to each other as rng likes (null for primes) +export function factorPair(rng, n) { + const pairs = []; + for (let d = 2; d * d <= n; d++) if (n % d === 0) pairs.push([d, n / d]); + return pairs.length ? pick(rng, pairs) : null; +} + +export function makeFact(level, rng) { + const L = LEVELS[level]; + const a = pick(rng, L.tables); + const b = randInt(rng, 1, L.upTo); + return rng() < 0.5 ? { a, b, answer: a * b, text: `${a} × ${b}` } : { a: b, b: a, answer: a * b, text: `${b} × ${a}` }; +} + +// wrong answers a child might give: the facts next door, off by one, a ten out +export function nearMisses(rng, answer, n, fact = null) { + const out = new Set(); + const cands = [answer + 1, answer - 1, answer + 10, answer - 10, answer + 2]; + if (fact) cands.push(fact.a * (fact.b + 1), fact.a * (fact.b - 1), (fact.a + 1) * fact.b, (fact.a - 1) * fact.b); + for (const v of shuffle(rng, cands)) if (v > 0 && v !== answer) out.add(v); + while (out.size < n) { + const v = answer + randInt(rng, -12, 12); + if (v > 0 && v !== answer) out.add(v); + } + return [...out].slice(0, n); +} + +const make = (tag, cls, text) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; +}; + +// ctx for one run of one demo. hudEl: the demo's status bar; onFinish(result). +export function createContext({ level, hudEl, onFinish, rng = Math.random }) { + const cleanups = []; + let running = true; + const keys = new Set(); + + const listen = (target, type, fn, opts) => { + target.addEventListener(type, fn, opts); + cleanups.push(() => target.removeEventListener(type, fn, opts)); + }; + listen(window, 'keydown', (e) => keys.add(e.key)); + listen(window, 'keyup', (e) => keys.delete(e.key)); + listen(window, 'blur', () => keys.clear()); + + const hud = (() => { + hudEl.innerHTML = ''; + const score = make('span', 'demo-score', '0'); + const hearts = make('span', 'demo-hearts'); + const info = make('span', 'demo-info'); + const msg = make('span', 'demo-msg'); + msg.setAttribute('aria-live', 'polite'); + const left = make('span', 'demo-left'); + left.append(hearts, info); + const right = make('span', 'demo-right'); + right.append(make('small', null, 'Score '), score); + hudEl.append(left, msg, right); + return { + score: (n) => (score.textContent = n), + hearts: (n, max = 3) => (hearts.textContent = n === null ? '' : '❤️'.repeat(Math.max(0, n)) + '🤍'.repeat(Math.max(0, max - n))), + info: (text) => (info.textContent = text || ''), + msg: (text, kind = '') => { + msg.textContent = text || ''; + msg.className = `demo-msg ${kind}`; + }, + }; + })(); + hud.hearts(null); + + const ctx = { + level, + L: LEVELS[level], + rng, + hud, + keys, + listen, + randInt: (lo, hi) => randInt(rng, lo, hi), + pick: (list) => pick(rng, list), + shuffle: (list) => shuffle(rng, list), + isPrime, + factorPair: (n) => factorPair(rng, n), + fact: () => makeFact(level, rng), + nearMisses: (answer, n, fact) => nearMisses(rng, answer, n, fact), + toast, + confetti: (el) => confettiFrom(el || hudEl), + celebrate: () => confettiFullScreen(), + color: (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(), + get running() { + return running; + }, + + loop(fn) { + let last = performance.now(); + let id = requestAnimationFrame(function frame(t) { + if (!running) return; + const dt = Math.min(0.05, (t - last) / 1000); + last = t; + fn(dt, t); + id = requestAnimationFrame(frame); + }); + const stop = () => cancelAnimationFrame(id); + cleanups.push(stop); + return stop; + }, + every(ms, fn) { + const id = setInterval(() => running && fn(), ms); + cleanups.push(() => clearInterval(id)); + return () => clearInterval(id); + }, + after(ms, fn) { + const id = setTimeout(() => running && fn(), ms); + cleanups.push(() => clearTimeout(id)); + return () => clearTimeout(id); + }, + + // A number pad (0–9, delete, OK) that also takes typing (digits, Backspace, Enter). + // onSubmit(number) is called on OK/Enter. Returns { el, clear(), set(text), enable(on), prompt(text) }. + keypad(container, { onSubmit, prompt = '' } = {}) { + const box = make('div', 'demo-keypad'); + const q = make('div', 'demo-q', prompt); + const shown = make('span', 'answer-box'); + const line = make('div', 'demo-answer'); + line.append(q, shown); + const pad = make('div', 'keypad'); + for (const k of ['1', '2', '3', '4', '5', '6', '7', '8', '9', 'back', '0', 'ok']) { + const b = make('button', k === 'back' ? 'back' : k === 'ok' ? 'check' : '', k === 'back' ? '⌫' : k === 'ok' ? '✓' : k); + b.type = 'button'; + b.dataset.key = k; + pad.append(b); + } + box.append(line, pad); + container.append(box); + let typed = ''; + let enabled = true; + const render = () => (shown.textContent = typed); + const press = (k) => { + if (!enabled) return; + if (k === 'back') typed = typed.slice(0, -1); + else if (k === 'ok') { + if (!typed) return; + const v = Number(typed); + typed = ''; + render(); + return onSubmit && onSubmit(v); + } else if (/^\d$/.test(k) && typed.length < 5) typed += k; + render(); + }; + listen(pad, 'click', (e) => { + const b = e.target.closest('[data-key]'); + if (b) press(b.dataset.key); + }); + listen(window, 'keydown', (e) => { + if (!enabled || e.target.closest('input, textarea')) return; + if (/^\d$/.test(e.key)) press(e.key); + else if (e.key === 'Backspace') press('back'); + else if (e.key === 'Enter') press('ok'); + else return; + e.preventDefault(); + }); + return { + el: box, + clear: () => ((typed = ''), render()), + set: (text) => ((typed = String(text)), render()), + enable: (on) => { + enabled = on; + box.classList.toggle('disabled', !on); + }, + prompt: (text) => (q.textContent = text), + get value() { + return typed; + }, + }; + }, + + // Answer buttons; onPick(value, button). Returns the container element. + choices(container, values, onPick) { + const box = make('div', 'demo-choices'); + for (const v of values) { + const b = make('button', 'btn', String(v)); + b.type = 'button'; + b.dataset.value = v; + b.addEventListener('click', () => onPick(v, b)); + box.append(b); + } + container.replaceChildren(box); + return box; + }, + + // A canvas drawn at w × h CSS pixels, sharp on high-DPI screens, shrinking to fit. + canvas(container, w, h) { + const c = make('canvas', 'demo-canvas'); + const dpr = window.devicePixelRatio || 1; + c.width = w * dpr; + c.height = h * dpr; + c.style.aspectRatio = `${w} / ${h}`; + c.style.maxWidth = `${w}px`; + const g = c.getContext('2d'); + g.setTransform(dpr, 0, 0, dpr, 0, 0); + container.append(c); + // pointer position in canvas coordinates + const point = (e) => { + const r = c.getBoundingClientRect(); + return { x: ((e.clientX - r.left) / r.width) * w, y: ((e.clientY - r.top) / r.height) * h }; + }; + return { canvas: c, ctx: g, width: w, height: h, point }; + }, + + finish(result) { + if (!running) return; + running = false; + stopAll(); + onFinish(result); + }, + }; + + function stopAll() { + running = false; + while (cleanups.length) { + try { + cleanups.pop()(); + } catch (e) {} + } + } + ctx.stop = stopAll; + return ctx; +} diff --git a/apps/web/src/demos/main.js b/apps/web/src/demos/main.js new file mode 100644 index 0000000..35bab33 --- /dev/null +++ b/apps/web/src/demos/main.js @@ -0,0 +1,156 @@ +// Blockout demos: the hub (every demo, by group) and one demo at #. +// Opening a demo shows its intro (the idea, how to play, Easy/Medium/Hard), +// then loads ./games/.js and mounts it (see kit.js). +import './style.css'; +import { GROUPS, DEMOS, byId } from './catalog.js'; +import { LEVELS, createContext } from './kit.js'; + +const app = document.getElementById('app'); +const games = import.meta.glob('./games/*.js'); // loaded when opened +const LEVEL_KEY = 'blockout.demos.level'; +let current = null; // the running demo's ctx + +const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); +const savedLevel = () => { + try { + return LEVELS[localStorage.getItem(LEVEL_KEY)] ? localStorage.getItem(LEVEL_KEY) : 'easy'; + } catch (e) { + return 'easy'; + } +}; + +function stopCurrent() { + if (current) current.stop(); + current = null; + window.Demo = null; +} + +// ---------------------------------------------------------------- the hub + +function showHub() { + stopCurrent(); + document.title = 'Demos · Blockout'; + app.innerHTML = ` +
    +
    + ← Blockout (multiplication) +

    DEMOS

    +

    Classic games, turned into math games. Quick demos to try: pick one, choose Easy, Medium or Hard (the times tables), and play.

    +
    + ${GROUPS.map( + (g) => `
    +

    ${esc(g.name)}

    +
    + ${DEMOS.filter((d) => d.group === g.id) + .map( + (d) => ` +

    ${d.icon} ${esc(d.name)}

    +

    Like ${esc(d.classic)}

    +

    ${esc(d.idea)}

    +
    ` + ) + .join('')} +
    +
    ` + ).join('')} +
    `; +} + +// ---------------------------------------------------------------- one demo + +function showIntro(demo) { + stopCurrent(); + document.title = `${demo.name} · Blockout demos`; + let level = savedLevel(); + app.innerHTML = ` +
    +
    + ← All demos + +

    ${esc(demo.name)}

    +

    Like ${esc(demo.classic)}: ${esc(demo.idea)}

    +
      ${demo.how.map((h) => `
    • ${esc(h)}
    • `).join('')}
    +
    + Difficulty +
    + ${Object.entries(LEVELS) + .map(([id, l]) => ``) + .join('')} +
    +
    + +
    +
    `; + app.querySelector('#demo-level').addEventListener('click', (e) => { + const b = e.target.closest('[data-level]'); + if (!b) return; + level = b.dataset.level; + try { + localStorage.setItem(LEVEL_KEY, level); + } catch (err) {} + for (const x of app.querySelectorAll('#demo-level button')) x.classList.toggle('selected', x === b); + }); + const start = app.querySelector('#demo-start'); + start.addEventListener('click', () => play(demo, level)); + start.focus(); +} + +async function play(demo, level) { + stopCurrent(); + app.innerHTML = ` +
    +
    + ← +

    ${demo.icon} ${esc(demo.name)}

    +
    +
    +
    +
    +
    + `; + app.querySelector('#demo-restart').addEventListener('click', () => play(demo, level)); + app.querySelector('#demo-again').addEventListener('click', () => play(demo, level)); + const load = games[`./games/${demo.id}.js`]; + const stage = app.querySelector('#demo-stage'); + if (!load) { + stage.innerHTML = '

    This demo isn’t built yet.

    '; + return; + } + const mod = await load(); + const ctx = createContext({ + level, + hudEl: app.querySelector('#demo-hud'), + onFinish: (result = {}) => { + app.querySelector('#demo-over-title').textContent = result.title || 'Done!'; + app.querySelector('#demo-over-lines').innerHTML = (result.lines || []).map((l) => `
  1. ${esc(l)}
  2. `).join(''); + app.querySelector('#demo-over').hidden = false; + app.querySelector('#demo-again').focus(); + if (result.won) ctx.celebrate(); + }, + }); + current = ctx; + const handle = mod.mount(stage, ctx) || {}; + // for automated checks + window.Demo = { id: demo.id, level, state: handle.state || (() => null), finished: () => !app.querySelector('#demo-over').hidden }; +} + +// ---------------------------------------------------------------- routing + +function route() { + const id = location.hash.slice(1); + const demo = byId(id); + if (demo) showIntro(demo); + else showHub(); + window.scrollTo(0, 0); +} +window.addEventListener('hashchange', route); +route(); diff --git a/apps/web/src/demos/style.css b/apps/web/src/demos/style.css new file mode 100644 index 0000000..ba3de16 --- /dev/null +++ b/apps/web/src/demos/style.css @@ -0,0 +1,53 @@ +/* Blockout demos: the shared Blockout look, plus the hub, intro, status bar and helpers. */ +@import '@blockout/ui/css/tokens.css'; +@import '@blockout/ui/css/base.css'; +@import '@blockout/ui/css/menu.css'; +@import '@blockout/ui/css/game.css'; +@import '@blockout/ui/css/play.css'; +@import '@blockout/ui/css/dice-skins.css'; +@import '@blockout/ui/css/results.css'; +@import '@blockout/ui/css/rewards.css'; +@import '@blockout/ui/css/small-screens.css'; +@import '@blockout/game-kit/style.css'; + +/* hub */ +.demos-hub .demo-group { margin-top: 18px; } +.demos-hub .demo-group > h2 { margin: 0 0 10px; font-size: 1.3rem; } +.demo-card { color: inherit; text-decoration: none; cursor: pointer; } +.demo-card:hover { border-color: var(--accent); } + +/* intro */ +.demo-intro { text-align: center; } +.demo-icon { font-size: 3rem; line-height: 1; margin-top: 6px; } +.demo-title { margin: 6px 0 4px; font-size: clamp(1.8rem, 6vw, 2.4rem); font-weight: 900; } +.demo-how { text-align: left; margin: 10px 0 16px; padding-left: 1.2em; line-height: 1.5; } +#demo-level small { display: block; } + +/* one demo */ +.demo-screen { max-width: 1100px; margin: 0 auto; } +.demo-screen .game-header { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; } +.demo-name { font-size: clamp(1rem, 4vw, 1.6rem); min-width: 0; text-align: center; overflow-wrap: anywhere; line-height: 1.15; } +.demo-hud { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: 10px; padding: 8px 14px; margin: 8px 0 10px; border: 2px solid var(--edge); border-radius: 12px; background: var(--paper); font-weight: 800; } +.demo-left { display: flex; gap: 12px; align-items: center; } +.demo-right { text-align: right; font-size: 1.2rem; font-variant-numeric: tabular-nums; } +.demo-right small { font-size: 0.8rem; color: var(--muted); } +.demo-msg { text-align: center; min-height: 1.3em; } +.demo-msg.good { color: var(--good-ink); } +.demo-msg.bad { color: var(--bad); } +.demo-info { color: var(--muted); } +.demo-stage { display: flex; flex-direction: column; align-items: center; gap: 12px; } +.demo-canvas { width: 100%; height: auto; display: block; border: 2px solid var(--edge); border-radius: 12px; background: var(--surface); touch-action: none; } +.demo-keypad { display: flex; flex-direction: column; align-items: center; gap: 8px; } +.demo-keypad.disabled { opacity: 0.5; } +.demo-answer { display: flex; align-items: center; gap: 10px; font-size: 1.5rem; font-weight: 900; } +.demo-answer .answer-box { min-width: 3em; } +.demo-choices { display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } +.demo-choices .btn { min-width: 64px; font-size: 1.2rem; font-weight: 900; } +.demo-row { display: flex; flex-wrap: wrap; gap: 16px; justify-content: center; align-items: flex-start; width: 100%; } +.demo-lines { text-align: left; margin: 8px 0 0; padding-left: 1.2em; } +.demo-big { font-size: 1.6rem; font-weight: 900; text-align: center; } +@media (max-width: 560px) { + .demo-screen .header-right .btn { padding-left: 10px; padding-right: 10px; } + .demo-hud { grid-template-columns: 1fr 1fr; } + .demo-msg { grid-column: 1 / -1; grid-row: 2; } +} diff --git a/apps/web/src/more-games/main.js b/apps/web/src/more-games/main.js new file mode 100644 index 0000000..50f86d4 --- /dev/null +++ b/apps/web/src/more-games/main.js @@ -0,0 +1,25 @@ +// More games: a card for each game built on @blockout/game-kit, and the other prototypes. +import '@blockout/game-kit/style.css'; +import { VARIANTS } from '@blockout/engine/variants'; + +const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); +const BOARD_KIND = { grid: 'Grid board', bars: 'Fraction bars' }; + +document.getElementById('hub-grid').innerHTML = VARIANTS.map((v) => { + const kind = BOARD_KIND[v.newBoard('easy').kind]; + return `
    +

    ${esc(v.op)} ${esc(v.name)}

    +

    ${esc(v.title)} · ${esc(v.grade)}

    +

    ${esc(v.tagline)}

    +

    ${esc(v.concept)}

    + ${kind}${v.answer === 'frac' ? ' · fraction answers' : ''} + Play › +
    `; +}).join('') + `
    +

    🐍 Number Snake

    +

    Times tables · Grades 2–4

    +

    A snake game that eats numbers instead of apples.

    +

    Steer the snake to the right number: the answer to 6 × 7, or every multiple of 4. Or eat any number you like and add it to your running total. The right numbers make it grow; a wrong one costs a heart and shows the fact to remember. It speeds up as you go.

    + Arcade · arrows, swipe or on-screen buttons + Play › +
    `; diff --git a/apps/web/src/multiplication/accounts.js b/apps/web/src/multiplication/accounts.js new file mode 100644 index 0000000..60c01f8 --- /dev/null +++ b/apps/web/src/multiplication/accounts.js @@ -0,0 +1,1140 @@ +// Account screens: signing in (dev sign-in or Keycloak), a student's homework and +// classes, a teacher's classes (roster, stats, homework, scheduled tournaments, +// inviting teachers and grown-ups), a parent's view of their children, and +// one-time codes (#code=…) that make someone a teacher or a child's grown-up. Talks to apps/server through +// @blockout/auth and borrows a few helpers from the game (./game/api.js). +// Guests never see any of it except "Sign in". + +import * as Auth from '@blockout/auth'; +import * as Game from './game/api.js'; +import * as Progress from '@blockout/progress'; +import * as Homework from '@blockout/progress/homework'; +import * as Grownups from '@blockout/progress/grownups'; + +let el, account, homework, classes, kids; + +const { make, toast } = Game; + +const $ = (id) => document.getElementById(id); + +const { ROLE, FORMAT_NAMES, STATUS_ORDER, ago, activityText, dayString, niceTime, shortDay } = Grownups; + +const DIFFICULTY_NAMES = Progress.DIFFICULTY_NAMES; + + +const LIVE_POLL_MS = 20 * 1000; + +const SCHEDULE_POLL_MS = 15 * 1000; + +function button(text, cls = 'btn', onClick = null) { + const b = make('button', cls, text); + b.type = 'button'; + if (onClick) b.addEventListener('click', onClick); + return b; +} + +function field(label, input) { + const f = make('label', 'field'); + f.append(make('span', null, label), input); + return f; +} + +function input(attrs = {}) { + const i = document.createElement('input'); + for (const [k, v] of Object.entries(attrs)) i.setAttribute(k, v); + return i; +} + +// "Tue, Oct 29" +const codeLink = (code) => `${location.origin}${location.pathname}#code=${code}`; + +// A one-time code to hand over: big, with when it stops working, a QR code of +// its link (for a phone) and a button to copy the link +function codeCard(invite, who) { + const card = make('div', 'acct-invite'); + const qr = document.createElement('canvas'); + qr.className = 'acct-qr'; + Game.drawQr(qr, codeLink(invite.code)); + const text = make('div', 'acct-invite-text'); + const copy = button('Copy link', 'btn btn-small', async () => { + try { + await navigator.clipboard.writeText(codeLink(invite.code)); + copy.textContent = 'Copied!'; + } catch (_) { + prompt('Copy this link:', codeLink(invite.code)); + } + }); + text.append(make('div', 'acct-code', invite.code.replace(/(.{4})/, '$1 ')), make('p', 'setting-help', `${who} Works once, until ${shortDay(invite.expiresAt)}.`), copy); + card.append(qr, text); + return card; +} + +// "I have a code": a teacher's or a grown-up's one-time code. The account +// changes (a new role, a linked child), so the page starts again. +function redeemForm(placeholder = 'Code') { + const form = make('form', 'acct-inline'); + const code = input({ type: 'text', maxlength: '9', autocomplete: 'off', placeholder, class: 'acct-code-input' }); + const go = button('Use code', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(code, go); + const error = errorLine(); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + await redeem(code.value); + } catch (err) { + showError(error, err); + } + }); + const wrap = make('div'); + wrap.append(form, error); + return wrap; +} + +async function redeem(code) { + const r = await Auth.api('POST', '/api/invites/redeem', { code }); + await Auth.refreshUser(); + toast(r.child ? '👪 Linked' : `${ROLE[r.user.role].emoji} Welcome`, r.child ? r.child.name : `You’re a ${ROLE[r.user.role].label.toLowerCase()} now.`); + setTimeout(() => location.reload(), 1200); // (the menu changes with the role) +} + +// Everything Blockout keeps about an account, as a file to keep +async function downloadData(path, name) { + const data = await Auth.api('GET', path); + const a = document.createElement('a'); + a.href = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })); + a.download = `blockout-${name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}.json`; + a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 10000); +} + +// Deleting an account: typed, because it can't be undone +async function confirmDelete(what, typed) { + const answer = prompt(`${what} This can’t be undone. Type ${typed} to go ahead.`); + return answer !== null && answer.trim().toLowerCase() === typed.toLowerCase(); +} + +function select(options, value) { + const s = document.createElement('select'); + for (const [v, text] of options) { + const o = document.createElement('option'); + o.value = v; + o.textContent = text; + s.append(o); + } + if (value !== undefined) s.value = String(value); + return s; +} + +// A row of choice buttons (the game's .segmented look); returns { el, value() } +function segmented(options, value, onChange) { + const wrap = make('div', 'segmented'); + let current = value; + for (const [v, text] of options) { + const b = button(text); + b.dataset.value = v; + b.classList.toggle('selected', v === value); + b.addEventListener('click', () => { + current = v; + for (const x of wrap.children) x.classList.toggle('selected', x === b); + if (onChange) onChange(v); + }); + wrap.append(b); + } + return { el: wrap, value: () => current }; +} + +const errorLine = () => { + const p = make('p', 'invite-error'); + p.setAttribute('role', 'alert'); + p.hidden = true; + return p; +}; + +const showError = (p, err) => { + p.textContent = err.message || String(err); + p.hidden = false; +}; + + +const { niceDay } = Homework; + + +// Our own dialogs, built like the game's: .overlay > .dialog with a bottom +// .dialog-actions panel. Escape or the backdrop closes them. +function dialog(id, title, cls = '') { + let overlay = $(id); + if (!overlay) { + overlay = make('div', 'overlay'); + overlay.id = id; + overlay.hidden = true; + const box = make('div', `dialog acct-dialog ${cls}`); + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + box.setAttribute('aria-labelledby', `${id}-title`); + const h = make('h2', null, title); + h.id = `${id}-title`; + const body = make('div', 'acct-body'); + const actions = make('div', 'dialog-actions'); + box.append(h, body, actions); + overlay.append(box); + document.body.append(overlay); + overlay.addEventListener('click', (e) => e.target === overlay && close()); + overlay.addEventListener('keydown', (e) => e.key === 'Escape' && close()); + } + const d = { + overlay, + title: overlay.querySelector('h2'), + body: overlay.querySelector('.acct-body'), + actions: overlay.querySelector('.dialog-actions'), + onClose: null, + }; + function close() { + overlay.hidden = true; + if (overlay._onClose) overlay._onClose(); + } + d.open = () => (overlay.hidden = false); + d.close = close; + d.setOnClose = (fn) => (overlay._onClose = fn); + d.clear = (heading = title) => { + d.title.textContent = heading; + d.body.replaceChildren(); + d.actions.replaceChildren(); + }; + d.done = (text = 'Done') => { + const b = button(text, 'btn btn-primary', close); + d.actions.append(b); + return b; + }; + return d; +} + +function renderMenu() { + const served = Auth.served(); + el.accountBtn.hidden = !served; + const u = Auth.user(); + el.accountBtn.replaceChildren(...(u ? [`${ROLE[u.role].emoji} ${u.name}`] : [Game.icon('user'), ' Sign in'])); + el.accountBtn.title = u ? `${ROLE[u.role].label} account: tap for yours` : 'Sign in (you can always play as a guest)'; + const role = Auth.role(); + el.homeworkBtn.hidden = !['guest', 'student'].includes(role); // (guests too: today's practice) + el.classesBtn.hidden = role !== 'teacher'; + el.kidsBtn.hidden = role !== 'parent'; +} + +function setBadge(btn, count) { + let badge = btn.querySelector('.badge'); + if (!count) return badge && badge.remove(); + if (!badge) { + badge = make('span', 'badge'); + badge.setAttribute('aria-hidden', 'true'); + btn.append(badge); + } + badge.textContent = String(count); +} + +async function openSignIn({ role = 'student', note = '' } = {}) { + account.clear('Sign in'); + let config; + try { + config = await Auth.config(); + } catch (err) { + account.body.append(make('p', 'setting-help', err.message)); + account.done('OK'); + return account.open(); + } + if (note) account.body.append(make('p', 'acct-note', note)); + if (config.provider === 'keycloak') { + account.body.append(make('p', 'setting-help', 'Sign in with your school or family account.')); + account.actions.append(button('Play as a guest', 'btn', account.close), button('Sign in', 'btn btn-primary', () => Auth.signInKeycloak())); + return account.open(); + } + // Dev sign-in: pick who you are + account.body.append( + make('p', 'setting-help', 'Practice sign-in: pick who you are. (Signing in with a real school account comes later.) You can always play as a guest instead.') + ); + const who = segmented( + [ + ['student', '🎒 Student'], + ['teacher', '🍎 Teacher'], + ['parent', '👪 Parent'], + ], + role, + () => showFields() + ); + const first = input({ type: 'text', maxlength: '20', autocomplete: 'off', placeholder: 'e.g. Maya' }); + const initial = input({ type: 'text', maxlength: '1', autocomplete: 'off', placeholder: 'K', class: 'acct-initial' }); + const email = input({ type: 'email', autocomplete: 'off', placeholder: 'maya@iusd.org' }); + const school = select([...config.schools.map((s) => [s.id, `${s.name} (@${s.domain})`]), ['', 'No school']]); + const admin = input({ type: 'checkbox' }); + const adminField = make('label', 'acct-check'); + adminField.append(admin, make('span', null, 'Admin (manage accounts at /admin/)')); + const emailField = field('School email', email); + const schoolField = field('School', school); + const names = make('div', 'acct-names'); + names.append(field('First name', first), field('Last initial', initial)); + const error = errorLine(); + const form = make('form', 'acct-form'); + form.append(who.el, names, emailField, schoolField, adminField, make('p', 'setting-help acct-privacy', 'We only keep your first name and last initial.'), error); + let emailTouched = false; + email.addEventListener('input', () => (emailTouched = true)); + first.addEventListener('input', () => { + if (!emailTouched && config.schools[0]) email.value = first.value.trim() ? `${first.value.trim().toLowerCase()}@${config.schools[0].domain}` : ''; + }); + function showFields() { + emailField.hidden = who.value() !== 'student'; + schoolField.hidden = who.value() !== 'teacher'; + } + showFields(); + const submit = async (e) => { + if (e) e.preventDefault(); + error.hidden = true; + go.disabled = true; + try { + const r = who.value(); + await Auth.signInDev({ + role: r, + firstName: first.value, + lastInitial: initial.value, + email: r === 'student' ? email.value : r === 'parent' ? '' : undefined, + schoolId: r === 'teacher' ? school.value : undefined, + admin: admin.checked, + }); + location.reload(); // the page picks up the account's save + } catch (err) { + showError(error, err); + go.disabled = false; + } + }; + form.addEventListener('submit', submit); + account.body.append(form); + const go = button('Sign in', 'btn btn-primary', submit); + account.actions.append(button('Play as a guest', 'btn', account.close), go); + account.open(); + first.focus(); +} + +async function openAccount() { + const u = Auth.user(); + if (!u) return openSignIn(); + account.clear('Your account'); + const card = make('div', 'acct-card'); + card.append(make('div', 'acct-big', `${ROLE[u.role].emoji} ${u.name}`), make('div', 'acct-sub', `${ROLE[u.role].label}${u.isAdmin ? ' · Admin' : ''}${u.school ? ` · ${u.school.name}` : ''}`)); + account.body.append(card); + if (u.role === 'student') await studentAccount(); + if (u.role === 'teacher') account.body.append(make('p', 'setting-help', 'Your classes, homework, tournaments and codes for other teachers are under 🏫 My classes on the main menu.')); + if (u.role === 'parent') account.body.append(make('p', 'setting-help', 'Follow your children under 👪 My kids on the main menu.')); + if (u.role === 'student') { + // Everyone starts as a student: a teacher's or a grown-up's code changes that + const grownUps = make('div', 'acct-section'); + grownUps.append(make('h3', 'stats-heading', 'Teachers and grown-ups'), make('p', 'setting-help', 'Got a code to become a teacher, or to follow a child? Type it here.'), redeemForm()); + account.body.append(grownUps); + } + if (u.isAdmin) account.actions.append(button('Admin', 'btn', () => location.assign('/admin/'))); + if (u.role !== 'student') account.actions.append(button('Download my data', 'btn', () => downloadData('/api/me/export', u.name).catch((err) => alert(err.message)))); + if (u.role === 'teacher' || u.role === 'parent') { + account.actions.append( + button('Delete my account', 'btn btn-danger', async () => { + if (!(await confirmDelete('Delete your Blockout account and everything in it?', 'delete'))) return; + try { + await Auth.api('DELETE', '/api/me'); + Auth.signOut(); + } catch (err) { + alert(err.message); + } + }) + ); + } + account.actions.append(button('Sign out', 'btn', () => Auth.signOut())); + account.done(); + account.open(); +} + +// Back from signing out of an account that came from Google. Blockout and +// Keycloak are signed out; Google isn't, and only Google can do that, for all of +// Google in this browser at once. So it's offered, not done. +function signedOutOfGoogle() { + account.clear('Signed out'); + account.body.append( + make('p', null, 'You’re signed out of Blockout.'), + make('p', 'setting-help', 'On a shared computer? This browser is still signed in to Google. Signing out of Google signs you out of all of it here: Gmail, Classroom, YouTube and the rest.'), + ); + const google = make('a', 'btn', 'Also sign out of Google'); + google.href = 'https://accounts.google.com/Logout'; + google.target = '_blank'; + google.rel = 'noopener'; + account.actions.append(google); + account.done(); + account.open(); +} + +// A student's classes, joining one with a code, and the code for a grown-up +async function studentAccount() { + const classes = make('div', 'acct-section'); + classes.append(make('h3', 'stats-heading', 'Your classes')); + const list = make('div', 'acct-list'); + classes.append(list); + const code = input({ type: 'text', maxlength: '8', autocomplete: 'off', placeholder: 'Class code', class: 'acct-code-input' }); + const error = errorLine(); + const row = make('form', 'acct-inline'); + const join = button('Join', 'btn btn-small btn-primary'); + join.type = 'submit'; + row.append(code, join); + row.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + const r = await Auth.api('POST', '/api/me/classes', { code: code.value }); + toast('🏫 Joined!', r.class.name); + code.value = ''; + await fill(); + } catch (err) { + showError(error, err); + } + }); + classes.append(row, error); + const parent = make('div', 'acct-section'); + account.body.append(classes, parent); + async function fill() { + list.replaceChildren(); + try { + const { classes: mine } = await Auth.api('GET', '/api/me/classes'); + if (!mine.length) list.append(make('p', 'setting-help', 'Not in a class yet. Type the code your teacher gives you.')); + for (const c of mine) list.append(make('div', 'acct-item', `🏫 ${c.name}`)); + } catch (err) { + list.append(make('p', 'setting-help', err.message)); + } + } + await fill(); + parent.append(make('h3', 'stats-heading', 'For a grown-up'), make('p', 'setting-help', 'Make a code for your mom, dad or another grown-up, so they can see how you’re doing and add homework.')); + const slot = make('div'); + const codeError = errorLine(); + parent.append( + button('Make a grown-up code', 'btn btn-small', async () => { + codeError.hidden = true; + try { + slot.replaceChildren(codeCard(await Auth.api('POST', '/api/me/parent-invites'), 'Give it to one grown-up.')); + } catch (err) { + showError(codeError, err); + } + }), + slot, + codeError + ); +} + +function askTeacherSignIn() { + openSignIn({ role: 'teacher', note: '🍎 Teachers: sign in to host a class. Students join with the code (no sign-in needed).' }); +} + + + +// A thin bar: mastered / learning / needs practice / not seen, out of 144 +function masteryBar(summary) { + const bar = make('div', 'acct-mastery'); + bar.title = STATUS_ORDER.map(([k, label]) => `${label}: ${summary[k]}`).join(' · '); + for (const [k] of STATUS_ORDER) { + const seg = make('span', `seg is-${k}`); + seg.style.width = `${(summary[k] / summary.total) * 100}%`; + bar.append(seg); + } + return bar; +} + +// onPlay(goal): the student's list, where each open homework starts its game +function homeworkList(items, { onRemove = null, mine = () => false, onPlay = null } = {}) { + const list = make('div', 'acct-list'); + if (!items.length) list.append(make('p', 'setting-help', 'No homework.')); + for (const h of items) { + const words = Homework.homeworkRow(h); + const row = make('div', `acct-hw ${h.status}`); + const top = make('div', 'acct-hw-top'); + top.append(make('strong', null, words.label)); + top.append(make('span', 'acct-hw-state', words.state)); + row.append(top); + const bar = make('div', 'practice-bar'); + const fill = make('span'); + fill.style.width = `${(h.have / h.need) * 100}%`; + bar.append(fill); + const meta = make('div', 'acct-hw-meta'); + meta.append(make('span', null, `${h.have} of ${h.need}`), make('span', null, words.from)); + if (onRemove && mine(h)) meta.append(button('Remove', 'btn btn-small', () => onRemove(h))); + if (onPlay && words.playable) { + row.classList.add('playable'); + const play = button('Play ›', 'btn btn-small btn-primary', () => onPlay(h.goal)); + play.setAttribute('aria-label', `Play: ${words.label}`); + meta.append(play); + } + row.append(bar, meta); + list.append(row); + } + return list; +} + +// What a teacher or parent sees for one student +function studentDetail(s, { onAddHomework, onRemoveHomework, mine }) { + const box = make('div', 'acct-detail'); + const head = make('div', 'acct-card'); + head.append(make('div', 'acct-sub', `Last played ${ago(s.lastPlayed)}`)); // (the dialog title is the name) + box.append(head); + // Times Table + const tt = make('div', 'acct-section'); + tt.append(make('h3', 'stats-heading', 'Times Table')); + const counts = make('div', 'acct-counts'); + for (const [k, label] of STATUS_ORDER) counts.append(make('span', `acct-count is-${k}`, `${s.summary[k]} ${label.toLowerCase()}`)); + tt.append(counts, masteryBar(s.summary)); + if (s.facts) { + tt.append(Game.renderFactLegend()); + tt.append(Game.renderFactGrid(null, s.facts, 12)); + } + if (s.summary.needsPractice.length) { + tt.append(make('p', 'setting-help', 'Needs practice:')); + const chips = make('div', 'acct-chips'); + for (const f of s.summary.needsPractice) chips.append(make('span', 'acct-chip is-practice', f)); + tt.append(chips); + } + box.append(tt); + // Homework + const hw = make('div', 'acct-section'); + hw.append(make('h3', 'stats-heading', 'Homework'), homeworkList(s.assignments, { onRemove: onRemoveHomework, mine })); + if (onAddHomework) hw.append(homeworkForm(onAddHomework, s.options, s.name)); + box.append(hw); + // Recent activity + const act = make('div', 'acct-section'); + act.append(make('h3', 'stats-heading', 'Recently')); + if (!s.recent.length) act.append(make('p', 'setting-help', 'Nothing played yet.')); + for (const e of s.recent) { + const row = make('div', 'acct-activity'); + row.append(make('span', null, activityText(e)), make('span', 'acct-when', ago(e.t))); + act.append(row); + } + box.append(act); + return box; +} + +// Set homework: the goal, its details and a due date. onSubmit({ goal, due }) +// Only offers what the child (or every child in the class) can already play: +// options = { practice, difficulties, boards, tables } from the server. +function homeworkForm(onSubmit, options, who) { + const form = make('form', 'acct-hw-form'); + form.append(make('h4', null, 'Add homework')); + const c = Grownups.homeworkChoices(options); + const type = select(c.kinds); + const table = select(c.tables.map((n) => [n, `×${n}`]), c.start.table); + const rounds = select(Grownups.HOMEWORK_COUNTS.map((n) => [n, String(n)]), c.start.rounds); + const games = select(Grownups.HOMEWORK_COUNTS.map((n) => [n, String(n)]), c.start.games); + const size = select(c.boards.map((n) => [n, `${n}×${n}`]), c.start.size); + const difficulty = select(c.difficulties, c.start.difficulty); + const week = new Date(Date.now() + 7 * 86400000); + const due = input({ type: 'date', value: dayString(week), min: dayString(new Date()) }); + const f = { + table: field('Table', table), + rounds: field('Rounds', rounds), + games: field('Games', games), + size: field('Board', size), + difficulty: field('Difficulty', difficulty), + }; + const grid = make('div', 'acct-grid'); + grid.append(field('Homework', type), f.table, f.rounds, f.games, f.size, f.difficulty, field('Due', due)); + const show = () => { + const uses = Grownups.homeworkFields(type.value); + for (const k of Object.keys(f)) f[k].hidden = !uses[k]; + }; + type.addEventListener('change', show); + show(); + const error = errorLine(); + const go = button('Set homework', 'btn btn-small btn-primary'); + go.type = 'submit'; + const note = make('p', 'setting-help', Grownups.homeworkNote(who)); + form.append(grid, note, error, go); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + const goal = Grownups.goalFrom({ type: type.value, table: table.value, rounds: rounds.value, games: games.value, size: size.value, difficulty: difficulty.value }); + go.disabled = true; + try { + await onSubmit({ goal, due: due.value || null }); + toast(...Grownups.homeworkSet(goal)); + } catch (err) { + showError(error, err); + } finally { + go.disabled = false; + } + }); + return form; +} + +// Today's practice first (everyone), then the homework set by a teacher or grown-up +async function openHomework() { + homework.clear(); + homework.done(); + homework.open(); + homework.body.append(make('h3', 'stats-heading', '⭐ Today’s practice'), Game.dailyCard(() => homework.close())); + homework.body.append(make('h3', 'stats-heading', 'From your teacher')); + if (Auth.role() !== 'student') { + homework.body.append(make('p', 'setting-help', 'Sign in with your school account to see homework from your teacher or a grown-up.')); + return; + } + try { + const { homework: items } = await Auth.api('GET', '/api/me/homework'); + homework.body.append(make('p', 'setting-help', Homework.homeworkIntro(items))); + homework.body.append(homeworkList(Homework.sortHomework(items), { onPlay: playHomework })); + } catch (err) { + homework.body.append(make('p', 'setting-help', err.message)); + } +} + +// Tapping a homework: start it (Homework.homeworkChoice: mode, difficulty, +// board, table). A times table's practice starts straight away; a game sets +// the menu up for it in Free play (not the Journey's level) and starts. +// Homework only asks for what the student has unlocked, so nothing here +// should be locked. +function playHomework(goal) { + homework.close(); + const tap = (selector) => { + const b = document.querySelector(selector); + if (b && !b.dataset.unlock && !b.closest('[hidden]')) b.click(); + }; + const choice = Homework.homeworkChoice(Game.progress(), goal); + if (choice.tables) return Game.startPractice('You', 5, choice.tables, false, 0, choice.difficulty || 'easy'); + tap(`[data-mode="${choice.mode}"]`); + if (choice.mode === 'single') tap('#play-kind [data-play="free"]'); + if (choice.difficulty) tap(`[data-setting="difficulty"] [data-value="${choice.difficulty}"]`); + if (choice.size) tap(`#board-size [data-size="${choice.size}"]`); + const start = document.getElementById('start-btn'); + if (!start.disabled) start.click(); +} + +// Homework's badge: what's still to do, today's practice too. localOnly: just +// today's practice changed (no need to ask the server again) +let homeworkOpen = 0; +async function refreshHomeworkBadge(localOnly = false) { + if (!localOnly && Auth.role() === 'student') { + try { + homeworkOpen = Homework.openCount((await Auth.api('GET', '/api/me/homework')).homework); + } catch (_) { + // offline: just today's practice + } + } + if (!['guest', 'student'].includes(Auth.role())) return; + setBadge(el.homeworkBtn, homeworkOpen + (Game.dailyOpen() ? 1 : 0)); +} + +let currentClass = null; + +let schedulePoll = null; + +async function openClasses() { + classes.open(); + await renderClasses(); + clearInterval(schedulePoll); + schedulePoll = setInterval(() => !classes.overlay.hidden && renderSchedules(), SCHEDULE_POLL_MS); +} + +async function renderClasses() { + classes.clear('🏫 My classes'); + classes.actions.append(button('Host a live game', 'btn', () => (classes.close(), Game.openHostSetup()))); + classes.done(); + let list; + try { + list = (await Auth.api('GET', '/api/classes')).classes; + } catch (err) { + classes.body.append(make('p', 'setting-help', err.message)); + return; + } + if (list.length && !list.some((c) => c.id === currentClass)) currentClass = list[0].id; + // class tabs + a new class + const tabs = make('div', 'acct-tabs'); + for (const c of list) { + const t = button(`${c.name} (${c.students})`, 'btn btn-small'); + t.classList.toggle('btn-primary', c.id === currentClass); + t.addEventListener('click', () => ((currentClass = c.id), renderClasses())); + tabs.append(t); + } + const name = input({ type: 'text', maxlength: '40', placeholder: list.length ? 'New class name' : 'Class name, like “Room 12”' }); + const add = make('form', 'acct-inline'); + const addBtn = button('+ New class', 'btn btn-small'); + addBtn.type = 'submit'; + add.append(name, addBtn); + const error = errorLine(); + add.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const c = await Auth.api('POST', '/api/classes', { name: name.value }); + currentClass = c.id; + await renderClasses(); + } catch (err) { + showError(error, err); + } + }); + classes.body.append(tabs, add, error); + if (!list.length) { + classes.body.append(make('p', 'setting-help', 'Make a class, then give your students its code. Any student with a school account (@iusd.org) can join with it.')); + name.focus(); + return; + } + const slot = make('div', 'acct-class'); + classes.body.append(slot); + await renderClass(slot); + classes.body.append(await teacherInvites()); +} + +// A student's grown-ups, for their teacher: who's linked (take off a wrong +// one), and a code for a new one +async function openGrownUps(student, d, back) { + d.clear(`${student.name}’s grown-ups`); + d.actions.append(button('← Back', 'btn', back)); + d.done(); + const list = make('div', 'acct-list'); + const error = errorLine(); + async function fill() { + list.replaceChildren(); + try { + const { parents } = await Auth.api('GET', `/api/students/${student.id}/parents`); + if (!parents.length) list.append(make('p', 'setting-help', 'No grown-up is following yet.')); + for (const p of parents) { + const row = make('div', 'acct-item'); + row.append( + make('span', null, `👪 ${p.name}`), + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Stop ${p.name} following ${student.name}?`)) return; + await Auth.api('DELETE', `/api/students/${student.id}/parents/${p.id}`); + fill(); + }) + ); + list.append(row); + } + } catch (err) { + showError(error, err); + } + } + const slot = make('div'); + d.body.append( + list, + make('p', 'setting-help', `A code lets one grown-up follow ${student.name}: their Times Table, homework, and setting homework of their own.`), + button('Make a grown-up code', 'btn btn-small', async () => { + try { + slot.replaceChildren(codeCard(await Auth.api('POST', `/api/students/${student.id}/parent-invites`), `For ${student.name}’s grown-up.`)); + } catch (err) { + showError(error, err); + } + }), + slot, + error + ); + await fill(); +} + +// Bringing in another teacher: a one-time code, and the codes still out there +async function teacherInvites() { + const section = make('div', 'acct-section'); + section.append(make('h3', 'stats-heading', 'Invite a teacher'), make('p', 'setting-help', 'A colleague signs in, then types the code (or opens its link) to get their own classes.')); + const slot = make('div'); + const list = make('div', 'acct-list'); + const error = errorLine(); + async function fill() { + list.replaceChildren(); + try { + const { invites } = await Auth.api('GET', '/api/me/teacher-invites'); + for (const i of invites) { + const row = make('div', 'acct-item'); + row.append(make('span', null, `🍎 Code from ${shortDay(i.createdAt)}`), make('span', 'acct-small', Grownups.inviteState(i))); + if (!i.usedAt) { + row.append( + button('Remove', 'btn btn-small', async () => { + await Auth.api('DELETE', `/api/me/invites/${i.id}`); + fill(); + }) + ); + } + list.append(row); + } + } catch (err) { + showError(error, err); + } + } + section.append( + button('Make a teacher code', 'btn btn-small', async () => { + error.hidden = true; + try { + slot.replaceChildren(codeCard(await Auth.api('POST', '/api/me/teacher-invites', {}), 'For one teacher.')); + fill(); + } catch (err) { + showError(error, err); + } + }), + slot, + list, + error + ); + await fill(); + return section; +} + +async function renderClass(slot) { + let d; + try { + d = await Auth.api('GET', `/api/classes/${currentClass}`); + } catch (err) { + slot.append(make('p', 'setting-help', err.message)); + return; + } + const c = d.class; + // join code and link + const join = make('div', 'acct-join'); + const link = `${location.origin}${location.pathname}#roster=${c.code}`; + join.append(make('div', 'acct-sub', 'Students join with'), make('div', 'acct-code', c.code)); + const copy = button('Copy join link', 'btn btn-small', async () => { + try { + await navigator.clipboard.writeText(link); + copy.textContent = 'Copied!'; + } catch (_) { + prompt('Copy this link:', link); + } + }); + join.append(copy, make('p', 'setting-help', `Students sign in with their school account${c.school ? ` (${c.school})` : ''}, then type the code or open the link.`)); + slot.append(join); + + // roster + const roster = make('div', 'acct-section'); + roster.append(make('h3', 'stats-heading', `Students · ${d.students.length}`)); + if (!d.students.length) roster.append(make('p', 'setting-help', 'Nobody has joined yet.')); + else { + const table = make('table', 'stats-table acct-roster'); + const head = make('tr'); + for (const h of ['Name', 'Times Table', 'Needs practice', 'Last played', 'Homework', 'Grown-ups']) head.append(make('th', null, h)); + const thead = make('thead'); + thead.append(head); + const tbody = make('tbody'); + for (const s of d.students) { + const tr = make('tr'); + const nameCell = make('td'); + nameCell.append(button(s.name, 'acct-link', () => openStudent(s.id, classes, renderClasses))); + const tt = make('td'); + tt.append(masteryBar(s.summary), make('span', 'acct-small', `${s.summary.mastered} of ${s.summary.total} mastered`)); + tr.append(nameCell, tt, make('td', null, Grownups.needsPracticeShort(s.summary.needsPractice)), make('td', null, ago(s.lastPlayed)), make('td', null, Grownups.homeworkCount(s.homework)), make('td')); + tr.lastChild.append(button('Grown-ups', 'btn btn-small', () => openGrownUps(s, classes, renderClasses))); + const remove = button('×', 'acct-remove', async () => { + if (!confirm(`Take ${s.name} out of ${c.name}?`)) return; + await Auth.api('DELETE', `/api/classes/${c.id}/students/${s.id}`); + renderClasses(); + }); + remove.title = `Take ${s.name} out of the class`; + tr.lastChild.append(remove); + tbody.append(tr); + } + table.append(thead, tbody); + const wrap = make('div', 'acct-table-wrap'); + wrap.append(table); + roster.append(wrap); + } + slot.append(roster); + + // homework + const hw = make('div', 'acct-section'); + hw.append(make('h3', 'stats-heading', 'Class homework')); + if (!d.assignments.length) hw.append(make('p', 'setting-help', 'No homework set.')); + for (const a of d.assignments) { + const row = make('div', 'acct-item'); + row.append(make('strong', null, a.label), make('span', 'acct-small', Grownups.classHomeworkLine(a, niceDay))); + row.append( + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Remove “${a.label}” for the whole class?`)) return; + await Auth.api('DELETE', `/api/homework/${a.id}`); + renderClasses(); + }) + ); + hw.append(row); + } + hw.append( + homeworkForm( + async ({ goal, due }) => { + await Auth.api('POST', '/api/homework', { classId: c.id, goal, due }); + renderClasses(); + }, + d.options, + d.students.length ? `every student in ${c.name}` : 'a new student' + ) + ); + slot.append(hw); + + // scheduled tournaments + const tour = make('div', 'acct-section'); + tour.id = 'acct-schedules'; + slot.append(tour); + await renderSchedules(); +} + +async function renderSchedules() { + const box = $('acct-schedules'); + if (!box) return; + let list; + try { + list = (await Auth.api('GET', '/api/schedules')).schedules; + } catch (err) { + return; + } + box.replaceChildren(make('h3', 'stats-heading', 'Tournaments')); + const mine = Grownups.schedulesFor(list, currentClass); + if (!mine.length) box.append(make('p', 'setting-help', 'Plan a tournament and its lobby opens by itself at that time.')); + for (const s of mine) { + const row = make('div', `acct-item${s.open ? ' live' : ''}`); + row.append( + make('strong', null, s.title), + make('span', 'acct-small', Grownups.scheduleLine(s)) + ); + if (s.open) { + row.append( + button('Open the lobby', 'btn btn-small btn-primary', async () => { + const h = await Auth.api('GET', `/api/schedules/${s.id}/host`); + classes.close(); + Game.startHost({ code: h.code, teacherKey: h.teacherKey }); + }) + ); + } + row.append( + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Remove “${s.title}”?`)) return; + await Auth.api('DELETE', `/api/schedules/${s.id}`); + renderSchedules(); + }) + ); + box.append(row); + } + box.append(scheduleForm()); +} + +function scheduleForm() { + const form = make('form', 'acct-hw-form'); + form.append(make('h4', null, 'Plan a tournament')); + const soon = new Date(Date.now() + 24 * 3600 * 1000); + soon.setMinutes(0, 0, 0); + const local = (d) => `${dayString(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + const title = input({ type: 'text', maxlength: '60', placeholder: 'e.g. Friday Cup' }); + const when = input({ type: 'datetime-local', value: local(soon) }); + const format = select(Object.entries(FORMAT_NAMES), 'knockout'); + const size = select(Grownups.SCHEDULE_SIZES.map((n) => [n, `${n}×${n}`]), 8); + const difficulty = select(Grownups.SCHEDULE_DIFFICULTIES.map((d) => [d, DIFFICULTY_NAMES[d]]), 'easy'); + const scope = select([ + ['class', 'This class'], + ['school', 'Whole school'], + ]); + const grid = make('div', 'acct-grid'); + grid.append(field('Name', title), field('When', when), field('Format', format), field('Board', size), field('Difficulty', difficulty), field('Who', scope)); + const error = errorLine(); + const go = button('Plan it', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(grid, error, go); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + await Auth.api('POST', '/api/schedules', { + classId: currentClass, + scope: scope.value, + title: title.value, + startsAt: new Date(when.value).getTime(), + settings: { format: format.value, size: Number(size.value), difficulty: difficulty.value }, + }); + toast('🏆 Tournament planned', niceTime(new Date(when.value).getTime())); + renderSchedules(); + } catch (err) { + showError(error, err); + } + }); + return form; +} + +// One student's page (teacher or parent), inside dialog d; back() returns +async function openStudent(id, d, back) { + d.clear('Student'); + d.actions.append(button('← Back', 'btn', back)); + d.done(); + let s; + try { + s = await Auth.api('GET', `/api/students/${id}`); + } catch (err) { + d.body.append(make('p', 'setting-help', err.message)); + return; + } + d.title.textContent = s.name; + const me = Auth.user(); + d.body.append( + studentDetail(s, { + mine: (h) => Grownups.canRemove(h, me), + onAddHomework: async ({ goal, due }) => { + await Auth.api('POST', '/api/homework', { studentId: s.id, goal, due }); + openStudent(id, d, back); + }, + onRemoveHomework: async (h) => { + if (!confirm(`Remove “${h.label}”?`)) return; + await Auth.api('DELETE', `/api/homework/${h.id}`); + openStudent(id, d, back); + }, + }) + ); + if (me.role === 'parent') { + d.actions.prepend(button(`Download ${s.name}’s data`, 'btn', () => downloadData(`/api/students/${s.id}/export`, s.name).catch((err) => alert(err.message)))); + d.actions.prepend( + button(`Delete ${s.name}’s account`, 'btn btn-danger', async () => { + if (!(await confirmDelete(`Delete ${s.name}’s Blockout account, with their progress, classes and homework?`, s.name.split(' ')[0]))) return; + try { + await Auth.api('DELETE', `/api/students/${s.id}`); + back(); + } catch (err) { + alert(err.message); + } + }) + ); + } +} + +async function openKids() { + kids.open(); + await renderKids(); +} + +async function renderKids() { + kids.clear('👪 My kids'); + kids.done(); + let list; + try { + list = (await Auth.api('GET', '/api/children')).children; + } catch (err) { + kids.body.append(make('p', 'setting-help', err.message)); + return; + } + if (!list.length) kids.body.append(make('p', 'setting-help', 'Link your child’s account with a code: your child makes one on their Blockout account page (tap their name at the top of the menu), or their teacher can.')); + const cards = make('div', 'acct-kids'); + for (const c of list) { + const card = button('', 'acct-kid', () => openStudent(c.id, kids, renderKids)); + card.append(make('strong', null, `🎒 ${c.name}`), masteryBar(c.summary), make('span', 'acct-small', `${c.summary.mastered} of ${c.summary.total} facts mastered · played ${ago(c.lastPlayed)}`), make('span', 'acct-small', Grownups.kidHomework(c.homework))); + cards.append(card); + } + kids.body.append(cards); + const form = make('form', 'acct-inline'); + const code = input({ type: 'text', maxlength: '9', autocomplete: 'off', placeholder: 'Child’s code', class: 'acct-code-input' }); + const go = button('Link', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(code, go); + const error = errorLine(); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + const r = await Auth.api('POST', '/api/children', { code: code.value }); + toast('👪 Linked', r.child.name); + renderKids(); + } catch (err) { + showError(error, err); + } + }); + const section = make('div', 'acct-section'); + section.append(make('h3', 'stats-heading', 'Link a child'), form, error); + kids.body.append(section); +} + +async function pollLive() { + if (Auth.role() !== 'student' || !Game.menuShowing()) return; + try { + const { live } = await Auth.api('GET', '/api/me/live'); + el.liveBanner.replaceChildren(); + el.liveBanner.hidden = !live.length; + for (const l of live) { + const row = make('div', 'live-row'); + row.append(make('span', null, `🏆 ${l.title} is open!`), button('Join', 'btn btn-small btn-primary', () => Game.joinClass(l.code))); + el.liveBanner.append(row); + } + } catch (_) { + // offline + } +} + +// #roster=CODE: a teacher's link to join their class roster +async function rosterLink() { + const m = /^#roster=([A-Za-z0-9]+)$/.exec(location.hash); + if (!m) return; + const code = m[1].toUpperCase(); + if (Auth.role() !== 'student') return openSignIn({ role: 'student', note: `🏫 To join class ${code}, sign in with your school account.` }); + history.replaceState(null, '', location.pathname); + try { + const r = await Auth.api('POST', '/api/me/classes', { code }); + toast('🏫 You joined', r.class.name); + } catch (err) { + toast('🏫 Couldn’t join', err.message); + } +} + +// #code=CODE: a one-time code's link (a teacher's or a grown-up's) +async function codeFromLink() { + const m = /^#code=([A-Za-z0-9]+)$/.exec(location.hash); + if (!m) return; + const code = m[1].toUpperCase(); + if (!Auth.signedIn()) return openSignIn({ note: '🔑 Sign in first, then the code is used for your account.' }); + history.replaceState(null, '', location.pathname); + try { + await redeem(code); + } catch (err) { + toast('🔑 That code didn’t work', err.message); + } +} + +async function start() { + renderMenu(); + if (!Auth.served()) return; + try { + if (await Auth.finishKeycloak()) location.reload(); + } catch (err) { + toast('Sign-in', err.message); + } + if (Auth.takeSignedOut() === 'google') signedOutOfGoogle(); + if (Auth.signedIn()) { + try { + const next = await Auth.syncProgress(Game.progress()); + if (next) Game.replaceProgress(next); + } catch (_) { + // offline: keep playing on the local copy; it syncs with the next save + } + renderMenu(); // (a stale sign-in may have been dropped) + } + await rosterLink(); + await codeFromLink(); + refreshHomeworkBadge(); + pollLive(); + setInterval(pollLive, LIVE_POLL_MS); + setInterval(refreshHomeworkBadge, 60 * 1000); +} + +// What the account screens do when the page loads (main.js runs this after the game) +export function run() { + el = { + accountBtn: $('account-btn'), + homeworkBtn: $('homework-btn'), + classesBtn: $('classes-btn'), + kidsBtn: $('kids-btn'), + liveBanner: $('live-banner'), + }; + + account = dialog('account', 'Sign in', 'account-dialog'); + + el.accountBtn.addEventListener('click', openAccount); + + homework = dialog('homework-dialog', '📚 Homework', 'wide-dialog'); + + el.homeworkBtn.addEventListener('click', openHomework); + + classes = dialog('classes-dialog', '🏫 My classes', 'wide-dialog'); + + classes.setOnClose(() => clearInterval(schedulePoll)); + + el.classesBtn.addEventListener('click', openClasses); + + kids = dialog('kids-dialog', '👪 My kids', 'wide-dialog'); + + el.kidsBtn.addEventListener('click', openKids); + + + start(); +} + +export { askTeacherSignIn, openSignIn, openAccount, refreshHomeworkBadge }; diff --git a/apps/web/src/multiplication/css/accounts.css b/apps/web/src/multiplication/css/accounts.css new file mode 100644 index 0000000..5560215 --- /dev/null +++ b/apps/web/src/multiplication/css/accounts.css @@ -0,0 +1,103 @@ +/* ---------- Accounts: sign in, homework, classes, kids (js/accounts.js) ---------- */ +.top-row { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } +.top-row .wallet { margin-bottom: 0; } +.account-chip { + font: inherit; + font-weight: 800; + padding: 4px 12px; + border: 2px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-chip:hover { border-color: var(--accent); } +.live-banner { margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; } +.live-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; font-weight: 800; text-align: left; } +.acct-menu-btn { position: relative; } + +.acct-dialog { text-align: left; max-height: calc(100vh - 32px); overflow-y: auto; } +.acct-dialog h2 { text-align: center; margin-bottom: 0.8rem; } +.acct-dialog.wide-dialog { max-width: 860px; } +.acct-dialog.account-dialog { max-width: 460px; } +.acct-dialog :is(input[type="text"], input[type="email"], input[type="date"], input[type="datetime-local"], select) { + font: inherit; + padding: 0.5rem 0.65rem; + border: 2px solid var(--line); + border-radius: 10px; + background: var(--surface); + color: var(--ink); + min-width: 0; +} +.acct-dialog :is(input, select):focus { outline: none; border-color: var(--accent); } +.acct-form { display: flex; flex-direction: column; gap: 12px; } +.acct-names { display: grid; grid-template-columns: 1fr 110px; gap: 10px; } +.acct-initial { text-transform: uppercase; } +.acct-note { margin: 0 0 10px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; } +.acct-privacy { margin: 0; } +.acct-card { text-align: center; margin-bottom: 8px; } +.acct-big { font-size: 1.4rem; font-weight: 900; } +.acct-sub { color: var(--muted); font-weight: 700; } +.acct-small { display: block; font-size: 0.8rem; color: var(--muted); font-weight: 700; } +.acct-section { margin-top: 16px; } +.acct-section h4, .acct-hw-form h4 { margin: 0 0 8px; } +.acct-list { display: flex; flex-direction: column; gap: 8px; } +.acct-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; border: 2px solid var(--line); border-radius: 10px; background: var(--surface); } +.acct-item strong { flex: 1 1 auto; } +.acct-item.live { border-color: var(--gold-line); background: var(--gold-bg); } +.acct-inline { display: flex; gap: 8px; margin-top: 8px; } +.acct-inline input { flex: 1 1 auto; } +.acct-code-input { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800; } +.acct-code { font-size: 1.6rem; font-weight: 900; letter-spacing: 0.12em; font-family: ui-monospace, "Courier New", monospace; } +.acct-mono { font-family: ui-monospace, "Courier New", monospace; font-weight: 700; } +.acct-join { text-align: center; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; margin: 12px 0; } +.acct-join .btn { margin-top: 6px; } +.acct-tabs { display: flex; gap: 6px; flex-wrap: wrap; } +.acct-mastery { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--soft-2); min-width: 90px; } +.acct-mastery .seg.is-mastered, .acct-count.is-mastered { background: var(--good); color: #fff; } +.acct-mastery .seg.is-learning, .acct-count.is-learning { background: var(--learning); color: var(--learning-ink); } +.acct-mastery .seg.is-practice, .acct-count.is-practice, .acct-chip.is-practice { background: var(--danger); color: #fff; } +.acct-mastery .seg.is-unseen, .acct-count.is-unseen { background: var(--soft-2); color: var(--ink); } +.acct-counts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.acct-count, .acct-chip { padding: 2px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 800; } +.acct-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.acct-hw { padding: 10px 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; gap: 6px; } +.acct-hw.done { border-color: var(--good); } +.acct-hw.playable .acct-hw-meta .btn-primary { margin-left: auto; } +.acct-hw.late { border-color: var(--danger); } +.acct-hw-top, .acct-hw-meta { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } +.acct-hw-state { font-weight: 800; font-size: 0.85rem; color: var(--muted); } +.acct-hw.late .acct-hw-state { color: var(--bad); } +.acct-hw.done .acct-hw-state { color: var(--good-ink); } +.acct-hw-meta { font-size: 0.8rem; color: var(--muted); font-weight: 700; } +.acct-hw-form { margin-top: 12px; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; } +.acct-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-bottom: 10px; } +.acct-grid > .field:first-child { grid-column: span 2; } +.acct-grid select, .acct-grid input { width: 100%; } +.acct-table-wrap { overflow-x: auto; } +.acct-roster td { vertical-align: middle; } +.acct-roster td:first-child { white-space: nowrap; } +.acct-link { font: inherit; font-weight: 800; background: none; border: 0; padding: 0; color: var(--accent-text); cursor: pointer; text-decoration: underline; } +.acct-remove { font: inherit; margin-left: 8px; border: 0; background: none; color: var(--muted); cursor: pointer; font-weight: 900; } +.acct-remove:hover { color: var(--bad); } +.acct-activity { display: flex; justify-content: space-between; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--line); font-size: 0.9rem; } +.acct-when { color: var(--muted); white-space: nowrap; } +.acct-kids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.acct-kid { font: inherit; text-align: left; display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--ink); cursor: pointer; } +.acct-kid:hover { border-color: var(--accent); } +.acct-detail .fact-grid-wrap { max-width: 420px; margin: 8px auto; } +@media (max-width: 560px) { + .acct-dialog { --pad-x: 14px; --pad-y: 18px; } + .acct-names { grid-template-columns: 1fr 80px; } +} + +/* A one-time code to hand over: QR code beside the code itself */ +.acct-invite { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin: 10px 0; padding: 12px; border: 2px dashed var(--line); border-radius: var(--radius); background: var(--surface); } +.acct-qr { width: 120px; height: 120px; image-rendering: pixelated; border-radius: 6px; } +.acct-invite-text { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; min-width: 0; } +.acct-invite-text .setting-help { margin: 0; } +.acct-check { display: flex; gap: 8px; align-items: center; font-size: 0.95rem; color: var(--muted); } diff --git a/apps/web/src/multiplication/css/classroom.css b/apps/web/src/multiplication/css/classroom.css new file mode 100644 index 0000000..c822b1d --- /dev/null +++ b/apps/web/src/multiplication/css/classroom.css @@ -0,0 +1,159 @@ +/* ---------- Classroom: join / host dialogs ---------- */ +.class-dialog { max-width: 440px; text-align: left; } +.class-dialog h2 { text-align: center; margin-bottom: 0.6rem; } +.class-dialog form { display: flex; flex-direction: column; gap: 12px; } +.class-dialog > .field { display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px; } +.class-dialog .dialog-actions { margin-top: 8px; } +.class-dialog .field { text-align: left; } +.code-input { font-size: 1.8rem !important; font-weight: 900; letter-spacing: 0.3em; text-transform: uppercase; text-align: center; } +.class-wait-big { font-size: 1.3rem; font-weight: 900; text-align: center; margin: 0.4rem 0; } +.class-wait-text { text-align: center; color: var(--muted); margin: 0 0 12px; } +.score-rank { margin-top: 4px; font-size: 0.85rem; font-weight: 800; color: var(--muted); } +.result-list li.result-you { outline: 3px solid var(--c); } +.result-list li.result-gap { background: none; border: 0; text-align: center; color: var(--muted); padding: 0; margin: -2px 0 4px; display: block; } + +/* ---------- Classroom: the teacher's screen ---------- */ +#class-host-screen .game-header { max-width: 1200px; } +.host-code-pill { background: var(--paper); border: 2px solid var(--edge); border-radius: 999px; padding: 4px 14px; font-weight: 800; } +.host-code-pill strong { letter-spacing: 0.12em; } +.host-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; max-width: 1200px; margin: 0 auto; align-items: start; } +.host-main, .host-side { background: var(--paper); border: 2px solid var(--edge); border-radius: var(--radius); box-shadow: 0 6px 0 var(--edge-shadow); padding: clamp(16px, 3vw, 32px); } +.host-panel { display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center; } +.host-kicker { margin: 0; font-size: 1.6rem; font-weight: 900; } +.host-join { display: flex; align-items: center; justify-content: center; gap: clamp(16px, 4vw, 40px); flex-wrap: wrap; } +.host-qr { width: min(240px, 60vw); height: auto; border-radius: 12px; border: 2px solid var(--edge); background: #fff; image-rendering: pixelated; } +.host-join-text { text-align: left; } +.host-step { margin: 0; color: var(--muted); font-weight: 800; } +.host-url { margin: 0 0 12px; font-size: clamp(1.1rem, 2.4vw, 1.7rem); font-weight: 900; word-break: break-all; } +.host-share { display: flex; gap: 6px; flex-wrap: wrap; margin: -4px 0 12px; } +.host-share .btn.copied, #host-copy-teacher.copied { background: var(--good-bg); border-color: var(--good); color: var(--good-ink); } +.host-code { margin: 0; font-size: clamp(3rem, 9vw, 5.5rem); font-weight: 900; letter-spacing: 0.15em; line-height: 1; color: var(--accent); } +.host-settings { margin: 0; color: var(--muted); font-weight: 700; } +.host-panel .btn-big { max-width: 420px; } +.host-dice { gap: 28px; margin: 0; } +.host-dice .die { width: 120px; height: 120px; padding: 16px; border-radius: 22px; box-shadow: 0 6px 0 var(--edge-shadow); } +.host-dice .pip { width: 20px; height: 20px; } +.host-dice .die-num { font-size: 3.4rem; } +.host-roll-text { margin: 0; font-size: 1.5rem; font-weight: 900; } +.host-progress { width: min(100%, 420px); display: flex; align-items: center; gap: 12px; font-weight: 800; white-space: nowrap; } +.host-progress .progress-bar { flex: 1; height: 16px; } +#host-done-fill { display: block; height: 100%; background: var(--good); transition: width 0.3s; } +.host-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } +.host-actions .btn { font-size: 1.1rem; padding: 0.7rem 1.3rem; } +.host-auto { display: flex; gap: 8px; align-items: center; font-weight: 700; color: var(--muted); cursor: pointer; } +.host-auto input { width: 18px; height: 18px; accent-color: var(--accent); } +.host-side .stats-heading { margin-top: 0; } +.host-roster { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: calc(100vh - 200px); overflow-y: auto; } +.host-roster li { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 10px; font-weight: 800; } +.host-roster li.offline { opacity: 0.55; } +.host-roster li.waiting { border-style: dashed; } +.host-waiting { font-size: 0.8rem; color: var(--muted); white-space: nowrap; } +.host-roster li.host-empty { display: block; border-style: dashed; color: var(--muted); font-weight: 700; } +.host-rank { min-width: 2.6em; color: var(--muted); font-variant-numeric: tabular-nums; } +.host-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.host-score { font-variant-numeric: tabular-nums; font-size: 1.1rem; } +.host-status { width: 1.4em; text-align: center; color: var(--good-ink); } +.host-remove { border: 0; background: none; font-size: 1.3rem; line-height: 1; color: var(--muted); cursor: pointer; padding: 0 4px; } +.host-remove:hover { color: var(--bad); } +.podium { list-style: none; margin: 0; padding: 0; display: flex; gap: 12px; justify-content: center; align-items: flex-end; flex-wrap: wrap; } +.podium li { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 140px; padding: 16px; border-radius: 14px; border: 2px solid var(--edge); background: var(--surface); font-weight: 900; } +.podium .podium-1 { order: 2; background: var(--gold-bg); border-color: var(--gold-line); padding-top: 28px; } +.podium .podium-2 { order: 1; } +.podium .podium-3 { order: 3; } +.podium-medal { font-size: 2.6rem; } +.podium-name { font-size: 1.3rem; } +.podium-score { color: var(--muted); } +#host-report { width: 100%; } +.host-hardest { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; } +.host-hardest li { display: flex; flex-direction: column; background: var(--bad-bg); border-radius: 10px; padding: 8px 12px; text-align: left; } +.host-hardest li.host-empty { background: var(--good-bg); grid-column: 1 / -1; font-weight: 800; } +.host-fact { font-size: 1.4rem; font-weight: 900; } +.host-missed { font-size: 0.85rem; font-weight: 700; color: var(--bad); } +/* Lobby: game settings */ +.host-game-settings { width: 100%; max-width: 760px; text-align: left; border-top: 2px solid var(--line); padding-top: 12px; } +.host-settings-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.host-settings-head .stats-heading { margin: 0; } +.host-game-settings > .setting-help { margin: 2px 0 10px; } +.host-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px 16px; } +.host-settings-grid .segmented button { padding: 0.45rem 0.3rem; font-size: 0.92rem; } +.host-settings-grid small { display: block; } + +/* Tournaments */ +.host-settings-grid .field.wide { grid-column: 1 / -1; } +.host-settings-grid [data-key="format"] { display: grid; grid-template-columns: repeat(5, 1fr); } +@media (max-width: 700px) { + .host-settings-grid [data-key="format"] { grid-template-columns: repeat(2, 1fr); } +} +.host-tournament { width: 100%; display: flex; flex-direction: column; gap: 14px; } +.bracket { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; align-items: stretch; } +.bracket-round { display: flex; flex-direction: column; justify-content: space-around; gap: 10px; min-width: 150px; } +.bracket-label { font-size: 0.8rem; font-weight: 900; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; text-align: center; } +.bracket-match { border: 2px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; } +.bracket-name { padding: 4px 10px; font-weight: 800; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bracket-name + .bracket-name { border-top: 1px solid var(--line); } +.bracket-name.winner { background: var(--good-bg); color: var(--good-ink); } +.bracket-name.loser { color: var(--muted); } +.bracket-bye { font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; } +.standings { width: 100%; border-collapse: collapse; font-weight: 700; font-variant-numeric: tabular-nums; } +.standings th, .standings td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--line); } +.standings th { font-size: 0.8rem; color: var(--muted); } +.standings tr.out td { color: var(--muted); } +.koth-hills { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.koth-hill { border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; padding: 10px 12px; text-align: left; } +.koth-title { font-size: 0.8rem; font-weight: 900; color: var(--gold-ink); text-transform: uppercase; } +.koth-king { font-size: 1.4rem; font-weight: 900; } +.koth-streak { font-weight: 800; } +.koth-queue, .koth-count { font-size: 0.85rem; color: var(--muted); font-weight: 700; } +.host-roster li.knocked-out { opacity: 0.6; } +.result-tournament { margin: 0 0 14px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; text-align: center; } + +/* Pairs */ +.host-pairs { width: 100%; max-width: 760px; display: flex; flex-direction: column; gap: 12px; text-align: left; } +#host-odd small { display: block; } +.host-pairs .setting-help { margin: 0; } +.host-pair-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; } +.host-pair-list li { display: flex; align-items: center; gap: 6px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 8px; font-weight: 800; } +.pair-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } +button.pair-name { border: 2px dashed var(--line); border-radius: 8px; background: var(--paper); padding: 4px 6px; font-weight: 800; cursor: pointer; } +button.pair-name:hover { border-color: var(--accent); } +button.pair-name.picked { border-style: solid; border-color: var(--accent); background: var(--accent-soft); } +.pair-name.other { color: var(--muted); font-style: italic; } +.pair-vs { font-size: 0.75rem; color: var(--muted); } +.host-teacher-device { display: flex; gap: 14px; align-items: center; background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 12px; padding: 10px; } +.host-qr.small { width: 120px; } +.host-teacher-url { margin: 2px 0; font-weight: 800; word-break: break-all; font-size: 0.85rem; } +.host-teacher-status { margin: 0; font-weight: 800; color: var(--muted); } +.host-teacher-status.ok { color: var(--good-ink); } +.host-matches { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.host-match { background: var(--surface); border: 2px solid var(--edge); border-radius: 12px; padding: 8px 10px; text-align: left; } +.host-match.over { border-color: var(--line); } +.match-row { display: flex; align-items: center; gap: 6px; font-weight: 800; padding: 2px 0; } +.match-row.winner { color: var(--good-ink); } +.match-turn { width: 1.4em; text-align: center; } +.match-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.match-score { font-size: 1.2rem; font-weight: 900; font-variant-numeric: tabular-nums; } +.match-status { margin-top: 4px; font-size: 0.8rem; font-weight: 700; color: var(--muted); } +.host-mygame.your-turn { animation: pulse 1.2s ease-in-out infinite; } +@keyframes pulse { + 0%, 100% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 0 var(--accent-ring); } + 50% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 8px var(--accent-ring); } +} + +@media (max-width: 820px) { + .host-layout { grid-template-columns: 1fr; } + .host-roster { max-height: none; } +} + +/* ---------- Classmates' boards: the student's side panel and the teacher's screen ---------- */ +.mini-board { display: block; border: 2px solid var(--edge); border-radius: 6px; background: var(--surface); } +.classmates { margin-top: 12px; } +.classmates-title { margin: 0 0 8px; font-size: 0.95rem; color: var(--muted); } +.classmates-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 8px; } +.classmate, .host-board { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 6px; border: 2px solid var(--line); border-radius: 10px; background: var(--paper); } +.classmate.done, .host-board.done { border-color: var(--good); } +.host-board.offline { opacity: 0.55; } +.classmate-who { display: flex; flex-direction: column; align-items: center; text-align: center; line-height: 1.15; } +.classmate-name { font-weight: 800; font-size: 0.85rem; } +.classmate-score { font-size: 0.75rem; color: var(--muted); font-weight: 700; } +.host-boards { display: grid; grid-template-columns: repeat(auto-fill, minmax(118px, 1fr)); gap: 10px; width: 100%; margin-top: 14px; } +.host-name .player-title, .match-name .player-title, .podium-name .player-title, .pair-name .player-title, .result-name .player-title { display: block; font-size: 0.7rem; white-space: normal; line-height: 1.15; } diff --git a/apps/web/src/multiplication/game/achievements.js b/apps/web/src/multiplication/game/achievements.js new file mode 100644 index 0000000..219fc5e --- /dev/null +++ b/apps/web/src/multiplication/game/achievements.js @@ -0,0 +1,78 @@ +// Achievements: part of the multiplication game (split from the old js/game.js). + +import * as Views from '@blockout/progress/views'; +import { el } from './base.js'; +import { progress } from './wallet.js'; +import { icon } from './wardrobe.js'; +import { make } from './play.js'; + +function renderAchievements() { + el.achievementsBody.innerHTML = ''; + const list = Views.achievementGroups(progress); + el.achievementsBody.append(make('p', 'stats-summary', `${list.earned} of ${list.total} earned`)); + for (const g of list.groups) { + el.achievementsBody.append(make('h3', 'stats-heading', `${g.name} · ${g.earned} of ${g.total}`)); + if (g.note) el.achievementsBody.append(make('p', 'setting-help', g.note)); + const box = make('div', 'achievement-list'); + for (const r of g.rows) { + const row = make('div', 'achievement' + (r.earned ? ' earned' : '')); + const badge = make('span', 'achievement-icon', r.icon || undefined); + if (!r.icon) badge.append(icon('lock')); + const text = make('div', 'achievement-text'); + text.append(make('strong', null, r.name), make('span', null, r.desc)); + row.append(badge, text, make('span', 'achievement-reward', r.right)); + box.append(row); + } + el.achievementsBody.append(box); + } + el.achievementsBody.append(renderFactSpeed()); +} + +// Fact speed: one cell per fact with the best tier earned (10, 5 or 1 seconds). +function renderFactSpeed() { + const box = make('div', 'fact-speed'); + const fs = Views.factSpeed(progress); + box.append(make('h3', 'stats-heading', `Fact speed · ${fs.earned} of ${fs.total}`)); + box.append(make('p', 'setting-help', fs.note)); + const counts = make('div', 'fact-legend'); + for (const t of fs.tiers) { + const item = make('span', 'fact-legend-item'); + item.append(make('span', `fact-cell speed-${t.secs}`, String(t.secs)), document.createTextNode(`Under ${t.secs} s: ${t.count}`)); + counts.append(item); + } + box.append(counts); + const grid = make('div', 'fact-grid'); + grid.style.setProperty('--n', fs.size + 1); + grid.append(make('span', 'fact-head', '×')); + for (let c = 1; c <= fs.size; c++) grid.append(make('span', 'fact-head', String(c))); + fs.cells.forEach((row, r) => { + grid.append(make('span', 'fact-head', String(r + 1))); + for (const { a, b, best } of row) { + const cell = make('span', `fact-cell ${best ? `speed-${best}` : 'unseen'}`, best ? String(best) : ''); + cell.title = best ? `${a} × ${b}: under ${best} s` : `${a} × ${b}: not earned yet`; + grid.append(cell); + } + }); + box.append(grid); + return box; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.achievementsBtn.addEventListener('click', () => { + renderAchievements(); + el.achievementsDialog.hidden = false; + el.achievementsDialog.querySelector('.dialog').scrollTop = 0; + el.achievementsDone.focus(); + }); + + el.achievementsDone.addEventListener('click', () => (el.achievementsDialog.hidden = true)); + + el.achievementsDialog.addEventListener('click', (e) => { + if (e.target === el.achievementsDialog) el.achievementsDialog.hidden = true; + }); + + el.achievementsDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') el.achievementsDialog.hidden = true; + }); +} diff --git a/apps/web/src/multiplication/game/api.js b/apps/web/src/multiplication/game/api.js new file mode 100644 index 0000000..290115d --- /dev/null +++ b/apps/web/src/multiplication/game/api.js @@ -0,0 +1,17 @@ +// What the account screens (../accounts.js) use from the game. +import { el } from './base.js'; +import { progress as state } from './wallet.js'; +import { connectHost, openClassJoin } from './classroom.js'; + +export { replaceProgress, saveProgress, toast, celebrate } from './wallet.js'; +export { openHostSetup } from './classroom.js'; +export { renderFactGrid, renderFactLegend, make } from './play.js'; +export { icon } from './wardrobe.js'; +export { startPractice } from './practice.js'; +export { drawQr } from './invites.js'; +export { dailyCard, dailyOpen } from './daily.js'; + +export const progress = () => state; +export const startHost = (h) => connectHost(h); +export const joinClass = (code) => openClassJoin(code, true); +export const menuShowing = () => !el.startScreen.hidden; diff --git a/apps/web/src/multiplication/game/base.js b/apps/web/src/multiplication/game/base.js new file mode 100644 index 0000000..a5124cc --- /dev/null +++ b/apps/web/src/multiplication/game/base.js @@ -0,0 +1,311 @@ +// Shared constants, elements and helpers: part of the multiplication game (split from the old js/game.js). + +import * as Settings from '@blockout/progress/settings'; +import * as Auth from '@blockout/auth'; +import * as Invite from '../invite.js'; + +let PROGRESS_KEY, el, ctx; + +const seededRandom = Invite.seededRandom; + +const DEFAULT_NAMES = ['Player 1', 'Player 2', 'Player 3', 'Player 4']; + +const PIPS = { + 1: [4], + 2: [0, 8], + 3: [0, 4, 8], + 4: [0, 2, 6, 8], + 5: [0, 2, 4, 6, 8], + 6: [0, 2, 3, 5, 6, 8], +}; + +// How long each computer action takes (ms): every explanation step, +// every square it draws and every number it counts. +const CPU_STEP_MS = Settings.CPU_STEP_MS; + +const COUNT_STEP = 850; // ms between skip-counting steps when helping a human + +const SETTINGS_KEY = 'blockout.settings'; + +const DEFAULT_SETTINGS = Settings.DEFAULT_SETTINGS; // (shared with the phone app: @blockout/progress/settings) + +// Board sizes other than 6×6 are unlocked by the Journey (levels.js). +const BOARD_UNLOCK = { 6: null, 8: 'board8', 10: 'board10', 12: 'board12', 16: 'board16', 20: 'board20', 24: 'board24', custom: 'boardCustom' }; + +const PASS_DELAY = 1600; + +const AUTO_PLACE_MS = 900; // how long an auto-placed rectangle is previewed + +const AUTO_ROLL_MS = 700; // pause before auto roll, so "Your turn" can be read first + +const TRY_AGAIN = ['Not quite — try again!', 'Almost! Have another go.', 'Hmm, not that one. Try again!']; + +const $ = (id) => document.getElementById(id); + +// types[i] is 'human' or 'cpu' for each multiplayer slot. +// names start empty: multiplayer players type their own ("Player N" is only a placeholder) +// multiKind: 'local' (one screen) or 'classroom' (not built yet) +// level/timer: single player's Learn / Expert and Expert's answer timer (seconds) +// play: single player's 'journey' (level by level) or 'free' (pick the board) +// practicePlay: Practice's 'journey' (a table a level) or 'free' (Auto: the facts you need) +const setup = { mode: 'single', play: 'journey', practicePlay: 'journey', multiKind: 'local', level: 'learn', timer: 0, count: 2, size: 6, names: ['', '', '', ''], types: ['human', 'human', 'human', 'human'] }; + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + // A signed-in player's save is kept apart from the guest one (see js/auth.js) + PROGRESS_KEY = Auth ? Auth.progressKey() : 'blockout.progress'; + + el = { + startScreen: $('start-screen'), + gameScreen: $('game-screen'), + modeCards: document.querySelectorAll('.mode-card'), + singleSetup: $('single-setup'), + multiSetup: $('multi-setup'), + multiKind: $('multi-kind'), + localSetup: $('local-setup'), + classroomSetup: $('classroom-setup'), + classJoinOpen: $('class-join-open'), + classHostOpen: $('class-host-open'), + classOffline: $('class-offline'), + classJoin: $('class-join'), + classJoinForm: $('class-join-form'), + classCodeInput: $('class-code-input'), + classNameInput: $('class-name-input'), + classJoinError: $('class-join-error'), + classJoinBtn: $('class-join-btn'), + classJoinCancel: $('class-join-cancel'), + classWait: $('class-wait'), + classWaitName: $('class-wait-name'), + classWaitText: $('class-wait-text'), + classLeaveBtn: $('class-leave-btn'), + hostSetup: $('class-host-setup'), + hostSetupTitle: $('class-host-title'), + hostSize: $('host-size'), + hostTypeField: $('host-type-field'), + hostType: $('host-type'), + hostTypeHelp: $('host-type-help'), + hostDifficulty: $('host-difficulty'), + hostRounds: $('host-rounds'), + hostError: $('host-error'), + hostCancel: $('host-cancel'), + hostCreate: $('host-create'), + hostScreen: $('class-host-screen'), + hostCodeSmall: $('host-code-small'), + hostLobby: $('host-lobby'), + hostQr: $('host-qr'), + hostUrl: $('host-url'), + hostCode: $('host-code'), + hostSettingsGrid: $('host-settings-grid'), + hostQuick: $('host-quick'), + hostStartBtn: $('host-start-btn'), + hostLocalHelp: $('host-local-help'), + hostPlay: $('host-play'), + hostRound: $('host-round'), + hostDieA: $('host-die-a'), + hostDieB: $('host-die-b'), + hostRollText: $('host-roll-text'), + hostDoneFill: $('host-done-fill'), + hostDoneText: $('host-done-text'), + hostNextBtn: $('host-next-btn'), + hostEndBtn: $('host-end-btn'), + hostAuto: $('host-auto'), + hostEnded: $('host-ended'), + hostPodium: $('host-podium'), + hostReport: $('host-report'), + hostHardest: $('host-hardest'), + hostAgainBtn: $('host-again-btn'), + hostClose2Btn: $('host-close2-btn'), + hostCloseBtn: $('host-close-btn'), + hostRosterTitle: $('host-roster-title'), + hostRoster: $('host-roster'), + hostPairs: $('host-pairs'), + hostMatching: $('host-matching'), + hostOdd: $('host-odd'), + hostPairsHelp: $('host-pairs-help'), + hostPairList: $('host-pair-list'), + hostTeacherDevice: $('host-teacher-device'), + hostTeacherQr: $('host-teacher-qr'), + hostTeacherUrl: $('host-teacher-url'), + hostTeacherStatus: $('host-teacher-status'), + hostMyGameBtn: $('host-mygame-btn'), + hostCopyLink: $('host-copy-link'), + hostCopyMsg: $('host-copy-msg'), + hostShareBtn: $('host-share-btn'), + hostCopyTeacher: $('host-copy-teacher'), + hostMatches: $('host-matches'), + hostDice: $('host-dice'), + hostProgress: $('host-progress'), + hostAutoLabel: $('host-auto-label'), + hostMatchResults: $('host-match-results'), + hostTournament: $('host-tournament'), + hostTournamentFinal: $('host-tournament-final'), + resultTournament: $('result-tournament'), + singleName: $('single-name'), + playerCount: $('player-count'), + nameInputs: $('name-inputs'), + namesError: $('names-error'), + boardSize: $('board-size'), + gameLevel: $('game-level'), + gameLevelHelp: $('game-level-help'), + gameTimerField: $('game-timer-field'), + gameTimer: $('game-timer'), + gameTimerHelp: $('game-timer-help'), + boardSizeHelp: $('board-size-help'), + journeyCard: $('journey-card'), + practiceCountField: $('practice-count-field'), + practiceLevelField: $('practice-level-field'), + pdJourney: $('pd-journey'), + playKind: $('play-kind'), + journeyInfo: $('journey-info'), + journeyLevel: $('journey-level'), + journeyLine: $('journey-line'), + journeyGoal: $('journey-goal'), + journeySteps: $('journey-steps'), + customSize: $('custom-size'), + customSizeInput: $('custom-size-input'), + customSizeEcho: $('custom-size-echo'), + setupForm: $('setup-form'), + menuBtn: $('menu-btn'), + progress: $('progress'), + progressText: $('progress-text'), + progressBar: $('progress-bar'), + boardWrap: $('board-wrap'), + canvas: $('board'), + turnPanel: $('turn-panel'), + turnLabel: $('turn-label'), + dieA: $('die-a'), + dieB: $('die-b'), + turnMsg: $('turn-msg'), + rollBtn: $('roll-btn'), + math: $('math'), + mathQ: $('math-q'), + answerBox: $('answer-box'), + countTrail: $('count-trail'), + mathFeedback: $('math-feedback'), + keypad: $('keypad'), + helpBtn: $('help-btn'), + steps: $('steps'), + continueBtn: $('continue-btn'), + timer: $('timer'), + timerFill: $('timer-fill'), + timerNum: $('timer-num'), + stats: $('stats'), + detailsBtn: $('details-btn'), + scores: $('scores'), + classmates: $('classmates'), + hostBoards: $('host-boards'), + gameOver: $('game-over'), + resultTitle: $('result-title'), + resultSub: $('result-sub'), + resultList: $('result-list'), + againBtn: $('again-btn'), + toMenuBtn: $('to-menu-btn'), + settingsBtn: $('settings-btn'), + historyBtn: $('history-btn'), + historyDialog: $('history'), + historyBody: $('history-body'), + historyDone: $('history-done'), + resetHistory: $('reset-history'), + settingsDialog: $('settings'), + walletAmount: $('wallet-amount'), + wardrobeBtn: $('wardrobe-btn'), + unlockDialog: $('unlock'), + unlockTitle: $('unlock-title'), + unlockPrice: $('unlock-price'), + unlockMessage: $('unlock-message'), + unlockYes: $('unlock-yes'), + unlockNo: $('unlock-no'), + wardrobeDialog: $('wardrobe'), + wardrobeBody: $('wardrobe-body'), + wardrobeDone: $('wardrobe-done'), + stickersBtn: $('stickers-btn'), + stickersDialog: $('stickers'), + stickersBody: $('stickers-body'), + stickersTrade: $('stickers-trade'), + stickersDone: $('stickers-done'), + gameSettingsBtn: $('game-settings-btn'), + practiceSetup: $('practice-setup'), + practiceName: $('practice-name'), + practiceCount: $('practice-count'), + practiceCountHelp: $('practice-count-help'), + practiceTimerField: $('practice-timer-field'), + practiceTimer: $('practice-timer'), + practiceTimerHelp: $('practice-timer-help'), + practicePreview: $('practice-preview'), + practiceLevel: $('practice-level'), + practiceLevelHelp: $('practice-level-help'), + difficultyField: $('difficulty-field'), + boardSizeField: $('board-size-field'), + startBtn: $('start-btn'), + practiceScreen: $('practice-screen'), + practiceMenuBtn: $('practice-menu-btn'), + practiceSettingsBtn: $('practice-settings-btn'), + prCounter: $('pr-counter'), + prStreak: $('pr-streak'), + streakBadge: $('streak-badge'), + prBar: $('pr-bar'), + prArray: $('pr-array'), + prTimer: $('pr-timer'), + prTimerFill: $('pr-timer-fill'), + prTimerNum: $('pr-timer-num'), + prQ: $('pr-q'), + prAnswer: $('pr-answer'), + prTrail: $('pr-trail'), + prFeedback: $('pr-feedback'), + prKeypad: $('pr-keypad'), + prHelp: $('pr-help'), + practiceDone: $('practice-done'), + pdSub: $('pd-sub'), + pdBody: $('pd-body'), + pdDetailsBtn: $('pd-details-btn'), + pdShopBtn: $('pd-shop-btn'), + resultsShopBtn: $('results-shop-btn'), + pdAgain: $('pd-again'), + pdMenu: $('pd-menu'), + settingsNote: $('settings-note'), + toasts: $('toasts'), + shopBtn: $('shop-btn'), + shopDialog: $('shop'), + shopBody: $('shop-body'), + shopDone: $('shop-done'), + achievementsBtn: $('achievements-btn'), + achievementsDialog: $('achievements'), + achievementsBody: $('achievements-body'), + achievementsDone: $('achievements-done'), + rewards: $('rewards'), + inviteBtn: $('invite-btn'), + inviteMake: $('invite-make'), + inviteCodeInput: $('invite-code-input'), + inviteCodeOpen: $('invite-code-open'), + inviteCodeError: $('invite-code-error'), + invitePlayers: $('invite-players'), + inviteNote: $('invite-note'), + inviteBoard: $('invite-board'), + inviteCustomField: $('invite-custom-field'), + inviteCustom: $('invite-custom'), + inviteSeed: $('invite-seed'), + inviteCreate: $('invite-create'), + inviteResult: $('invite-result'), + inviteLink: $('invite-link'), + inviteCode: $('invite-code'), + inviteLocalWarning: $('invite-local-warning'), + inviteTry: $('invite-try'), + inviteQrWrap: $('invite-qr-wrap'), + inviteQr: $('invite-qr'), + inviteQrSave: $('invite-qr-save'), + inviteQrPrint: $('invite-qr-print'), + inviteDone: $('invite-done'), + inviteOpen: $('invite-open'), + inviteOpenNote: $('invite-open-note'), + inviteOpenSummary: $('invite-open-summary'), + inviteOpenNameField: $('invite-open-name-field'), + inviteOpenName: $('invite-open-name'), + inviteStart: $('invite-start'), + inviteCancel: $('invite-cancel'), + settingsDone: $('settings-done'), + }; + + ctx = el.canvas.getContext('2d'); +} + +export { $, AUTO_PLACE_MS, AUTO_ROLL_MS, BOARD_UNLOCK, COUNT_STEP, CPU_STEP_MS, DEFAULT_NAMES, DEFAULT_SETTINGS, PASS_DELAY, PIPS, PROGRESS_KEY, SETTINGS_KEY, TRY_AGAIN, ctx, el, seededRandom, setup }; diff --git a/apps/web/src/multiplication/game/cheats.js b/apps/web/src/multiplication/game/cheats.js new file mode 100644 index 0000000..33ae50c --- /dev/null +++ b/apps/web/src/multiplication/game/cheats.js @@ -0,0 +1,116 @@ +// Cheat codes: part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import { $, el } from './base.js'; +import { celebrate, confettiFullScreen, progress, saveProgress, updateMenuBadges } from './wallet.js'; +import { saveSettings } from './settings.js'; +import { enforceLocks, renderBoardHint, renderGameLevel, renderWardrobe, syncBoardLocks, syncModeLocks, syncSettingsUI } from './wardrobe.js'; +import { game, make } from './play.js'; +import { refreshAvailableFilters, refreshOpenNudges, renderShop } from './shop.js'; +import { updatePracticePreview } from './practice.js'; + +let cheatsEl; + +const inClassGame = () => game && (game.mode === 'class' || game.mode === 'pair'); + +// Unlocks changed: bring every menu, list and lock badge up to date. +function refreshUnlocks() { + enforceLocks(); + saveSettings(); + saveProgress(); + refreshAvailableFilters(); + if (!el.shopDialog.hidden) renderShop(); + if (!el.wardrobeDialog.hidden) renderWardrobe(); + syncSettingsUI(); + syncBoardLocks(); + syncModeLocks(); + updatePracticePreview(); + renderGameLevel(); + renderBoardHint(); + refreshOpenNudges(); + updateMenuBadges(); +} + +function renderActiveCheats() { + const codes = Progress.activeCheats(progress); + cheatsEl.active.hidden = !codes.length; + cheatsEl.active.replaceChildren(); + if (!codes.length) return; + cheatsEl.active.append(make('h3', null, 'Active')); + for (const code of codes) { + const row = make('div', 'cheat-row'); + const off = make('button', 'btn btn-small', 'Turn off'); + off.type = 'button'; + off.addEventListener('click', () => { + Progress.cheatOff(progress, code); + refreshUnlocks(); + renderActiveCheats(); + cheatsEl.msg.className = 'cheats-msg'; + cheatsEl.msg.textContent = 'Cheat turned off.'; + }); + row.append(make('span', null, `✨ ${Progress.CHEAT_CODES[code].name}`), off); + cheatsEl.active.append(row); + } +} + +function openCheats() { + cheatsEl.input.value = ''; + cheatsEl.input.classList.remove('wrong'); + cheatsEl.msg.className = 'cheats-msg'; + cheatsEl.msg.textContent = inClassGame() ? 'Cheats are switched off in class games. Nice try! 😄' : ''; + renderActiveCheats(); + cheatsEl.box.hidden = false; + cheatsEl.input.focus(); +} + +const closeCheats = () => (cheatsEl.box.hidden = true); + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + // Press ~ anywhere (outside a text box) to open the cheat codes box. + + // code (lower case) -> { name, run() }. None yet. + // Codes live in Progress.CHEAT_CODES. The menu lists the ones that are on by + // what they do (never the code itself), each with a Turn off button. + cheatsEl = { box: $('cheats'), form: $('cheats-form'), input: $('cheats-input'), msg: $('cheats-msg'), close: $('cheats-close'), active: $('cheats-active') }; + + document.addEventListener('keydown', (e) => { + if (e.key !== '~' && e.key !== '`') return; + if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select')) return; + if ([...document.querySelectorAll('.overlay')].some((o) => !o.hidden)) return; // not on top of another dialog + e.preventDefault(); + openCheats(); + }); + + cheatsEl.close.addEventListener('click', closeCheats); + + cheatsEl.box.addEventListener('click', (e) => e.target === cheatsEl.box && closeCheats()); + + cheatsEl.box.addEventListener('keydown', (e) => e.key === 'Escape' && closeCheats()); + + cheatsEl.form.addEventListener('submit', (e) => { + e.preventDefault(); + const code = cheatsEl.input.value.trim().toLowerCase(); + if (!code || inClassGame()) return; + cheatsEl.input.classList.remove('wrong'); + cheatsEl.input.value = ''; + if (Progress.CHEAT_CODES[code]) { + const on = Progress.cheatOn(progress, code); + if (on) { + confettiFullScreen(3); + celebrate(Progress.awardAchievements(progress, { event: 'cheat', code })); // Cheater, plus this code's secret + refreshUnlocks(); + } + renderActiveCheats(); + cheatsEl.msg.className = 'cheats-msg good'; + cheatsEl.msg.textContent = on ? `✨ ${Progress.CHEAT_CODES[code].name}` : 'That one is already on.'; + return; + } + void cheatsEl.input.offsetWidth; // restart the shake + cheatsEl.input.classList.add('wrong'); + cheatsEl.msg.className = 'cheats-msg'; + cheatsEl.msg.textContent = 'Nothing happened…'; + }); +} + +export { refreshUnlocks }; diff --git a/apps/web/src/multiplication/game/checkin.js b/apps/web/src/multiplication/game/checkin.js new file mode 100644 index 0000000..b0d1665 --- /dev/null +++ b/apps/web/src/multiplication/game/checkin.js @@ -0,0 +1,19 @@ +// Daily check-in: part of the multiplication game (split from the old js/game.js). + +import * as Views from '@blockout/progress/views'; +import { celebrate, progress, renderWallet, saveProgress, toast } from './wallet.js'; +import { checkUrlForInvite } from './invites.js'; + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + renderWallet(); + + const visit = Views.dailyCheckIn(progress); + if (visit) { + toast(...visit.toast); + celebrate(visit.earned); + saveProgress(); + } + + checkUrlForInvite(); +} diff --git a/apps/web/src/multiplication/game/classroom.js b/apps/web/src/multiplication/game/classroom.js new file mode 100644 index 0000000..d0391d7 --- /dev/null +++ b/apps/web/src/multiplication/game/classroom.js @@ -0,0 +1,1244 @@ +// Classroom: part of the multiplication game (split from the old js/game.js). + +import * as Auth from '@blockout/auth'; +import { askTeacherSignIn } from '../accounts.js'; +import * as Classroom from '@blockout/classroom'; +import * as Core from '@blockout/engine'; +import * as Progress from '@blockout/progress'; +import * as Stats from '@blockout/progress/stats'; +import * as Live from '@blockout/progress/classroom'; +import * as Hosting from '@blockout/progress/hosting'; +import { AUTO_ROLL_MS, el } from './base.js'; +import { celebrate, celebrateWin, grantStickers, progress, saveProgress, toast } from './wallet.js'; +import { settings } from './settings.js'; +import { icon, syncBoardLocks } from './wardrobe.js'; +import { currentPlayer, game, gameId, humanRoll, make, renderStats, resetTurn, rollDice, setGame, setGameId, showRewards, startGame, startPlacing, statsName, stopTimer, waitInGame } from './play.js'; +import { loadHistory, saveHistory } from './stats.js'; +import { refreshNudges, tap } from './shop.js'; +import { drawQr } from './invites.js'; +import { dragRect, setDetailsOpen, toMenu } from './input.js'; +import { render, renderDie, resizeBoard, setMsg } from './render.js'; + +const HOST_SETTINGS = Hosting.HOST_SETTINGS; // the lobby's settings ([value, label, detail]; `only`: some game types) + +// Your Wardrobe look, sent when you join so classmates see your avatar and title +const myLook = () => ({ avatar: settings.cosmetics.avatar, title: settings.cosmetics.title }); + +// A player's name with their avatar and title (from the server) +function lookName(p, cls, text = p.name) { + const span = make('span', cls, p.avatar ? `${p.avatar} ${text}` : text); + if (p.title) span.append(make('span', 'player-title', p.title)); + return span; +} + +// A small picture of a board: rects are [x, y, w, h] +function miniBoard(rects, size, color, px = 84) { + const c = document.createElement('canvas'); + const dpr = window.devicePixelRatio || 1; + c.width = c.height = px * dpr; + c.style.width = c.style.height = `${px}px`; + c.className = 'mini-board'; + const g = c.getContext('2d'); + g.scale(dpr, dpr); + const cell = px / size; + const css = getComputedStyle(document.documentElement); + g.fillStyle = css.getPropertyValue('--surface').trim() || '#fff'; + g.fillRect(0, 0, px, px); + g.fillStyle = color; + g.strokeStyle = css.getPropertyValue('--paper').trim() || '#fff'; + g.lineWidth = 1; + for (const [x, y, w, h] of rects) { + g.globalAlpha = 0.85; + g.fillRect(x * cell, y * cell, w * cell, h * cell); + g.globalAlpha = 1; + g.strokeRect(x * cell + 0.5, y * cell + 0.5, w * cell - 1, h * cell - 1); + } + return c; +} + +// Whole class: classmates near you on the leaderboard, with their boards (live as they place) +function renderClassmates(view) { + const box = el.classmates; + if (!box) return; + const list = view.classmates || []; + box.hidden = !list.length; + if (!list.length) return; + box.replaceChildren(make('h3', 'classmates-title', 'Classmates')); + const grid = make('div', 'classmates-grid'); + for (const c of list) { + const card = make('div', 'classmate' + (c.done ? ' done' : '')); + card.append(miniBoard(c.rects, view.settings.size, '#2e86de', 64)); + const who = make('div', 'classmate-who'); + who.append(lookName(c, 'classmate-name'), make('span', 'classmate-score', `${ordinal(c.rank)} · ${c.score}`)); + card.append(who); + grid.append(card); + } + box.append(grid); +} + + +const ordinal = Stats.ordinal; + +// Can this page reach the classroom server? Shown in the Classroom menu. +async function checkClassroom() { + const reason = await Classroom.check(); + el.classOffline.hidden = !reason; + el.classJoinOpen.disabled = el.classHostOpen.disabled = Boolean(reason); + if (reason) { + el.classOffline.innerHTML = + reason === 'file' + ? 'Classroom games run through the Blockout server. On the teacher’s computer run node server/classroom.js, then open the address it shows (like http://localhost:8080).' + : 'Can’t reach the classroom server. Run node server/classroom.js and open this page from the address it prints (like http://localhost:8080), not from another web server.'; + } + return !reason; +} + +// ---- students + +let classSession = null; // { code, id, key, name } + +let classStop = null; // closes the live updates + +let classSeen = false; // got at least one update since connecting + +// The first name used in the last class on this device, to fill in next time +const CLASS_NAME_KEY = 'blockout.className'; + +function lastClassName() { + try { + return localStorage.getItem(CLASS_NAME_KEY) || ''; + } catch (e) { + return ''; + } +} + +// fromLink: opened from a join link (QR code or a link posted in the class chat) +async function openClassJoin(code = '', fromLink = false) { + el.classCodeInput.value = code || ''; + // Signed-in students play under their account's first name + const account = Auth && Auth.role() === 'student' ? Auth.user() : null; + el.classNameInput.value = account ? account.firstName : lastClassName(); + el.classNameInput.readOnly = Boolean(account); + el.classJoinError.hidden = true; + el.classJoinForm.hidden = false; + el.classWait.hidden = true; + el.classJoin.hidden = false; + // With the code and your name filled in, joining is one tap + (!code ? el.classCodeInput : el.classNameInput.value ? el.classJoinBtn : el.classNameInput).focus(); + if (fromLink && code) { + try { + await Classroom.roomInfo(code); + } catch (err) { + if (err.code !== 'room') return; + el.classJoinError.textContent = 'This class has finished. Ask your teacher for a new link.'; + el.classJoinError.hidden = false; + el.classCodeInput.value = ''; + el.classCodeInput.focus(); + } + } +} + +function connectClass(session) { + if (classStop) classStop(); + classSession = session; + classSeen = false; + Classroom.saveSession(session); + classStop = Classroom.listenStudent(session, { + view: (v) => { + classSeen = true; + onClassView(v); + }, + gone: onClassGone, + }); +} + +function leaveClass() { + if (!classSession) return; + Classroom.leave(classSession).catch(() => {}); + if (classStop) classStop(); + classStop = null; + classSession = null; + Classroom.saveSession(null); +} + +function onClassGone(reason) { + const wasIn = classSeen; + const wasHost = classSession && classSession.host; + classStop = null; + classSession = null; + Classroom.saveSession(null); + el.classJoin.hidden = true; + if (wasHost) { + // the teacher's own player on the projector page: the host screen handles the rest + if (game && game.mode === 'pair') setGame(null); + return; + } + if (game && (game.mode === 'class' || game.mode === 'pair') && game.phase !== 'over') toMenu(); + const said = Live.goneToast(reason, wasIn); // (null: an old class from last time, just forgotten) + if (said) toast(...said); +} + +function showClassWaiting(view) { + el.classWaitName.textContent = classSession.name; + el.classWaitText.textContent = Live.waitingText(view, classSession); + el.classJoinForm.hidden = true; + el.classWait.hidden = false; + el.classJoin.hidden = false; +} + +function onClassView(view) { + if (view.type === 'pairs' || view.type === 'tournament') return onPairView(view); + if (view.you.benched) return showClassWaiting(view); // joined after this game started + const mine = game && game.mode === 'class' && game.classroom.game === view.game; + if (view.state === 'playing') { + if (!mine) startClassGame(view); + else syncClassGame(view); + return; + } + if (view.state === 'ended' && mine) { + if (game.phase !== 'over') finishClassGame(view); + return; + } + // Lobby (or a game that finished before we got here): wait for the next one. + // Results stay on screen until the next game starts. + if (game && game.mode === 'class' && game.phase === 'over') return; + if (game && game.mode === 'class') toMenu(); + showClassWaiting(view); +} + +// The teacher's settings for everyone in the class (Live.classCfg); placing is +// the student's own choice unless the teacher picked one. +const classCfg = (s) => Live.classCfg(s, settings.placeMode); + +function startClassGame(view) { + el.classJoin.hidden = true; + for (const d of document.querySelectorAll('.overlay')) if (d !== el.gameOver) d.hidden = true; + const you = view.you; + const { size, difficulty } = view.settings; + startGame([{ name: you.name, statsName: el.singleName.value.trim() || 'You', cpu: false }], size, 'class', null, { + cfg: { ...classCfg(view.settings), difficulty, autoRoll: 'off' }, // the teacher rolls + classroom: { session: classSession, game: view.game, round: 0, reported: true, view }, + }); + // Rejoining mid-game: put your rectangles back and pick up your score. + if (Live.rejoinClass(game, you)) toast(`🏫 Back in class ${view.code}`, `You have ${you.score} points.`); + syncClassGame(view); +} + +// A new roll from the teacher. If you were still working on the last one, it's gone. +function syncClassGame(view) { + const c = game.classroom; + c.view = view; + renderClassmates(view); + if (view.round > c.round) { + if (!['wait', 'over'].includes(game.phase)) { + setGameId(gameId + 1); // stop whatever the last roll was still doing + if (!c.reported) { + Live.skipUnfinished(game); + toast('⏭️ Next roll!', 'Your teacher moved on before you finished that one.'); + } + } + resetTurn(); + c.round = view.round; + const [a, b] = view.roll; + if (view.you.done) { + // No room for this roll (the server checks), or you'd already answered it + c.reported = true; + game.dice = [a, b]; + renderDie(el.dieA, a); + renderDie(el.dieB, b); + game.phase = 'wait'; + if (view.you.result === 'pass') { + currentPlayer().log.push({ pass: true, a, b }); + setMsg(`No room for a ${a} × ${b} anywhere. You pass this time.`); + } else setMsg('Waiting for the next roll…'); + } else { + c.reported = false; + classTurn(a, b); + } + } + render(); +} + +async function classTurn(a, b) { + setMsg(''); + await rollDice([a, b]); + await startPlacing(a, b); +} + +// Tell the server how this roll went (once per roll). +function reportClass(result) { + const c = game.classroom; + if (!c || c.reported) return; + c.reported = true; + c.doneTurn = c.round; + Classroom.sendResult(c.session, { round: c.round, ...result }).catch((err) => { + if (err.code !== 'stale' && err.code !== 'done') toast('⚠️ Couldn’t send your answer', err.message); + }); +} + +// ---- pairs: head to head on a shared board, taking turns + +function onPairView(view) { + const host = classSession.host; // the teacher playing from the projector screen + // (a tournament plays many games: each has its own match id) + const sameGame = game && game.mode === 'pair' && game.classroom.game === view.game; + const mine = sameGame && (!view.match || game.classroom.matchId === view.match.id); + if (view.type === 'tournament') tournamentUpdate(view, sameGame); + if (view.match && view.state !== 'lobby') { + if (!mine) { + if (view.match.over) return host ? updateMyGameButton() : showClassWaiting(view); // joined after it finished + startPairGame(view); + } else syncPairGame(view); + return; + } + if (sameGame && game.phase === 'over') return; // results stay up until the next game starts + if (game && game.mode === 'pair') { + if (host) setGame(null); + else toMenu(); + } + if (host) return updateMyGameButton(); + showClassWaiting(view); +} + +function startPairGame(view) { + const m = view.match; + const me = m.players.findIndex((p) => p.id === view.you.id); + if (!classSession.host) { + el.classJoin.hidden = true; + for (const d of document.querySelectorAll('.overlay')) if (d !== el.gameOver) d.hidden = true; + } + const defs = m.players.map((p, i) => ({ + name: p.name, + cpu: p.isCpu, + avatar: p.avatar, // your partner's look (yours comes from your Wardrobe) + title: p.title, + ...(i === me ? { statsName: el.singleName.value.trim() || 'You' } : {}), + })); + const { size, difficulty } = view.settings; + startGame(defs, size, 'pair', null, { + cfg: { ...classCfg(view.settings), difficulty, autoRoll: view.settings.autoRoll ? 'auto' : 'off' }, + classroom: { session: classSession, game: view.game, matchId: m.id, me, round: 0, reported: true, doneTurn: 0, synced: 0, passSeen: 0, view }, + }); + if (classSession.host && !hostOnGame) showHostView(); // the projector keeps showing the class + syncPairGame(view); +} + +const opponent = () => game.players[1 - game.classroom.me]; + +// Bring the shared board and the turn up to date with the server. +function syncPairGame(view) { + if (game.phase === 'over') return; // finished (and rewarded) once already + const c = game.classroom; + const m = view.match; + c.view = view; + const note = Live.syncPair(game, m, c.me, c); + if (m.over) return finishPairGame(view); + const myTurn = m.current === c.me; + const newTurn = m.turn !== c.round; + if (newTurn) c.round = m.turn; + if (newTurn && myTurn && ['wait', 'roll'].includes(game.phase)) startPairTurn(note); + // your partner's move as they make it: dragging, then answering + game.partnerLive = !myTurn && m.live && m.live.by !== game.classroom.view.you.id ? m.live : null; + if (!myTurn && game.phase === 'wait') { + if (m.roll) { + if (c.rollShown !== m.turn) { + c.rollShown = m.turn; + animatePartnerRoll(m.roll); + } + setMsg(Live.partnerLine(opponent().name, m.roll, game.partnerLive)); + } else if (newTurn || note) { + renderDie(el.dieA, null); + renderDie(el.dieB, null); + setMsg(`${note} ${opponent().name}'s turn…`.trim()); + } + } + render(); + updateMyGameButton(); +} + +// Your partner rolled: tumble the dice for a moment, like your own roll +async function animatePartnerRoll([a, b]) { + const my = gameId; + const sides = game.sides; + for (let i = 0; i < 7; i++) { + renderDie(el.dieA, 1 + Math.floor(Math.random() * sides)); + renderDie(el.dieB, 1 + Math.floor(Math.random() * sides)); + await waitInGame(55); + if (my !== gameId || !game) return; + } + renderDie(el.dieA, a); + renderDie(el.dieB, b); +} + +// Your turn in pairs: tell your partner what you're doing (the rectangle you're +// dragging, then the one you're answering), when it changes, a few +// times a second at most. Called from render(). +const LIVE_EVERY_MS = 120; +let liveSent = ''; +let liveTimer = null; +function pairLiveTick() { + if (!game || game.mode !== 'pair' || game.phase === 'over') return; + const c = game.classroom; + if (game.current !== c.me || !['place', 'answer'].includes(game.phase)) return; + const rect = game.phase === 'answer' ? game.pending && game.pending.rect : game.drag ? dragRect() : null; + const payload = { phase: game.phase === 'answer' ? 'answer' : 'place', rect: rect ? { x: rect.x, y: rect.y, w: rect.w, h: rect.h } : null }; + const key = JSON.stringify(payload); + if (key === liveSent) return; + liveSent = key; + if (liveTimer) return; // the latest goes out when the timer fires + const send = () => { + liveTimer = null; + if (!game || game.mode !== 'pair' || game.classroom !== c) return; + Classroom.live(c.session, JSON.parse(liveSent)).catch(() => {}); // only a preview: never mind if it misses + }; + send(); + liveTimer = setTimeout(send, LIVE_EVERY_MS); +} + +function startPairTurn(note = '') { + resetTurn(); + game.classroom.reported = false; + game.phase = 'roll'; + setMsg(`${note} ${game.autoRoll ? 'Your turn! Rolling…' : 'Your turn: roll the dice!'}`.trim()); + render(); + if (classSession.host && !hostOnGame) toast('🎲 Your turn', `in your game with ${opponent().name}`); + if (game.autoRoll) waitInGame(AUTO_ROLL_MS).then(humanRoll); +} + +// After your turn (or at the start): your turn again, or wait for your partner. +function pairTurnOrWait() { + const c = game.classroom; + const m = c.view && c.view.match; + if (m && !m.over && m.current === c.me && m.turn === c.round && m.turn !== c.doneTurn) return startPairTurn(); + game.phase = 'wait'; + setMsg(m && m.current !== c.me ? `${opponent().name}'s turn…` : 'Waiting…'); + render(); +} + +async function pairRoll() { + const c = game.classroom; + game.phase = 'rolling'; + render(); + let res; + try { + res = await Classroom.roll(c.session); + } catch (err) { + if (game && game.classroom === c) { + game.phase = 'roll'; + render(); + } + if (err.code !== 'turn' && err.code !== 'stale') toast('⚠️ Couldn’t roll', err.message); + return; + } + if (!game || game.classroom !== c) return; + const [a, b] = await rollDice(res.roll); + if (res.passed) { + c.reported = true; + c.doneTurn = c.round; + game.phase = 'wait'; + setMsg(`No room for a ${a} × ${b} anywhere. You pass.`); + render(); + return; + } + await startPlacing(a, b); +} + +function finishPairGame(view) { + setGameId(gameId + 1); + stopTimer(); + const c = game.classroom; + const m = view.match; + game.phase = 'over'; + game.dice = null; + game.pending = null; + el.math.hidden = true; + setMsg(''); + render(); + updateMyGameButton(); + const me = m.players[c.me]; + const opp = m.players[1 - c.me]; + const result = Live.pairResult(view, c.me, Core.emptyCount(game.board)); + el.resultTitle.textContent = result.title; + el.resultSub.textContent = result.sub; + el.resultList.innerHTML = ''; + const top = Math.max(1, me.score, opp.score); + for (const p of result.rows) { + const li = make('li', p.you ? 'result-you' : ''); + li.style.setProperty('--c', game.players[p.index].color); + const bar = make('span', 'result-bar'); + const fill = make('span'); + fill.style.width = `${(p.score / top) * 100}%`; + bar.append(fill); + li.append(lookName(p, 'result-name'), make('span', 'result-pts', `${p.score} pts`), bar); + el.resultList.append(li); + } + el.rewards.innerHTML = ''; + if (c.session.teacher) el.rewards.append(make('p', 'setting-help', 'Thanks for playing! Teacher games don’t count towards stats.')); + else if (view.type === 'tournament') { + // each tournament game earns its points; the tournament's own rewards come at the end + Live.tournamentGamePoints(progress, view, c.me); + saveProgress(); + showRewards([]); + } else renderPairRewards(view); + tournamentUpdate(view, true); + if (result.won) celebrateWin(); + renderStats(); + setDetailsOpen(false); + if (c.session.host && !hostOnGame) { + toast('🏁 Your game is over', `${me.score} – ${opp.score} against ${opp.name}`); + return; // the projector keeps showing the class; the results wait in "My game" + } + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; +} + +function renderPairRewards(view) { + const c = game.classroom; + const p = game.players[c.me]; + const { earned, entry } = Live.pairRewards(progress, view, c.me, p, { startedAt: game.startedAt, statsName: statsName(p) }); + grantStickers(earned); // they show up behind the Stickers button + saveProgress(); + saveHistory([...loadHistory(), entry]); + showRewards(earned); + el.rewards.append(make('p', 'setting-help', 'Stay on this screen: your teacher can start another game.')); +} + +// ---- tournaments (students): where you stand between games, and the rewards at the end + +const tournamentLine = Live.tournamentLine; + +const tournamentRewarded = new Set(); // `${code}:${game}` once its rewards are given + +// Every update: keep the line under your last result up to date, and give the +// tournament's rewards once it's over. +function tournamentUpdate(view, sameGame) { + const t = view.tournament; + if (!t) return; + if (sameGame && game.phase === 'over') { + el.resultTournament.textContent = tournamentLine(t); + el.resultTournament.hidden = false; + } + if (!el.classWait.hidden && !el.classJoin.hidden) el.classWaitText.textContent = tournamentLine(t); + if (classSession.teacher) return; + const done = Live.tournamentRewards(progress, view, classSession.name, tournamentRewarded); + if (!done) return; + celebrate(done.earned); + saveProgress(); + saveHistory([...loadHistory(), done.entry]); + if (done.champion) celebrateWin(); +} + +function finishClassGame(view) { + setGameId(gameId + 1); + stopTimer(); + const you = view.you; + const p = game.players[0]; + game.classroom.view = view; + game.phase = 'over'; + game.dice = null; + game.pending = null; + el.math.hidden = true; + setMsg(''); + Object.assign(p, { score: you.score, squares: you.squares, bonus: you.bonus }); + render(); + const result = Live.classResult(view); + el.resultTitle.textContent = result.title; + el.resultSub.textContent = result.sub; + el.resultList.innerHTML = ''; + const top = Math.max(1, ...result.rows.filter((r) => !r.gap).map((r) => r.score)); + for (const r of result.rows) { + const li = make('li', r.gap ? 'result-gap' : r.you ? 'result-you' : ''); + if (r.gap) { + li.textContent = '⋯'; + el.resultList.append(li); + continue; + } + li.style.setProperty('--c', r.you ? p.color : 'var(--muted)'); + const bar = make('span', 'result-bar'); + const fill = make('span'); + fill.style.width = `${(r.score / top) * 100}%`; + bar.append(fill); + li.append(lookName(r, 'result-name', `${ordinal(r.rank)} ${r.name}`), make('span', 'result-pts', `${r.score} pts`), bar); + el.resultList.append(li); + } + renderClassRewards(view); + refreshNudges('results'); + if (result.celebrate) celebrateWin(); + renderStats(); + setDetailsOpen(false); + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; +} + +function renderClassRewards(view) { + const p = game.players[0]; + el.rewards.innerHTML = ''; + const { earned, entry } = Live.classRewards(progress, view, p, { startedAt: game.startedAt, statsName: statsName(p) }); + grantStickers(earned); // they show up behind the Stickers button + saveProgress(); + saveHistory([...loadHistory(), entry]); + showRewards(earned); + el.rewards.append(make('p', 'setting-help', 'Stay on this screen: your teacher can start another game.')); +} + +// ---- teacher + +let host = null; // { code, teacherKey, lan } + +let hostStop = null; + +let hostView = null; + +function segValue(group) { + return group.querySelector('.selected').dataset.value; +} + +function setSeg(group, value) { + for (const b of group.querySelectorAll('button')) b.classList.toggle('selected', b.dataset.value === String(value)); +} + +// Game type: the whole class on one leaderboard, or head to head in pairs. +const HOST_TYPE_HELP = Hosting.HOST_TYPE_HELP; + +function renderHostType() { + const type = segValue(el.hostType); + for (const b of el.hostType.querySelectorAll('button')) b.setAttribute('aria-checked', String(b.dataset.value === type)); + el.hostTypeHelp.textContent = HOST_TYPE_HELP[type]; + el.hostRounds.closest('.field').hidden = type !== 'class'; // pairs and tournament games run by turns + setSeg(el.hostSize, Hosting.setupSize(type, Number(segValue(el.hostSize)))); // (a tournament is many short games: a small board) +} + +// Creating a class. Everything here (and more) can be changed in the lobby too. +function openHostSetup() { + // Hosting a class needs a teacher account (the server checks too) + if (Auth && Auth.served() && Auth.role() !== 'teacher') return askTeacherSignIn(); + renderHostType(); + el.hostError.hidden = true; + el.hostSetup.hidden = false; + el.hostCreate.focus(); +} + +function connectHost(h) { + if (hostStop) hostStop(); + host = h; + hostView = null; + Classroom.saveHost(h); + el.startScreen.hidden = true; + el.gameScreen.hidden = true; + el.hostScreen.hidden = false; + el.hostCodeSmall.textContent = h.code; + hostStop = Classroom.listenTeacher(h, { view: renderHost, gone: hostGone }); +} + +function hostGone(reason) { + const had = Boolean(hostView); + if (classSession && classSession.host) { + if (classStop) classStop(); + classStop = null; + classSession = null; + Classroom.saveSession(null); + if (game && game.mode === 'pair') setGame(null); + } + hostOnGame = false; + el.gameScreen.hidden = true; + el.gameOver.hidden = true; + hostStop = null; + host = null; + hostView = null; + Classroom.saveHost(null); + el.hostScreen.hidden = true; + el.startScreen.hidden = false; + if (had && reason !== 'closed') toast('⚠️ Lost the class', 'The classroom server may have restarted.'); +} + +async function hostAction(action, extra) { + try { + await Classroom.teacher(host, action, extra); + } catch (err) { + toast('⚠️ ' + err.message); + } +} + +// The address to show on the board. localhost only works on this computer, +// so use the network address the server found instead. +function joinUrl(code) { + const local = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname); + const base = local && host.lan && host.lan.length ? host.lan[0] : `${location.origin}${location.pathname}`; + return { base, full: `${base}#class=${code}`, local: local && !(host.lan && host.lan.length) }; +} + +// ---- teacher: links to post in the class chat + +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + // Older browsers or no permission: copy from a hidden text box + const box = document.createElement('textarea'); + box.value = text; + box.setAttribute('readonly', ''); + box.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; + document.body.append(box); + box.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch (err) {} + box.remove(); + return ok; + } +} + +async function copyWithFeedback(btn, text) { + const label = btn.innerHTML; + const ok = await copyText(text); + btn.classList.add('copied'); + btn.textContent = ok ? '✓ Copied!' : 'Couldn’t copy'; + setTimeout(() => { + btn.classList.remove('copied'); + btn.innerHTML = label; + }, 1600); +} + +const joinMessage = (code) => Hosting.joinMessage(joinUrl(code).full, code); + +function renderHost(view) { + const first = !hostView; + hostView = view; + const s = view.settings; + el.hostLobby.hidden = view.state !== 'lobby'; + el.hostPlay.hidden = view.state !== 'playing'; + el.hostEnded.hidden = view.state !== 'ended'; + + if (view.state === 'lobby') { + const url = joinUrl(view.code); + el.hostCode.textContent = view.code; + el.hostUrl.textContent = url.base.replace(/^https?:\/\//, '').replace(/\/$/, ''); + el.hostLocalHelp.hidden = !url.local; + if (first || el.hostQr.dataset.url !== url.full) { + drawQr(el.hostQr, url.full); + el.hostQr.dataset.url = url.full; + } + const pairs = view.type === 'pairs'; + renderHostSettings(view); + el.hostPairs.hidden = !pairs; + const start = Hosting.startButton(view); + el.hostStartBtn.disabled = start.disabled; + el.hostStartBtn.textContent = start.text; + if (pairs) renderHostPairsLobby(view); + } else if (view.state === 'playing' && view.type === 'tournament') { + renderHostMatches(view, el.hostMatches); + renderTournament(view.tournament, el.hostTournament); + el.hostRound.textContent = Hosting.roundLabel(view); + const next = Hosting.nextButton(view); + if (next) { + el.hostNextBtn.replaceChildren(icon('chevron-right'), ` ${next.text}`); + el.hostNextBtn.disabled = next.disabled; + } + el.hostAuto.checked = view.autoNext; + } else if (view.state === 'playing' && view.type === 'pairs') { + renderHostMatches(view, el.hostMatches); + el.hostRound.textContent = Hosting.roundLabel(view); + } else if (view.state === 'playing') { + const [a, b] = view.roll; + const sides = Core.DIFFICULTY_SIDES[s.difficulty]; + el.hostRound.textContent = Hosting.roundLabel(view); + renderDie(el.hostDieA, a, sides); + renderDie(el.hostDieB, b, sides); + el.hostRollText.textContent = `Draw a ${a} by ${b} rectangle`; + el.hostDoneFill.style.width = `${view.here ? (view.done / view.here) * 100 : 0}%`; + el.hostDoneText.textContent = `${view.done} of ${view.here} done`; + const next = Hosting.nextButton(view); + el.hostNextBtn.replaceChildren(icon(next.finish ? 'flag' : 'dices'), ` ${next.text}`); + el.hostAuto.checked = view.autoNext; + renderHostBoards(view); + } else { + renderHostResults(view); + } + // whole class: dice and pacing; pairs: one card per pair; tournament: the bracket too + const pairsPlaying = view.type !== 'class'; + const tourney = view.type === 'tournament'; + for (const node of [el.hostDice, el.hostRollText, el.hostProgress]) node.hidden = pairsPlaying; + el.hostNextBtn.hidden = el.hostAutoLabel.hidden = !Hosting.nextButton(view); + el.hostAutoLabel.lastChild.textContent = ` ${Hosting.autoLabel(view)}`; + el.hostMatches.hidden = !pairsPlaying; + el.hostBoards.hidden = view.type !== 'class' || view.state !== 'playing'; + el.hostTournament.hidden = !tourney || view.state !== 'playing'; + el.hostPodium.hidden = view.type === 'pairs'; + el.hostMatchResults.hidden = view.type !== 'pairs'; + el.hostTournamentFinal.hidden = !tourney || view.state !== 'ended'; + renderHostRoster(view); + updateMyGameButton(); +} + +// "Make it quick": everything that saves time +const QUICK_SETTINGS = Hosting.QUICK_SETTINGS; + +function renderHostSettings(view) { + const grid = el.hostSettingsGrid; + if (!grid.children.length) { + for (const def of HOST_SETTINGS) { + const field = make('div', 'field' + (def.wide ? ' wide' : '')); + field.append(make('span', null, def.label)); + const seg = make('div', 'segmented'); + seg.dataset.key = def.key; + for (const [value, label, detail] of def.options) { + const b = make('button', null, label); + b.type = 'button'; + b.dataset.value = JSON.stringify(value); + if (detail) b.append(' ', make('small', null, detail)); + seg.append(b); + } + field.append(seg); + grid.append(field); + } + } + HOST_SETTINGS.forEach((def, i) => { + grid.children[i].hidden = !Hosting.settingShown(def, view); + }); + for (const seg of grid.querySelectorAll('.segmented')) { + const current = JSON.stringify(view.settings[seg.dataset.key]); + for (const b of seg.querySelectorAll('button')) b.classList.toggle('selected', b.dataset.value === current); + } + el.hostQuick.disabled = Hosting.isQuick(view); +} + +// ---- teacher: tournaments on the projector + +const TOURNAMENT_NAMES = Stats.TOURNAMENT_NAMES; + +// Knockouts: the bracket, a column per round. Other formats: the standings +// table (and, for king of the hill, each hill's king and line). +function renderTournament(t, box) { + box.innerHTML = ''; + if (t.format === 'koth') { + const hills = make('div', 'koth-hills'); + for (const h of t.hills) { + const card = make('div', 'koth-hill'); + card.append(make('div', 'koth-title', t.hills.length > 1 ? `Hill ${h.id}` : 'The hill')); + card.append(make('div', 'koth-king', h.king ? `👑 ${h.king}` : '👑 …')); + if (h.streak > 1) card.append(make('div', 'koth-streak', `🔥 ${h.streak} wins in a row`)); + card.append(make('div', 'koth-queue', h.queue.length ? `Next: ${h.queue.join(', ')}` : '')); + card.append(make('div', 'koth-count', `${h.played} of ${h.limit} games`)); + hills.append(card); + } + box.append(hills); + } + if (t.format === 'knockout' || t.format === 'double') { + const cols = make('div', 'bracket'); + for (const r of t.rounds) { + const col = make('div', 'bracket-round'); + col.append(make('div', 'bracket-label', r.label)); + for (const m of r.matches) { + const game = make('div', 'bracket-match'); + for (const name of [m.a, m.b]) game.append(make('div', 'bracket-name' + (name === m.winner ? ' winner' : ' loser'), name)); + col.append(game); + } + for (const name of r.byes) col.append(make('div', 'bracket-bye', `${name}: bye`)); + cols.append(col); + } + box.append(cols); + } + if (t.format !== 'knockout') { + const table = make('table', 'standings'); + const head = table.createTHead().insertRow(); + for (const h of ['', 'Name', 'W', 'L', t.format === 'koth' ? 'Best streak' : 'Points']) head.append(make('th', null, h)); + const body = table.createTBody(); + for (const x of t.standings) { + const tr = body.insertRow(); + if (x.out) tr.className = 'out'; + tr.append(make('td', null, ordinal(x.place)), make('td', null, x.name), make('td', null, String(x.wins)), make('td', null, String(x.losses)), make('td', null, String(t.format === 'koth' ? x.bestStreak : x.points))); + } + box.append(table); + } +} + +function renderPodium(view) { + el.hostPodium.innerHTML = ''; + for (const x of Hosting.podium(view)) { + const li = make('li', `podium-${x.place || x.rank}`); + li.append(make('span', 'podium-medal', x.medal), lookName(x, 'podium-name'), make('span', 'podium-score', x.score)); + el.hostPodium.append(li); + } +} + +// The roster (Hosting.roster): standings in a tournament, else who's joined or the leaderboard +function renderRosterRows(view) { + const r = Hosting.roster(view); + el.hostRosterTitle.textContent = r.title; + el.hostRoster.innerHTML = ''; + el.hostRoster.classList.toggle('ranked', r.ranked); + if (!r.rows.length) el.hostRoster.append(make('li', 'host-empty', 'Nobody yet. Names show up here as students join.')); + for (const p of r.rows) { + if (p.waiting) { + const li = make('li', 'waiting' + (p.offline ? ' offline' : '')); + li.append(lookName(p, 'host-name'), make('span', 'host-waiting', p.waiting)); + li.title = 'Joined after this game started'; + el.hostRoster.append(li); + continue; + } + const li = make('li', (p.offline ? 'offline ' : '') + (p.out ? 'knocked-out' : '')); + if (p.rank) li.append(make('span', 'host-rank', p.rank)); + li.append(lookName(p, 'host-name')); + if (p.removable) { + const remove = make('button', 'host-remove', '×'); + remove.type = 'button'; + remove.title = `Remove ${p.name}`; + remove.setAttribute('aria-label', `Remove ${p.name}`); + remove.addEventListener('click', () => confirm(`Remove ${p.name} from the class?`) && hostAction('remove', { id: p.id })); + li.append(remove); + } else li.append(make('span', 'host-score', p.score), make('span', 'host-status', p.status)); + el.hostRoster.append(li); + } +} + +// ---- teacher: pairs + +let swapPick = null; // arranging pairs: the first name tapped + +function renderHostPairsLobby(view) { + // Only "on this screen" plays from this page; otherwise let go of the teacher's + // player here (another device or the CPU plays instead). + if (view.pairOptions.odd !== 'screen' && classSession && classSession.host) { + if (classStop) classStop(); + classStop = null; + classSession = null; + Classroom.saveSession(null); + if (game && game.mode === 'pair') setGame(null); + } + setSeg(el.hostMatching, view.pairOptions.matching); + setSeg(el.hostOdd, view.pairOptions.odd); + const n = view.players.length; + el.hostPairsHelp.textContent = Hosting.pairsHelp(view); + // Pairs preview (shuffle mode draws new ones at the start) + el.hostPairList.hidden = view.pairOptions.matching === 'shuffle' || !n; + el.hostPairList.innerHTML = ''; + const arrange = view.pairOptions.matching === 'arrange'; + if (swapPick && !view.players.some((p) => p.id === swapPick)) swapPick = null; + const nameChip = (p) => { + if (!arrange) return lookName(p, 'pair-name'); + const b = make('button', 'pair-name' + (swapPick === p.id ? ' picked' : ''), p.name); + b.type = 'button'; + b.addEventListener('click', () => { + if (!swapPick) swapPick = p.id; + else if (swapPick === p.id) swapPick = null; + else { + hostAction('swap', { a: swapPick, b: p.id }); + swapPick = null; + } + renderHostPairsLobby(hostView); + }); + return b; + }; + for (const [a, b] of view.pairs) { + const li = make('li'); + li.append(nameChip(a), make('span', 'pair-vs', 'vs')); + li.append(b ? nameChip(b) : make('span', 'pair-name other', view.pairOptions.odd === 'cpu' ? 'CPU' : 'You')); + el.hostPairList.append(li); + } + // Playing the odd one out from another device: its link and whether it has joined + const device = view.pairOptions.odd === 'device'; + el.hostTeacherDevice.hidden = !device; + if (device) { + const url = `${joinUrl(view.code).base}#class=${view.code}&play=${view.playKey}`; + el.hostTeacherUrl.textContent = url.replace(/^https?:\/\//, ''); + if (el.hostTeacherQr.dataset.url !== url) { + drawQr(el.hostTeacherQr, url); + el.hostTeacherQr.dataset.url = url; + } + const joined = view.teacher && view.teacher.connected; + el.hostTeacherStatus.textContent = joined ? '✓ Your other device has joined.' : 'Not joined yet.'; + el.hostTeacherStatus.classList.toggle('ok', Boolean(joined)); + } +} + +// One card per pair: both names and scores, whose turn it is, and how it ended. +function renderHostMatches(view, list) { + list.innerHTML = ''; + for (const m of view.matches) { + const li = make('li', 'host-match' + (m.over ? ' over' : '')); + const card = Hosting.matchCard(m); + for (const p of card.rows) { + const row = make('div', 'match-row' + (p.winner ? ' winner' : '')); + row.append(make('span', 'match-turn', p.mark), lookName(p, 'match-name'), make('span', 'match-score', String(p.score))); + li.append(row); + } + li.append(make('div', 'match-status', card.status)); + list.append(li); + } +} + +// ---- teacher: playing the odd one out on this screen + +let hostOnGame = false; // showing the teacher's own game instead of the class + +function showHostView() { + hostOnGame = false; + el.gameScreen.hidden = true; + el.gameOver.hidden = true; + el.hostScreen.hidden = !host; + if (!host) el.startScreen.hidden = false; + updateMyGameButton(); +} + +function showMyGame() { + if (!game || game.mode !== 'pair') return; + hostOnGame = true; + el.hostScreen.hidden = true; + el.gameScreen.hidden = false; + resizeBoard(); + render(); + if (game.phase === 'over') { + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; + } +} + +function updateMyGameButton() { + const btn = el.hostMyGameBtn; + const mine = classSession && classSession.host && game && game.mode === 'pair'; + btn.hidden = !mine || !hostView || hostView.state === 'lobby'; + if (btn.hidden) return; + const opp = opponent(); + const yourTurn = game.phase !== 'over' && game.current === game.classroom.me; + btn.textContent = game.phase === 'over' ? `Your game with ${opp.name} is over: see the result` : yourTurn ? `🎲 Your turn! Play your game with ${opp.name}` : `Your game with ${opp.name}`; + btn.classList.toggle('your-turn', yourTurn); +} + +function renderHostRoster(view) { + renderRosterRows(view); +} + +// Whole class: every student's board, updating as they place their rectangles +function renderHostBoards(view) { + const box = el.hostBoards; + box.replaceChildren(); + for (const p of view.players) { + const card = make('div', 'host-board' + (p.done ? ' done' : '') + (p.connected ? '' : ' offline')); + card.append(miniBoard((view.boards && view.boards[p.id]) || [], view.settings.size, '#2e86de', 96)); + const who = make('div', 'classmate-who'); + who.append(lookName(p, 'classmate-name'), make('span', 'classmate-score', `${p.done ? '✓ ' : ''}${p.score}`)); + card.append(who); + box.append(card); + } +} + +function renderHostResults(view) { + if (view.type === 'pairs') renderHostMatches(view, el.hostMatchResults); + if (view.type === 'tournament' && view.tournament) { + renderTournament(view.tournament, el.hostTournamentFinal); + renderPodium(view); + renderHostReport(view); + return; + } + renderPodium(view); + renderHostReport(view); +} + +function renderHostReport(view) { + const r = view.report; + el.hostReport.innerHTML = ''; + const tile = (value, label) => { + const t = make('div', 'tile'); + t.append(make('span', 'tile-value', value), make('span', 'tile-label', label)); + return t; + }; + el.hostReport.append(...Hosting.reportTiles(r).map(([value, label]) => tile(value, label))); + el.hostHardest.innerHTML = ''; + if (!r.hardest.length) el.hostHardest.append(make('li', 'host-empty', 'Nothing missed. What a class! 🌟')); + for (const f of r.hardest) { + const li = make('li'); + li.append(make('span', 'host-fact', f.fact), make('span', 'host-missed', `missed ${f.missed} of ${f.asked}`)); + el.hostHardest.append(li); + } +} + +async function closeClass() { + if (!confirm('Close the class? Everyone goes back to their menu.')) return; + const h = host; + if (hostStop) hostStop(); + await Classroom.teacher(h, 'close').catch(() => {}); + hostGone('closed'); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.classJoinOpen.addEventListener('click', () => openClassJoin()); + + el.classJoinCancel.addEventListener('click', () => (el.classJoin.hidden = true)); + + el.classCodeInput.addEventListener('input', () => { + el.classCodeInput.value = el.classCodeInput.value.toUpperCase().replace(/[^A-Z]/g, ''); + }); + + el.classJoinForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const code = el.classCodeInput.value.trim().toUpperCase(); + const name = el.classNameInput.value.trim(); + const fail = (msg, input) => { + el.classJoinError.textContent = msg; + el.classJoinError.hidden = false; + if (input) input.focus(); + }; + if (code.length !== 4) return fail('The class code has 4 letters.', el.classCodeInput); + if (!name) return fail('Type your first name.', el.classNameInput); + el.classJoinBtn.disabled = true; + try { + const joined = await Classroom.join(code, name, myLook()); + try { + localStorage.setItem(CLASS_NAME_KEY, joined.name); + } catch (e) {} + connectClass({ code, id: joined.id, key: joined.key, name: joined.name }); + showClassWaiting(null); + } catch (err) { + fail(err.message, err.code === 'room' ? el.classCodeInput : el.classNameInput); + } finally { + el.classJoinBtn.disabled = false; + } + }); + + el.classLeaveBtn.addEventListener('click', () => { + leaveClass(); + el.classJoin.hidden = true; + }); + + for (const group of [el.hostType, el.hostSize, el.hostDifficulty, el.hostRounds]) { + group.addEventListener('click', (e) => { + const b = e.target.closest('button'); + if (!b) return; + setSeg(group, b.dataset.value); + if (group === el.hostType) renderHostType(); + }); + } + + el.classHostOpen.addEventListener('click', openHostSetup); + + el.hostCancel.addEventListener('click', () => (el.hostSetup.hidden = true)); + + el.hostCreate.addEventListener('click', async () => { + const chosen = { size: Number(segValue(el.hostSize)), difficulty: segValue(el.hostDifficulty), rounds: Number(segValue(el.hostRounds)) }; + el.hostCreate.disabled = true; + try { + const room = await Classroom.createRoom({ ...chosen, type: segValue(el.hostType) }); + connectHost({ code: room.code, teacherKey: room.teacherKey, lan: room.lan }); + el.hostSetup.hidden = true; + } catch (err) { + el.hostError.textContent = err.message; + el.hostError.hidden = false; + } finally { + el.hostCreate.disabled = false; + } + }); + + el.hostCopyLink.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyLink, joinUrl(hostView.code).full)); + + el.hostCopyMsg.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyMsg, joinMessage(hostView.code))); + + el.hostShareBtn.hidden = !navigator.share; + + el.hostShareBtn.addEventListener('click', () => { + if (!hostView) return; + navigator.share({ title: 'Blockout class', text: joinMessage(hostView.code), url: joinUrl(hostView.code).full }).catch(() => {}); + }); + + el.hostCopyTeacher.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyTeacher, `${joinUrl(hostView.code).base}#class=${hostView.code}&play=${hostView.playKey}`)); + + // ---- teacher: game settings in the lobby, for the whole class and free of shop locks + + + el.hostSettingsGrid.addEventListener('click', (e) => { + const b = e.target.closest('.segmented button'); + if (!b) return; + const key = b.closest('.segmented').dataset.key; + hostAction('settings', { settings: { [key]: JSON.parse(b.dataset.value) } }); + }); + + el.hostQuick.addEventListener('click', () => hostAction('settings', { settings: QUICK_SETTINGS })); + + for (const [group, key] of [ + [el.hostMatching, 'matching'], + [el.hostOdd, 'odd'], + ]) { + group.addEventListener('click', (e) => { + const b = e.target.closest('button'); + if (b) hostAction('pairOptions', { options: { [key]: b.dataset.value } }); + }); + } + + el.hostMyGameBtn.addEventListener('click', showMyGame); + + el.hostStartBtn.addEventListener('click', async () => { + const v = hostView; + if (v && v.type === 'pairs' && v.odd.needed && v.odd.how === 'screen' && !(classSession && classSession.host)) { + // The odd one out plays the teacher, here: join as a player first + try { + const me = await Classroom.joinAsTeacher(host, myLook()); + connectClass({ code: host.code, id: me.id, key: me.key, name: me.name, teacher: true, host: true }); + } catch (err) { + return toast('⚠️ ' + err.message); + } + } + hostAction('start'); + }); + + el.hostNextBtn.addEventListener('click', () => hostAction('next')); + + el.hostEndBtn.addEventListener('click', () => confirm('End the game now? Scores so far are final.') && hostAction('end')); + + el.hostAuto.addEventListener('change', () => hostAction('autoNext', { on: el.hostAuto.checked })); + + el.hostAgainBtn.addEventListener('click', () => hostAction('lobby')); + + el.hostCloseBtn.addEventListener('click', closeClass); + + el.hostClose2Btn.addEventListener('click', closeClass); + + // Space rolls again on the teacher's screen + document.addEventListener('keydown', (e) => { + if (el.hostScreen.hidden || !hostView || e.target.closest('input, button, .overlay')) return; + if ((e.key === ' ' || e.key === 'Enter') && hostView.state === 'playing' && hostView.type === 'class') { + e.preventDefault(); + hostAction('next'); + } + }); + + // ---- after a reload: pick up where this browser left off + + if (Classroom.served()) { + const savedHost = Classroom.host(); + const savedSession = Classroom.session(); + const link = Classroom.linkFromHash(); + if (link) { + history.replaceState(null, '', location.pathname + location.search); + tap('[data-mode="multi"]'); + tap('#multi-kind [data-kind="classroom"]'); + if (savedSession && savedSession.code === link.code) connectClass(savedSession); + else if (link.play) { + // the teacher's other device, for playing an odd one out + Classroom.joinWithPlayKey(link.code, link.play, myLook()) + .then((me) => { + connectClass({ code: link.code, id: me.id, key: me.key, name: me.name, teacher: true }); + showClassWaiting(null); + }) + .catch((err) => toast('⚠️ ' + err.message)); + } else openClassJoin(link.code, true); + } else { + if (savedHost) connectHost(savedHost); + if (savedSession && (!savedHost || savedSession.host)) connectClass(savedSession); + } + } + + // Board wins started being counted when winning a board revealed the next one: + // give credit for wins already in the game history (once). + if (!progress.boardWinsFromHistory) { + for (const g of loadHistory()) { + if (!g.kind && g.vsComputer && g.players.some((p) => !p.cpu && p.result === 'win')) Progress.recordBoardWin(progress, g.size); + } + progress.boardWinsFromHistory = true; + saveProgress(); + syncBoardLocks(); + } +} + +export { pairLiveTick, TOURNAMENT_NAMES, checkClassroom, classSession, connectHost, leaveClass, openClassJoin, openHostSetup, ordinal, pairRoll, pairTurnOrWait, reportClass, showHostView }; diff --git a/apps/web/src/multiplication/game/daily.js b/apps/web/src/multiplication/game/daily.js new file mode 100644 index 0000000..72af3d9 --- /dev/null +++ b/apps/web/src/multiplication/game/daily.js @@ -0,0 +1,95 @@ +// Today's practice: part of the multiplication game. Three small tasks a day +// to pick from, from the player's own Times Table and Journey +// (@blockout/progress/daily, shared with the phone app), with a reward that +// grows with a streak, and new cards for points. It's shown at the top of +// Homework (../accounts.js), for guests too. + +import * as Daily from '@blockout/progress/daily'; +import { refreshHomeworkBadge } from '../accounts.js'; +import { celebrate, progress, renderWallet, saveProgress, toast } from './wallet.js'; +import { make } from './play.js'; +import { startPractice } from './practice.js'; +import { tap } from './shop.js'; + +// Today's practice: three cards to pick from, like a level-up's (each plays +// its task), the reward or the streak, and new cards for points. onPlay: before +// one starts (Homework closes). +function dailyCard(onPlay = () => {}) { + const c = Daily.card(progress); + const box = make('div', 'daily' + (c.done ? ' done' : '')); + box.id = 'daily-card'; + const head = make('p', 'daily-head'); + head.append(make('span', 'daily-title', c.title)); + if (c.reward) head.append(' ', make('span', 'daily-reward', c.reward)); + if (c.streak) head.append(' ', make('span', 'daily-streak', c.streak)); + box.append(head); + if (c.done) { + box.append(make('p', 'daily-done', c.doneText)); + return box; + } + const cards = make('div', 'daily-cards'); + c.cards.forEach((card, i) => { + const pick = make('button', 'daily-pick'); + pick.type = 'button'; + pick.setAttribute('aria-label', `Play: ${card.text}. ${card.why}`); + const body = make('span', 'daily-body'); + body.append(make('span', 'daily-task', card.text), make('span', 'daily-why', card.why)); + pick.append(make('span', 'daily-icon', card.icon), body, make('span', 'daily-go', 'Play ›')); + pick.addEventListener('click', () => { + onPlay(); + playDaily(i); + }); + cards.append(pick); + }); + box.append(cards); + const again = make('button', 'btn btn-small daily-reroll', c.reroll.label); + again.type = 'button'; + again.disabled = !c.reroll.can; + again.addEventListener('click', () => { + if (!Daily.reroll(progress).ok) return; + saveProgress(); + renderWallet(); + box.replaceWith(dailyCard(onPlay)); + }); + box.append(again); + return box; +} + +// Still to do today? (Homework's badge counts it) +const dailyOpen = () => !Daily.today(progress).done; + +// A card's task: its round straight away (a taste of the next table too, +// though it's still locked), or the menu set up for its game (the Journey's +// level, or a board in Free play), then Start +function playDaily(i) { + const p = Daily.playFor(Daily.today(progress).choices[i]); + if (p.practice) return startPractice('You', p.practice.count, p.practice.tables, false, 0, p.practice.difficulty); + tap('[data-mode="single"]'); + if (p.game.journey) tap('#play-kind [data-play="journey"]'); + else { + tap('#play-kind [data-play="free"]'); + tap(`[data-setting="difficulty"] [data-value="${p.game.difficulty}"]`); + tap(`#board-size [data-size="${p.game.size}"]`); + } + const start = document.getElementById('start-btn'); + if (!start.disabled) start.click(); +} + +// A practice round or a game just finished: today's practice, if it was that +function completeDaily(result) { + const done = Daily.complete(progress, result); + if (!done) return []; + toast(...done.toast); + const earned = celebrate(done.earned); + saveProgress(); + refreshHomeworkBadge(true); + return earned; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + Daily.today(progress); // (a new day: today's practice) + saveProgress(); +} + +export { completeDaily, dailyCard, dailyOpen }; diff --git a/apps/web/src/multiplication/game/index.js b/apps/web/src/multiplication/game/index.js new file mode 100644 index 0000000..afe6fa4 --- /dev/null +++ b/apps/web/src/multiplication/game/index.js @@ -0,0 +1,35 @@ +// The multiplication game: every part's load-time work, in the original order. +import { run as base } from './base.js'; +import { run as wallet } from './wallet.js'; +import { run as settings } from './settings.js'; +import { run as wardrobe } from './wardrobe.js'; +import { run as journey } from './journey.js'; +import './play.js'; +import { run as stats } from './stats.js'; +import { run as shop } from './shop.js'; +import { run as achievements } from './achievements.js'; +import { run as practice } from './practice.js'; +import { run as invites } from './invites.js'; +import { run as classroom } from './classroom.js'; +import { run as cheats } from './cheats.js'; +import { run as checkin } from './checkin.js'; +import { run as daily } from './daily.js'; +import { run as input } from './input.js'; +import { run as render } from './render.js'; + +base(); +wallet(); +settings(); +wardrobe(); +journey(); +stats(); +shop(); +achievements(); +practice(); +invites(); +classroom(); +cheats(); +checkin(); +daily(); +input(); +render(); diff --git a/apps/web/src/multiplication/game/input.js b/apps/web/src/multiplication/game/input.js new file mode 100644 index 0000000..743d2a0 --- /dev/null +++ b/apps/web/src/multiplication/game/input.js @@ -0,0 +1,179 @@ +// Input: part of the multiplication game (split from the old js/game.js). + +import * as Match from '@blockout/engine/match'; +import { el } from './base.js'; +import { icon } from './wardrobe.js'; +import { cancelContinue, continueResolve, game, gameId, helpCount, humanRoll, placeInstructions, placeRect, pressContinue, setGame, setGameId, startAnswer, startGame, stopTimer, typeKey } from './play.js'; +import { classSession, leaveClass, showHostView } from './classroom.js'; +import { render, setMsg } from './render.js'; + +function setDetailsOpen(open) { + el.stats.hidden = !open; + el.detailsBtn.replaceChildren(icon('chart-column'), open ? ' Hide details' : ' Show details'); + el.detailsBtn.setAttribute('aria-expanded', String(open)); +} + +// Cell under the pointer; null when outside the board unless clamp is set. +function cellFromEvent(e, clamp = false) { + const rect = el.canvas.getBoundingClientRect(); + const cell = rect.width / game.size; + let c = Math.floor((e.clientX - rect.left) / cell); + let r = Math.floor((e.clientY - rect.top) / cell); + if (clamp) { + c = Math.max(0, Math.min(c, game.size - 1)); + r = Math.max(0, Math.min(r, game.size - 1)); + } else if (c < 0 || r < 0 || c >= game.size || r >= game.size) { + return null; + } + return { c, r }; +} + +// ---- draw mode: drag out the rectangle corner to corner + +function dragRect() { + const { c0, r0, c1, r1 } = game.drag; + return { x: Math.min(c0, c1), y: Math.min(r0, r1), w: Math.abs(c1 - c0) + 1, h: Math.abs(r1 - r0) + 1 }; +} + +// How the rectangle being drawn compares to the roll. +// (the game's state is shaped like a match: the rule is shared with the phone app) +const judgeDrawn = (rect) => Match.judge(game, rect); + +function finishDrag() { + const r = dragRect(); + game.drag = null; + const [a, b] = game.dice; + const verdict = judgeDrawn(r); + if (verdict.ok) { + startAnswer(placeRect(r.x, r.y, r.w, r.h)); + return; + } + setMsg( + !verdict.rightSize + ? `That's ${r.w} by ${r.h}, but you rolled ${a} and ${b}. Try again!` + : 'Right size, but it bumps into another rectangle. Try a different spot!' + ); + el.turnMsg.classList.add('oops'); + render(); +} + +function toMenu() { + cancelContinue(); + stopTimer(); + setGameId(gameId + 1); + setGame(null); + el.gameOver.hidden = true; + el.gameScreen.hidden = true; + el.startScreen.hidden = false; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.rollBtn.addEventListener('click', humanRoll); + + el.continueBtn.addEventListener('click', pressContinue); + + el.detailsBtn.addEventListener('click', () => setDetailsOpen(el.stats.hidden)); + + el.helpBtn.addEventListener('click', helpCount); + + el.keypad.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (btn) typeKey(btn.dataset.key); + }); + + document.addEventListener('keydown', (e) => { + if (!game || el.gameScreen.hidden || !el.gameOver.hidden) return; + if (!el.settingsDialog.hidden || !el.shopDialog.hidden) return; // a dialog is on top of the game + if (e.target.closest('.overlay, input')) return; // e.g. Esc that just closed a dialog + if (continueResolve && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + pressContinue(); + } else if (game.phase === 'answer') { + if (/^[0-9]$/.test(e.key)) typeKey(e.key); + else if (e.key === 'Backspace') typeKey('back'); + else if (e.key === 'Escape') typeKey('clear'); + else if (e.key === 'Enter') typeKey('check'); + else return; + e.preventDefault(); + } else if ((e.key === ' ' || e.key === 'Enter') && game.phase === 'roll') { + e.preventDefault(); + humanRoll(); + } + }); + + el.canvas.addEventListener('pointerdown', (e) => { + if (!game || game.phase !== 'place' || game.placeMode !== 'draw') return; + const cell = cellFromEvent(e); + if (!cell) return; + e.preventDefault(); + el.canvas.setPointerCapture(e.pointerId); + game.drag = { c0: cell.c, r0: cell.r, c1: cell.c, r1: cell.r }; + setMsg(placeInstructions()); + render(); + }); + + el.canvas.addEventListener('pointerup', (e) => { + if (!game || !game.drag || game.phase !== 'place') return; + const cell = cellFromEvent(e, true); + game.drag.c1 = cell.c; + game.drag.r1 = cell.r; + finishDrag(); + }); + + el.canvas.addEventListener('pointercancel', () => { + if (!game || !game.drag) return; + game.drag = null; + render(); + }); + + el.canvas.addEventListener('pointermove', (e) => { + if (!game || game.phase !== 'place') return; + if (game.drag) { + const cell = cellFromEvent(e, true); + if (cell.c === game.drag.c1 && cell.r === game.drag.r1) return; + game.drag.c1 = cell.c; + game.drag.r1 = cell.r; + render(); + return; + } + if (e.pointerType === 'touch') return; + game.hover = cellFromEvent(e); + render(); + }); + + el.canvas.addEventListener('pointerleave', (e) => { + if (!game || e.pointerType === 'touch') return; + game.hover = null; + render(); + }); + + el.menuBtn.addEventListener('click', () => { + if (classSession && classSession.host) return showHostView(); // teacher: back to the class + const inClass = game && (game.mode === 'class' || game.mode === 'pair'); + const question = inClass ? 'Leave the class game? Your score so far won’t count.' : 'Leave this game and go back to the menu?'; + if (game && game.phase !== 'over' && !confirm(question)) return; + if (inClass) leaveClass(); + toMenu(); + }); + + el.toMenuBtn.addEventListener('click', () => { + if (classSession && classSession.host) { + el.gameOver.hidden = true; + return showHostView(); + } + if (game && (game.mode === 'class' || game.mode === 'pair')) leaveClass(); + toMenu(); + }); + + el.againBtn.addEventListener('click', () => { + startGame( + game.players.map(({ name, cpu }) => ({ name, cpu })), + game.size, + game.mode, + game.invite + ); + }); +} + +export { dragRect, judgeDrawn, setDetailsOpen, toMenu }; diff --git a/apps/web/src/multiplication/game/invites.js b/apps/web/src/multiplication/game/invites.js new file mode 100644 index 0000000..9eca522 --- /dev/null +++ b/apps/web/src/multiplication/game/invites.js @@ -0,0 +1,272 @@ +// Invites: part of the multiplication game (split from the old js/game.js). + +import qrcode from 'qrcode-generator'; +import * as Invite from '../invite.js'; +import { $, DEFAULT_NAMES, DEFAULT_SETTINGS, el, setup } from './base.js'; +import { toast } from './wallet.js'; +import { clampCustom, saveNameInputs } from './wardrobe.js'; +import { make, startGame } from './play.js'; + +// Settings an invite can set from the "Make an invite" form (select id = invite-). +const INVITE_FORM_SETTINGS = ['difficulty', 'placeMode', 'answerTime', 'autoRoll', 'cpuSteps']; + +const INVITE_LABELS = { + difficulty: { + easy: 'Easy (6-sided dice)', + medium: 'Medium (8-sided dice)', + hard: 'Hard (12-sided dice)', + tricky: 'Tricky (no easy facts)', + master: 'Master (your weakest facts)', + legend: 'Legend (teens × 2–9)', + }, + placeMode: { draw: 'You draw each rectangle', auto: 'Rectangles are placed for you' }, + cpuSteps: { show: null, instant: 'CPU plays instantly' }, + autoRoll: { manual: null, auto: 'Dice roll by themselves' }, +}; + +// Players from the main-menu setup, in invite form. +function invitePlayersFromSetup() { + if (setup.mode === 'single') return [{ n: el.singleName.value.trim(), c: false }]; + saveNameInputs(); + return setup.names.slice(0, setup.count).map((name, i) => ({ n: setup.types[i] === 'cpu' ? '' : name.trim(), c: setup.types[i] === 'cpu' })); +} + +function invitePlayerNames(inv) { + let cpu = 0; + return inv.p.map((p, i) => (p.c ? `CPU ${++cpu}` : p.n || DEFAULT_NAMES[i])); +} + +function describeInvite(inv) { + const s = { ...DEFAULT_SETTINGS, ...inv.s }; + const lines = [ + inv.m === 'single' ? 'You vs the CPU' : `Players: ${invitePlayerNames(inv).join(', ')}`, + `Board: ${inv.b}×${inv.b}`, + INVITE_LABELS.difficulty[s.difficulty], + INVITE_LABELS.placeMode[s.placeMode], + ]; + if (s.answerTime !== '0') lines.push(`${s.answerTime} seconds to answer each question`); + for (const key of ['autoRoll', 'cpuSteps']) if (INVITE_LABELS[key][s[key]]) lines.push(INVITE_LABELS[key][s[key]]); + if (inv.r !== undefined) lines.push('Same dice rolls as everyone else with this invite'); + return lines; +} + +function openInviteMaker() { + const names = invitePlayersFromSetup(); + el.invitePlayers.textContent = + setup.mode === 'single' + ? `Single player: ${names[0].n || 'You'} vs the CPU` + : `Multiplayer: ${invitePlayerNames({ p: names }).join(', ')}`; + el.inviteCodeError.hidden = true; + el.inviteResult.hidden = true; + el.inviteMake.hidden = false; + el.inviteMake.querySelector('.dialog').scrollTop = 0; + el.inviteCodeInput.focus(); +} + +function closeInviteMaker() { + el.inviteMake.hidden = true; +} + +function buildInvite() { + const board = el.inviteBoard.value === 'custom' ? clampCustom(el.inviteCustom.value) : Number(el.inviteBoard.value); + const s = {}; + for (const key of INVITE_FORM_SETTINGS) { + const value = $(`invite-${key}`).value; + if (value !== String(DEFAULT_SETTINGS[key])) s[key] = value; // only what differs, to keep links short + } + const inv = { v: Invite.VERSION, m: setup.mode, b: board, p: invitePlayersFromSetup(), s }; + if (el.inviteNote.value.trim()) inv.t = el.inviteNote.value.trim(); + if (el.inviteSeed.checked) inv.r = Invite.newSeed(); + return Invite.validate(inv); +} + +let madeInvite = null; + +// Black-on-white QR code, 8px per module with the standard 4-module quiet zone. +function drawQr(canvas, text) { + const qr = qrcode(0, 'M'); + qr.addData(text); + qr.make(); + const modules = qr.getModuleCount(); + const cell = 8; + const quiet = 4; + const size = (modules + quiet * 2) * cell; + canvas.width = canvas.height = size; + const g = canvas.getContext('2d'); + g.fillStyle = '#ffffff'; + g.fillRect(0, 0, size, size); + g.fillStyle = '#000000'; + for (let r = 0; r < modules; r++) { + for (let c = 0; c < modules; c++) { + if (qr.isDark(r, c)) g.fillRect((c + quiet) * cell, (r + quiet) * cell, cell, cell); + } + } +} + +// ---- a child opens an invite + +let openedInvite = null; + +function showInvite(inv) { + openedInvite = inv; + el.inviteOpenNote.hidden = !inv.t; + el.inviteOpenNote.textContent = inv.t ? `“${inv.t}”` : ''; + el.inviteOpenSummary.innerHTML = ''; + for (const line of describeInvite(inv)) el.inviteOpenSummary.append(make('li', null, line)); + el.inviteOpenNameField.hidden = inv.m !== 'single'; + el.inviteOpenName.value = inv.m === 'single' ? inv.p[0].n || el.singleName.value.trim() || '' : ''; + for (const d of document.querySelectorAll('.overlay')) if (d !== el.inviteOpen) d.hidden = true; + el.inviteOpen.hidden = false; + el.inviteStart.focus(); +} + +function clearInviteFromUrl() { + if (location.hash.includes('invite=')) history.replaceState(null, '', location.pathname + location.search); +} + +// Links look like index.html#invite=CODE +function checkUrlForInvite() { + const token = Invite.tokenFrom(location.hash); + if (!token) return; + const inv = Invite.decode(token); + if (inv) showInvite(inv); + else { + clearInviteFromUrl(); + toast('⚠️ That invite link is broken', 'Ask for a new one, or paste the code in 🔗 Invite.'); + } +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.inviteCreate.addEventListener('click', () => { + if (setup.mode === 'multi' && invitePlayersFromSetup().some((p) => !p.c && !p.n)) { + toast('✏️ Players need names', 'Type every player’s name on the main menu first.'); + return; + } + madeInvite = buildInvite(); + if (!madeInvite) return; + const token = Invite.encode(madeInvite); + el.inviteCode.value = token; + el.inviteLink.value = `${location.href.split('#')[0]}#invite=${token}`; + const local = location.protocol === 'file:'; + el.inviteLocalWarning.hidden = !local; + el.inviteQrWrap.hidden = local; // a file:// link can't be opened from another device + if (!local) drawQr(el.inviteQr, el.inviteLink.value); + el.inviteResult.hidden = false; + el.inviteResult.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + + el.inviteQrSave.addEventListener('click', () => { + const a = document.createElement('a'); + a.href = el.inviteQr.toDataURL('image/png'); + a.download = 'blockout-invite.png'; + a.click(); + }); + + // A printable hand-out: QR code, the note and what the game will be. + el.inviteQrPrint.addEventListener('click', () => { + if (!madeInvite) return; + const win = window.open('', '_blank'); + if (!win) return toast('⚠️ Could not open the print page', 'Allow pop-ups for this page, or use Save QR image.'); + const doc = win.document; + doc.title = 'Blockout invite'; + const style = doc.createElement('style'); + style.textContent = + 'body{font-family:system-ui,sans-serif;text-align:center;padding:32px;color:#2b2a33}' + + 'img{width:320px;height:320px;image-rendering:pixelated}' + + '.note{font-size:1.4rem;font-weight:800;margin:16px auto;max-width:480px}' + + 'ul{display:inline-block;text-align:left;line-height:1.6}'; + doc.head.append(style); + const add = (tag, text, cls) => { + const node = doc.createElement(tag); + if (text) node.textContent = text; + if (cls) node.className = cls; + doc.body.append(node); + return node; + }; + add('h1', 'Blockout'); + if (madeInvite.t) add('p', `“${madeInvite.t}”`, 'note'); + const img = add('img'); + img.src = el.inviteQr.toDataURL('image/png'); + img.alt = 'QR code for the Blockout invite'; + add('p', 'Scan with a phone or tablet camera to start the game.'); + const list = add('ul'); + for (const line of describeInvite(madeInvite)) { + const li = doc.createElement('li'); + li.textContent = line; + list.append(li); + } + img.onload = () => win.print(); + }); + + el.inviteBoard.addEventListener('change', () => { + el.inviteCustomField.hidden = el.inviteBoard.value !== 'custom'; + }); + + el.inviteMake.addEventListener('click', async (e) => { + if (e.target === el.inviteMake) return closeInviteMaker(); + const copy = e.target.closest('[data-copy]'); + if (!copy) return; + const field = $(copy.dataset.copy); + try { + await navigator.clipboard.writeText(field.value); + } catch (_) { + field.select(); + document.execCommand('copy'); // older browsers / file:// pages + } + copy.textContent = 'Copied!'; + setTimeout(() => (copy.textContent = 'Copy'), 1500); + }); + + el.inviteCodeOpen.addEventListener('click', () => { + const token = Invite.tokenFrom(el.inviteCodeInput.value); + const inv = token && Invite.decode(token); + el.inviteCodeError.hidden = Boolean(inv); + if (!inv) return; + closeInviteMaker(); + showInvite(inv); + }); + + el.inviteCodeInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') el.inviteCodeOpen.click(); + }); + + el.inviteTry.addEventListener('click', () => { + if (!madeInvite) return; + closeInviteMaker(); + showInvite(madeInvite); + }); + + el.inviteBtn.addEventListener('click', openInviteMaker); + + el.inviteDone.addEventListener('click', closeInviteMaker); + + el.inviteMake.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeInviteMaker(); + }); + + el.inviteStart.addEventListener('click', () => { + const inv = openedInvite; + if (!inv) return; + el.inviteOpen.hidden = true; + clearInviteFromUrl(); + const names = invitePlayerNames(inv); + const players = + inv.m === 'single' + ? [ + { name: el.inviteOpenName.value.trim() || 'You', cpu: false }, + { name: 'CPU', cpu: true }, + ] + : inv.p.map((p, i) => ({ name: names[i], cpu: p.c })); + startGame(players, inv.b, inv.m, inv); + }); + + el.inviteCancel.addEventListener('click', () => { + el.inviteOpen.hidden = true; + clearInviteFromUrl(); + }); + + window.addEventListener('hashchange', checkUrlForInvite); +} + +export { checkUrlForInvite, drawQr }; diff --git a/apps/web/src/multiplication/game/journey.js b/apps/web/src/multiplication/game/journey.js new file mode 100644 index 0000000..50ec4d7 --- /dev/null +++ b/apps/web/src/multiplication/game/journey.js @@ -0,0 +1,127 @@ +// The Journeys on the menu: single player's (boards and dice) and Practice's +// (one times table a level). The level you're on, what it takes to move up, +// and the switch to Free play / Free practice once it's open. The rules are +// @blockout/progress/journey; part of the multiplication game. + +import * as Journey from '@blockout/progress/journey'; +import { el, setup } from './base.js'; +import { progress } from './wallet.js'; +import { icon, renderGameLevel, renderSetupSections } from './wardrobe.js'; +import { updatePracticePreview } from './practice.js'; + +// Playing the Journey right now: single player, not Free play, levels left +const journeyOn = () => setup.mode === 'single' && Journey.onJourney(progress, setup.play); + +// The level a new single-player game plays (null: Free play) +const journeyGame = () => (journeyOn() ? Journey.journeyLevel(progress) : null); + +// The difficulty single player's Level and timers go by: the level's, or null for the menu's +const journeyDifficulty = () => (journeyOn() ? Journey.levelSetup(Journey.journeyLevel(progress)).difficulty : null); + +// Practicing on the Journey: the ×n table of the Practice level reached +const practiceJourneyOn = () => setup.mode === 'practice' && Journey.onPracticeJourney(progress, setup.practicePlay); + +// The Practice level a new round plays (null: Free practice) +const practiceJourneyLevel = () => (practiceJourneyOn() ? Journey.practiceLevel(progress) : null); + +// The difficulty Practice's Expert, timers and lengths go by on the Journey (null: the menu's) +const practiceJourneyDifficulty = () => (practiceJourneyOn() ? Journey.practiceTier(Journey.practiceLevel(progress)) : null); + +function renderSteps(steps) { + el.journeySteps.replaceChildren( + ...steps.map((s) => { + const li = document.createElement('li'); + li.textContent = s.label || s.level; + li.className = s.done ? 'done' : s.current ? 'current' : ''; + li.setAttribute('aria-label', `${s.label || `Level ${s.level}`}${s.done ? ': done' : s.current ? ': you are here' : ''}`); + return li; + }) + ); +} + +// What the card shows for single player's Journey or Practice's +function journeyView() { + if (setup.mode === 'practice') { + const level = Journey.practiceLevel(progress); + const done = Journey.practiceDone(progress); + const round = Journey.practiceSetup(level); + return { + key: 'practicePlay', done, open: Journey.autoPracticeOpen(progress), on: practiceJourneyOn(), + name: Journey.practiceName(level), + line: done ? 'You finished every times table. Auto keeps you sharp!' : Journey.practiceLine(level), + goal: Journey.practiceGoalLine(), + steps: Journey.practiceSteps(progress), + free: ['Auto', 'picks for you'], + start: `Practice ×${round.table}`, + }; + } + const level = Journey.journeyLevel(progress); + const done = Journey.isDone(progress); + return { + key: 'play', done, open: Journey.freePlayOpen(progress), on: journeyOn(), + name: Journey.levelName(level), + line: done ? 'You finished every level. Pick any board and dice in Free play!' : Journey.levelLine(level), + goal: Journey.goalLine(level), + steps: Journey.steps(progress), + free: ['Free play', 'pick a board'], + start: `Play ${Journey.levelName(level)}`, + }; +} + +// The card, the Journey / Free switch, and what the rest of the form shows: on +// the Journey the level picks the board and dice (or the times table). +function renderJourney() { + if (!el.journeyCard) return; + const shown = setup.mode === 'single' || setup.mode === 'practice'; + el.journeyCard.hidden = !shown; + if (!shown) return; + const v = journeyView(); + if (v.done) setup[v.key] = 'free'; + else if (!v.open) setup[v.key] = 'journey'; + el.playKind.hidden = !v.open || v.done; + for (const b of el.playKind.querySelectorAll('[data-play]')) { + const on = b.dataset.play === setup[v.key]; + b.classList.toggle('selected', on); + b.setAttribute('aria-checked', String(on)); + } + const small = document.createElement('small'); + small.textContent = v.free[1]; + el.playKind.querySelector('[data-play="free"]').replaceChildren(icon('dices'), ` ${v.free[0]} `, small); + const on = journeyOn() || practiceJourneyOn(); + el.journeyInfo.hidden = !on && !v.done; + el.journeyLevel.textContent = v.name; + el.journeyLine.textContent = v.line; + el.journeyGoal.replaceChildren(icon('flag'), ` ${v.goal} to move up.`); + el.journeyGoal.hidden = v.done; + renderSteps(v.steps); + // (practice has no difficulty: the Journey's table or Auto picks the facts) + el.difficultyField.hidden = on || setup.mode === 'practice'; + if (setup.mode === 'single') el.boardSizeField.hidden = on; + else { + // (the round sets the length, Learn or Expert and the timer) + el.practiceCountField.hidden = on; + el.practiceLevelField.hidden = on; + if (on) el.practiceTimerField.hidden = true; + el.practicePreview.hidden = on; + } + if (on) { + el.startBtn.replaceChildren(icon('play'), ` ${v.start}`); + el.startBtn.disabled = false; + } +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + renderJourney(); + + el.playKind.addEventListener('click', (e) => { + const btn = e.target.closest('[data-play]'); + if (!btn) return; + setup[setup.mode === 'practice' ? 'practicePlay' : 'play'] = btn.dataset.play; + renderSetupSections(); + renderGameLevel(); + updatePracticePreview(); + }); +} + +export { journeyDifficulty, journeyGame, journeyOn, practiceJourneyDifficulty, practiceJourneyLevel, practiceJourneyOn, renderJourney }; diff --git a/apps/web/src/multiplication/game/play.js b/apps/web/src/multiplication/game/play.js new file mode 100644 index 0000000..10d3aa0 --- /dev/null +++ b/apps/web/src/multiplication/game/play.js @@ -0,0 +1,1017 @@ +// Game state: part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import * as Match from '@blockout/engine/match'; +import * as Progress from '@blockout/progress'; +import * as Views from '@blockout/progress/views'; +import * as Stats from '@blockout/progress/stats'; +import * as Journey from '@blockout/progress/journey'; +import * as Wardrobe from '@blockout/progress/wardrobe'; +import { AUTO_PLACE_MS, AUTO_ROLL_MS, COUNT_STEP, PASS_DELAY, TRY_AGAIN, el, seededRandom, setup } from './base.js'; +import { BONUS_TOASTS, capitalize, celebrate, celebrateWin, grantStickers, progress, queueAchievements, saveProgress, toast } from './wallet.js'; +import { cpuStep, saveSettings, settings } from './settings.js'; +import { GAME_EXPERT_BONUS, applySettingsForTurn, icon, look, syncBoardLocks, syncSettingsUI } from './wardrobe.js'; +import { recordGame } from './stats.js'; +import { refreshNudges } from './shop.js'; +import { completeDaily } from './daily.js'; +import { journeyGame, renderJourney } from './journey.js'; +import { playerFacts, setPractice, stopPracticeTimer, timerText, weakFactWeight } from './practice.js'; +import { pairRoll, pairTurnOrWait, reportClass } from './classroom.js'; +import { setDetailsOpen } from './input.js'; +import { PLACE_FX_MS, animateBoard, render, renderDie, resizeBoard, setMsg } from './render.js'; + +let game = null; + +let gameId = 0; // bumped on every new game so pending timers from an old game do nothing + +// Wardrobe on players: the CPU character's name and avatar; in single player, +// your avatar and title. "CPU 2" in multiplayer becomes e.g. "Owl 2". +// (avatar and title for the player on this device: in single player, class games +// and their own side of a pair; not in home multiplayer or for a pair partner) +const wardrobeLook = (p, mode) => Wardrobe.playerLook(settings.cosmetics, p, mode === 'multi' || (mode === 'pair' && !p.statsName) ? 'multi' : 'single'); + +// invite: a decoded invite, whose settings and dice seed apply to this game only. +// extra: { cfg, classroom } for a class game: fixed settings, dice from the teacher. +function startGame(playerDefs, size, mode, invite = null, extra = null) { + cancelContinue(); + stopTimer(); + stopPracticeTimer(); + setPractice(null); + el.practiceScreen.hidden = true; + el.practiceDone.hidden = true; + gameId++; + // On the Journey the level decides the board and the dice + const level = mode === 'single' && !invite && !extra ? journeyGame() : null; + const journey = level ? Journey.levelSetup(level) : null; + if (journey) size = journey.size; + const cfg = { ...settings, ...(invite ? invite.s : {}), ...(extra ? extra.cfg : {}), ...(journey ? { difficulty: journey.difficulty } : {}) }; + const rng = invite && invite.r !== undefined ? seededRandom(invite.r) : Math.random; + game = { + invite, + fixed: Boolean(extra), // settings don't change mid-game + classroom: extra ? extra.classroom : null, // { session, game, round, reported, view } + rng, + cpuSpeed: cfg.cpuSpeed, + cpuEnd: cfg.cpuEnd, + showProgress: cfg.showProgress, + id: gameId, + size, + level, // the Journey level this game is (null: Free play, multiplayer, class) + mode, // 'single' (you vs the CPU), 'multi' (one screen) or 'class' (dice from the teacher) + board: Core.createBoard(size), + difficulty: cfg.difficulty, + sides: (journey && journey.sides) || Core.DIFFICULTY_SIDES[cfg.difficulty] || 6, + cpuInstant: cfg.cpuSteps === 'instant', + autoRoll: cfg.autoRoll === 'auto', + players: playerDefs.map((p, i) => ({ + ...p, + ...wardrobeLook(p, mode), + color: look('colors').colors[i], + score: 0, // squares + bonus + squares: 0, + bonus: 0, + streak: 0, // first-try answers in a row + bestStreak: 0, + log: [], + answered: 0, + firstTry: 0, + stats: { placed: 0, biggest: null, wrong: 0, helped: 0, timeouts: 0, passes: 0, times: [], missed: [] }, + })), + answerTime: Number(cfg.answerTime) || 0, // seconds, 0 = untimed + startedAt: Date.now(), + turns: 0, + current: 0, + passes: 0, + dice: null, + phase: 'roll', // roll | rolling | place | answer | cpu | over + hover: null, // { c, r } cell under the pointer (draw mode) + cpuPreview: null, + lastRect: null, + placeMode: cfg.placeMode, // 'draw': drag out the rectangle; 'auto': the game places it + drag: null, // { c0, r0, c1, r1 } while a human is drawing + pending: null, // human rectangle waiting for its points to be worked out + counting: null, // { rect, byRows, shown, group } while skip-counting on the board + }; + // Loaded to suit board and difficulty. After Hard, the dice deal chosen + // pairs: Tricky skips the easy facts, Master leans on your weakest facts, + // Legend deals a teen × a one-digit number. + game.faces = Core.diceFaces(cfg.difficulty, game.sides); + game.diceBag = Core.usesPairDice(cfg.difficulty) + ? Core.createPairDice(size, rng, cfg.difficulty, cfg.difficulty === 'master' ? weakFactWeight(playerDefs.find((p) => !p.cpu)) : null) + : Core.createDice(size, rng, game.sides); + // Single player's Level: Expert has no "Help me count", doubles bonus points, + // and can have an answer timer (its own, not the Settings one) + const levelled = mode === 'single' && !invite && !extra; + game.expert = levelled && setup.level === 'expert'; + game.levelTimer = game.expert ? setup.timer : 0; + if (levelled) game.answerTime = game.levelTimer; + el.resultTournament.hidden = true; + const classroomGame = mode === 'class' || mode === 'pair'; + el.againBtn.hidden = classroomGame; // the teacher starts the next class game + el.againBtn.textContent = 'Play again'; + el.toMenuBtn.textContent = !classroomGame ? 'Main menu' : extra.classroom.session.host ? 'Back to the class' : 'Leave class'; + el.startScreen.hidden = true; + el.gameOver.hidden = true; + el.gameScreen.hidden = false; + renderDie(el.dieA, null); + renderDie(el.dieB, null); + resizeBoard(); + beginTurn(); +} + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Resolves after ms, or never if the game has changed in the meantime. +function waitInGame(ms) { + const id = gameId; + return wait(ms).then(() => (id === gameId ? true : new Promise(() => {}))); +} + +function currentPlayer() { + return game.players[game.current]; +} + +function resetTurn() { + stopTimer(); + game.dice = null; + game.hover = null; + game.cpuPreview = null; + game.drag = null; + game.pending = null; + game.counting = null; + el.math.hidden = true; + el.steps.hidden = true; + el.steps.innerHTML = ''; + renderDie(el.dieA, null); + renderDie(el.dieB, null); +} + +function beginTurn() { + applySettingsForTurn(); + const p = currentPlayer(); + game.turns++; + resetTurn(); + if (game.mode === 'class') { + game.phase = 'wait'; + setMsg(game.classroom.round ? 'Waiting for the next roll…' : 'Get ready! Waiting for the first roll…'); + render(); + return; + } + if (game.mode === 'pair') return pairTurnOrWait(); + if (p.cpu) { + game.phase = 'cpu'; + setMsg(`${p.name} is rolling…`); + render(); + runComputerTurn(); + } else { + game.phase = 'roll'; + setMsg(game.autoRoll ? 'Rolling the dice…' : 'Roll the dice!'); + render(); + if (game.autoRoll) waitInGame(AUTO_ROLL_MS).then(humanRoll); + } +} + +// forced: the class game's roll, from the teacher +async function rollDice(forced = null) { + game.phase = 'rolling'; + render(); + const rollStyle = `roll-${settings.cosmetics.roll}`; // Wardrobe: wobble, tumble, bounce, sparkle + el.dieA.classList.add('rolling', rollStyle); + el.dieB.classList.add('rolling', rollStyle); + const face = (list) => list[Math.floor(Math.random() * list.length)]; + for (let i = 0; i < 8; i++) { + renderDie(el.dieA, face(game.faces.a)); + renderDie(el.dieB, face(game.faces.b)); + await waitInGame(55); + } + el.dieA.classList.remove('rolling', rollStyle); + el.dieB.classList.remove('rolling', rollStyle); + game.dice = forced ? [...forced] : Core.rollForBoard(game.board, game.diceBag, game.rng); + renderDie(el.dieA, game.dice[0]); + renderDie(el.dieB, game.dice[1]); + return game.dice; +} + +async function humanRoll() { + if (!game || game.phase !== 'roll' || currentPlayer().cpu) return; + if (game.mode === 'pair') return pairRoll(); // the server rolls for a pairs game + const [a, b] = await rollDice(); + await startPlacing(a, b); +} + +async function startPlacing(a, b) { + const options = Core.validPlacements(game.board, a, b); + if (options.length === 0) { + await pass(a, b); + return; + } + if (game.placeMode === 'auto') { + await autoPlace(a, b); + return; + } + game.phase = 'place'; + setMsg(placeInstructions()); + render(); +} + +// Auto mode: put the rectangle in a snug spot (same strategy as the +// computer), show it briefly, then go straight to the maths. +async function autoPlace(a, b) { + game.phase = 'busy'; + const move = Core.chooseComputerMove(game.board, a, b); + setMsg(`You rolled ${a} and ${b}. Placing your ${a} by ${b} rectangle…`); + game.cpuPreview = move; + render(); + await waitInGame(AUTO_PLACE_MS); + game.cpuPreview = null; + startAnswer(placeRect(move.x, move.y, move.w, move.h)); +} + +function placeInstructions() { + const [a, b] = game.dice; + const shape = `a ${a} by ${b} rectangle`; + return `You rolled ${a} and ${b}. Draw ${shape}: press on a square and drag.`; +} + +async function pass(a, b) { + const p = currentPlayer(); + game.phase = p.cpu ? 'cpu' : 'rolling'; + p.log.push({ pass: true, a, b }); + p.stats.passes++; + game.passes++; + const who = game.mode !== 'multi' && !p.cpu ? 'You pass' : `${p.name} passes`; + setMsg(`No room for a ${a} × ${b} anywhere. ${who}.`); + if (game.mode === 'class') reportClass({ kind: 'pass' }); // (pairs: the server passes for you) + render(); + if (p.cpu) await finishComputerTurn(cpuStep()); + else await waitInGame(PASS_DELAY); + endTurn(); +} + +// Draw the rectangle on the board. Its points are awarded once the maths is done. +function placeRect(x, y, w, h) { + const [a, b] = game.dice; + const rect = Core.place(game.board, x, y, w, h, game.current); + rect.a = a; + rect.b = b; + rect.solved = false; + // Wardrobe placing effect (the player who placed it chose it; CPUs use yours too) + rect.fx = settings.cosmetics.place !== 'none' ? settings.cosmetics.place : null; + rect.placedAt = performance.now(); + if (rect.fx) animateBoard(PLACE_FX_MS + 50); + game.lastRect = rect; + game.passes = 0; + return rect; +} + + +// Points for a solved rectangle, as the shared rules count them +const awardPoints = (rect, firstTry, bonus = 0) => Match.award(game, rect, firstTry, bonus); + +// ---- human: work out the points + +function startAnswer(rect) { + game.pending = { rect, entry: '', attempts: 0, helped: false, miss: null, startedAt: performance.now(), pausedMs: 0 }; + game.phase = 'answer'; + setMsg('How many squares did you just draw?'); + el.mathQ.textContent = `${rect.a} × ${rect.b}`; + el.answerBox.textContent = ''; + el.answerBox.className = 'answer-box'; + el.countTrail.textContent = ''; + el.mathFeedback.textContent = ''; + el.mathFeedback.className = 'math-feedback'; + el.math.hidden = false; + showStreak(el.streakBadge, currentPlayer().streak); + setKeypadEnabled(true); + el.helpBtn.disabled = false; + el.helpBtn.hidden = Boolean(game.expert); // Expert: no help counting + render(); + startTimer(); + if (window.innerWidth <= 820) el.math.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function setKeypadEnabled(on) { + el.keypad.querySelectorAll('button').forEach((b) => (b.disabled = !on)); +} + +// Can more typing still change the outcome? No once it matches, overshoots, +// or has as many digits as the answer. +// "🔥 5 in a row"; with the Wardrobe's growing flames it gets bigger at 5, 10 and 20. +function showStreak(node, n) { + node.textContent = n >= 2 ? `🔥 ${n} in a row` : ''; + const level = n >= 20 ? 3 : n >= 10 ? 2 : n >= 5 ? 1 : 0; + node.classList.toggle('flames', settings.cosmetics.streak === 'flames' && level > 0); + node.dataset.level = level; +} + + +const answerDecided = Match.answerDecided; + +function typeKey(key) { + const pend = game && game.phase === 'answer' && game.pending; + if (!pend) return; + if (key === 'check') return checkAnswer(); + if (key === 'clear') pend.entry = ''; + else if (key === 'back') pend.entry = pend.entry.slice(0, -1); + else if (pend.entry.length < 3) pend.entry = (pend.entry === '0' ? '' : pend.entry) + key; + el.answerBox.textContent = pend.entry; + el.answerBox.className = 'answer-box'; + // Submit by itself once the answer is decided: it's right, it's bigger than + // the answer, or it has as many digits as the answer. Shorter entries wait + // for ✓, since you might not be done typing. + if (answerDecided(pend.entry, pend.rect.area)) checkAnswer(); +} + +async function checkAnswer() { + const pend = game.pending; + if (!pend || pend.entry === '') return; + const { rect } = pend; + if (Number(pend.entry) === rect.area) { + // the timer bonus goes by how much time was left (not in class games: the server scores those) + const timerLeft = timer && game.answerTime && game.mode !== 'class' && game.mode !== 'pair' ? { secs: game.answerTime, left: timer.remaining / timer.total } : null; + stopTimer(); + const p = currentPlayer(); + const ms = performance.now() - pend.startedAt - pend.pausedMs; + const firstTry = Match.firstTry(pend); // (no wrong answer and no "Help me count") + p.stats.times.push(ms); + p.streak = firstTry ? p.streak + 1 : 0; + p.bestStreak = Math.max(p.bestStreak, p.streak); + const bonus = Progress.answerBonus({ firstTry, ms, streak: p.streak, timer: timerLeft }); + game.phase = 'busy'; + setKeypadEnabled(false); + el.helpBtn.disabled = true; + el.answerBox.className = 'answer-box right'; + el.mathFeedback.className = 'math-feedback good'; + const squares = `+${rect.area} ${rect.area === 1 ? 'square' : 'squares'}`; + el.mathFeedback.textContent = firstTry ? `Correct! ${squares} 🎉` : `You got it! ${squares}`; + // Each bonus pops up on its own, like an achievement. + const mult = game.expert ? GAME_EXPERT_BONUS : 1; // Expert doubles bonus points + for (const part of bonus.parts) toast(`${BONUS_TOASTS[part.label.split(' ')[0]]} ${capitalize(part.label)}`, `+${part.points * mult} bonus points${mult > 1 ? ' (×2 Expert)' : ''}`); + awardPoints(rect, firstTry, bonus.total * mult); + showStreak(el.streakBadge, p.streak); + if (game.mode === 'class' || game.mode === 'pair') reportClass({ kind: 'placed', rect: { x: rect.x, y: rect.y, w: rect.w, h: rect.h }, firstTry, ms: Math.round(ms) }); + if (countsForStats()) { + Progress.recordAnswer(progress, statsName(p), { + a: rect.a, + b: rect.b, + firstTry, + correct: true, + wrongAnswers: pend.miss ? pend.miss.answers : [], + ms, + }); + const learned = Progress.learnedAfterHelp(progress, rect.a, rect.b, firstTry); + celebrate(Progress.awardAchievements(progress, { event: 'answer', correct: true, firstTry, ms, streak: p.streak, a: rect.a, b: rect.b, facts: playerFacts(statsName(p)), size: unlockedTableSize(), learned })); + saveProgress(); + } + game.pending = null; + render(); + await waitInGame(1400); + endTurn(); + return; + } + currentPlayer().streak = 0; + pend.attempts++; + noteMiss(pend, { answer: Number(pend.entry) }); + pend.entry = ''; + currentPlayer().stats.wrong++; + el.answerBox.className = 'answer-box'; + void el.answerBox.offsetWidth; // restart the shake animation + el.answerBox.className = 'answer-box wrong'; + el.answerBox.textContent = ''; + el.mathFeedback.className = 'math-feedback try'; + el.mathFeedback.textContent = TRY_AGAIN[(pend.attempts - 1) % TRY_AGAIN.length]; + if (pend.attempts >= 2 && !pend.helped && !game.expert) { + el.mathFeedback.textContent = "Let's count them together!"; + helpCount(); + } +} + +// Legend: a teen times a one-digit number, split into tens and ones +// (14 × 7 = 10 × 7 + 4 × 7). Null for anything else. + +// Legend: a teen times a one-digit number, split into tens and ones; null for anything else +const legendSplit = (rect) => (game ? (Match.legendSplit(rect, game.difficulty) || {}).split || null : null); + +async function helpCount() { + const pend = game.pending; + if (!pend || pend.helped || game.counting || game.expert) return; // Expert: no help + pend.helped = true; + currentPlayer().stats.helped++; + if (countsForStats()) { + Progress.noteHelp(progress, pend.rect.a, pend.rect.b); + celebrate(Progress.awardAchievements(progress, { event: 'help' })); + saveProgress(); + } + el.helpBtn.disabled = true; + const { rect } = pend; + const split = legendSplit(rect); + if (split) { + // Too many to skip-count: split it, and leave the adding to the player + el.countTrail.textContent = + `Split ${split.teen} into 10 and ${split.ones}: 10 × ${split.other} = ${split.tens}, ` + + `${split.ones} × ${split.other} = ${split.rest}. Now add them up!`; + return; + } + const plan = countPlan(rect); + const totals = []; + const helpStarted = performance.now(); // the timer is paused while counting + await skipCount(rect, plan, COUNT_STEP, (total) => { + totals.push(total); + const units = plan.steps === 1 ? plan.unit : `${plan.unit}s`; + el.countTrail.textContent = `${plan.steps} ${units} of ${plan.group}: ${totals.join(', ')}`; + }); + pend.pausedMs += performance.now() - helpStarted; + if (game.pending === pend) { + el.mathFeedback.className = 'math-feedback'; + el.mathFeedback.textContent = `So what is ${rect.a} × ${rect.b}?`; + } +} + +// A fact goes on the "to practise" list the first time it's missed in a turn. +// Record a missed fact once per turn, with every wrong answer given for it. +// Entries look like { fact: '6 × 7', answers: [48, 36], timeout: false }. + +// Record a missed fact once per turn, with every wrong answer given for it. +// (The shared rule reads the pending answer from the game; a timeout has +// already cleared it, so it's handed over.) +const noteMiss = (pend, how) => Match.noteMiss({ ...game, pending: pend }, how); + +// ---- answer timer (paused while "Help me count" is animating) + +let timer = null; // { total, remaining, last, interval } + +function startTimer() { + stopTimer(); + el.timer.hidden = !game.answerTime; + if (!game.answerTime) return; + const id = gameId; + const total = Progress.timerLength(progress, game.answerTime) * 1000; // (the bonus still follows answerTime) + timer = { total, remaining: total, last: performance.now() }; + renderTimer(); + timer.interval = setInterval(() => { + if (!timer || id !== gameId) return stopTimer(); + const now = performance.now(); + // Paused while "Help me count" is animating or Settings is open. + if (!game.counting && el.settingsDialog.hidden) timer.remaining -= now - timer.last; + timer.last = now; + renderTimer(); + if (timer.remaining <= 0) answerTimeout(); + }, 100); +} + +function stopTimer() { + if (timer) clearInterval(timer.interval); + timer = null; +} + +function renderTimer() { + const left = Math.max(0, timer.remaining); + el.timerNum.textContent = timerText(left); + el.timerFill.style.width = `${(left / timer.total) * 100}%`; + el.timer.classList.toggle('low', left <= 5000); +} + +// Out of time: show the answer, take the rectangle back off the board, no points. +async function answerTimeout() { + stopTimer(); + const pend = game.pending; + if (!pend || game.phase !== 'answer') return; + const p = currentPlayer(); + const { rect } = pend; + game.phase = 'busy'; + game.pending = null; + setKeypadEnabled(false); + el.helpBtn.disabled = true; + noteMiss(pend, { timeout: true }); + p.stats.timeouts++; + p.streak = 0; + if (game.mode === 'class' || game.mode === 'pair') reportClass({ kind: 'timeout' }); + if (countsForStats()) { + Progress.recordAnswer(progress, statsName(p), { a: rect.a, b: rect.b, firstTry: false, correct: false, wrongAnswers: pend.miss.answers, timeout: true }); + saveProgress(); + } + p.log.push({ timeout: true, a: rect.a, b: rect.b }); + el.answerBox.textContent = rect.area; + el.answerBox.className = 'answer-box timeout'; + el.mathFeedback.className = 'math-feedback try'; + el.mathFeedback.textContent = `⏰ Time's up! ${rect.a} × ${rect.b} = ${rect.area}. No points this turn.`; + Core.removeRect(game.board, rect); + game.lastRect = null; + game.passes = 0; // the roll did fit, so this isn't a pass + render(); + await waitInGame(3000); + endTurn(); +} + +// ---- skip counting, shared by the help button and the computer's explanation + +// Count a × b as "a rows (or columns) of b", matching the order of the dice. + +// Count a × b as "a rows (or columns) of b", matching the order of the dice +const countPlan = Match.countPlan; + +async function skipCount(rect, plan, stepMs, onStep) { + game.counting = { rect, byRows: plan.byRows, group: plan.group, shown: 0 }; + render(); + for (let i = 1; i <= plan.steps; i++) { + await waitInGame(stepMs); + game.counting.shown = i; + onStep(i * plan.group, i); + render(); + } + await waitInGame(stepMs); +} + +// ---- computer: show the working step by step + +// "an 8", "an 11", "an 18", but "a 7" +const article = (n) => (/^(8|11|18)$/.test(String(n)) || /^8\d$/.test(String(n)) ? 'an' : 'a'); + +function addStep(html, big = false) { + const li = document.createElement('li'); + li.innerHTML = html; + if (big) li.className = 'big'; + el.steps.append(li); + return li; +} + +async function explainComputerMove(rect) { + const plan = countPlan(rect); + const { a, b, area } = rect; + addStep(`I drew ${article(a)} ${a} by ${b} rectangle. How many squares is that?`); + await waitInGame(cpuStep()); + const split = legendSplit(rect); + if (split) { + addStep(`Split ${split.teen} into 10 and ${split.ones}.`); + await waitInGame(cpuStep()); + addStep(`10 × ${split.other} = ${split.tens} and ${split.ones} × ${split.other} = ${split.rest}.`); + await waitInGame(cpuStep()); + addStep(`${split.tens} + ${split.rest} = ${area}.`); + await waitInGame(cpuStep()); + } else if (a === 1 || b === 1) { + const n = a === 1 ? b : a; + addStep(`It's just 1 ${a === 1 ? 'row' : 'column'} of ${n}, so that's ${n}.`); + await waitInGame(cpuStep()); + } else { + addStep( + `That's ${plan.steps} ${plan.unit}s of ${plan.group}. ` + + `Let's count by ${plan.group}s:` + ); + let trail = null; // created with its first number so it never shows up empty + const totals = []; + await skipCount(rect, plan, cpuStep(), (total) => { + totals.push(total); + trail = trail || addStep(''); + trail.innerHTML = totals.map((t) => `${t}`).join(', '); + }); + } + game.counting = null; + addStep(`${a} × ${b} = ${area}`, true); + awardPoints(rect, true); + render(); +} + +// ---- end of the computer's turn: carry on by itself, or wait for "My turn" + +let continueResolve = null; + + +const gameEndsAfterThisTurn = () => Match.isOver(game); + +async function finishComputerTurn(autoDelay) { + if (game.cpuEnd !== 'button' || gameEndsAfterThisTurn()) { + await waitInGame(autoDelay); + return; + } + const next = game.players[(game.current + 1) % game.players.length]; + el.continueBtn.replaceChildren( + game.mode === 'single' ? 'My turn ' : next.cpu ? `${next.name} ` : `${next.name}'s turn `, + icon('chevron-right') + ); + el.continueBtn.hidden = false; + el.continueBtn.focus({ preventScroll: true }); + await new Promise((resolve) => (continueResolve = resolve)); +} + +function pressContinue() { + if (!continueResolve) return; + const resolve = continueResolve; + continueResolve = null; + el.continueBtn.hidden = true; + resolve(); +} + +function cancelContinue() { + continueResolve = null; // the abandoned turn just never resumes + el.continueBtn.hidden = true; +} + +function endTurn() { + if (game.mode === 'class' || game.mode === 'pair') return beginTurn(); // the server ends these games + if (Match.isOver(game)) { + finishGame(); + return; + } + game.current = (game.current + 1) % game.players.length; + beginTurn(); +} + +// Instant CPU turns: roll, place and score straight away, no explanation. +const INSTANT_CPU_MS = 250; // just long enough for the board to redraw between turns + +async function runInstantComputerTurn() { + const p = currentPlayer(); + const [a, b] = (game.dice = Core.rollForBoard(game.board, game.diceBag, game.rng)); + renderDie(el.dieA, a); + renderDie(el.dieB, b); + const move = Core.chooseComputerMove(game.board, a, b); + if (move) { + const rect = placeRect(move.x, move.y, move.w, move.h); + awardPoints(rect, true); + setMsg(`${p.name} rolled ${a} and ${b}: ${a} × ${b} = ${rect.area}.`); + } else { + p.log.push({ pass: true, a, b }); + p.stats.passes++; + game.passes++; + setMsg(`${p.name} rolled ${a} and ${b}: no room, so it passes.`); + } + render(); + await waitInGame(INSTANT_CPU_MS); + endTurn(); +} + +async function runComputerTurn() { + if (game.cpuInstant) return runInstantComputerTurn(); + await waitInGame(cpuStep()); + const [a, b] = await rollDice(); + const move = Core.chooseComputerMove(game.board, a, b); + if (!move) { + await pass(a, b); + return; + } + setMsg(''); + el.steps.hidden = false; + const hello = currentPlayer().hello ? `${currentPlayer().hello} ` : ''; // CPU character's greeting + addStep(`${hello}I rolled ${article(a)} ${a} and ${article(b)} ${b}.`); + await waitInGame(cpuStep()); + if (game.placeMode === 'draw') { + const where = game.board.rects.length ? 'snug against the others' : 'in a corner'; + addStep( + `I’ll draw it ${where}: ${move.w} across ` + + `and ${move.h} down.` + ); + await drawComputerRect(move); + } else { + addStep(`I’ll put my rectangle here, ${game.board.rects.length ? 'snug against the others' : 'in a corner'}…`); + game.cpuPreview = move; + render(); + } + await waitInGame(cpuStep()); + game.cpuPreview = null; + const rect = placeRect(move.x, move.y, move.w, move.h); + render(); + await explainComputerMove(rect); + await finishComputerTurn(cpuStep()); + endTurn(); +} + +// Grow the computer's rectangle: the first row one square at a time (at +// double speed), then one whole row per step. +async function drawComputerRect(move) { + for (let w = 1; w <= move.w; w++) { + game.cpuPreview = { ...move, w, h: 1 }; + render(); + await waitInGame(cpuStep() / 2); + } + for (let h = 2; h <= move.h; h++) { + game.cpuPreview = { ...move, h }; + render(); + await waitInGame(cpuStep()); + } +} + +function finishGame() { + game.phase = 'over'; + game.dice = null; + render(); + const ranked = game.players + .map((p, i) => ({ ...p, index: i })) + .sort((p, q) => q.score - p.score); + const top = ranked[0].score; + const winners = ranked.filter((p) => p.score === top); + const isSingle = game.mode === 'single'; + if (countsForStats()) recordGame(new Set(winners.map((p) => p.index))); + + if (winners.length > 1) el.resultTitle.textContent = "It's a tie!"; + else if (isSingle) el.resultTitle.textContent = winners[0].cpu ? `${winners[0].name} wins!` : 'You win! 🎉'; + else el.resultTitle.textContent = `${winners[0].name} wins!${winners[0].cpu ? '' : ' 🎉'}`; + + const empty = Core.emptyCount(game.board); + el.resultSub.textContent = + empty === 0 ? 'The board is completely full!' : `Nobody could fit a rectangle. ${empty} squares left empty.`; + + el.resultList.innerHTML = ''; + for (const p of ranked) { + const li = document.createElement('li'); + li.style.setProperty('--c', p.color); + const name = document.createElement('span'); + name.className = 'result-name'; + name.textContent = p.avatar ? `${p.avatar} ${p.name}` : p.name; + if (p.title) name.append(make('span', 'player-title', p.title)); + const pts = document.createElement('span'); + pts.className = 'result-pts'; + pts.textContent = `${p.score} pts`; + // Bar length relative to the top score, so the gap is easy to see. + const bar = document.createElement('span'); + bar.className = 'result-bar'; + const fill = document.createElement('span'); + fill.style.width = `${top ? (p.score / top) * 100 : 0}%`; + bar.append(fill); + li.append(name, pts, bar); + el.resultList.append(li); + } + renderRewards(winners, empty); + refreshNudges('results'); + if (winners.length === 1 && !winners[0].cpu) celebrateWin(); + renderStats(); + setDetailsOpen(false); + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; + el.againBtn.focus(); +} + +// Human players' points go into the shared wallet, and game achievements are +// checked. Shows "+N points" and any new achievements on the end screen. +function renderRewards(winners, empty) { + el.rewards.innerHTML = ''; + const humans = game.players.filter((p) => !p.cpu); + if (!humans.length) return; + const points = humans.reduce((sum, p) => sum + p.score, 0); + Progress.addPoints(progress, points); + if (!countsForStats()) { + // local multiplayer: points only, nothing permanent + saveProgress(); + el.rewards.append(make('p', 'setting-help', 'Multiplayer games are just for fun: they don’t count towards Stats or achievements.')); + return; + } + progress.counters.games++; + // Finishing bonus for bigger boards (single player against the CPU) + const boardBonus = game.mode === 'single' ? Progress.boardFinishBonus(game.size) : 0; + if (boardBonus) { + Progress.addPoints(progress, boardBonus); + const an = [8, 11, 18].includes(game.size) ? 'an' : 'a'; // "an 8×8", "an 11×11", "an 18×18" + toast(`🏁 Finished ${an} ${game.size}×${game.size} game!`, `+${boardBonus} bonus points`); + } + const humanWon = winners.length === 1 && !winners[0].cpu; + // for class stats and homework ("beat the CPU on 8×8", "finish 5 games") + Progress.noteActivity(progress, { kind: 'game', mode: game.mode, difficulty: game.difficulty, size: game.size, won: game.mode === 'single' && humanWon }); + if (humanWon && game.mode === 'single') Progress.recordBoardWin(progress, game.size); + if (game.mode === 'single') completeDaily({ kind: 'game', size: game.size, difficulty: game.difficulty, won: humanWon, level: game.level }); // (today's practice?) + const asked = (p) => p.answered + p.stats.timeouts; + const perfect = humans.some((p) => asked(p) >= 5 && p.firstTry === asked(p)); + const noHelp = humans.some((p) => asked(p) >= 5 && p.stats.helped === 0); + const earned = Progress.awardAchievements(progress, { + event: 'game', + noHelp, + games: progress.counters.games, + wonVsCpu: game.mode === 'single' && humanWon, + humanWon, + full: empty === 0, + perfect, + difficulty: game.difficulty, + bestScore: Math.max(...humans.map((p) => p.score)), + }); + if (game.level) earned.push(...renderLevelResult(humanWon)); + grantStickers(earned); // they show up behind the Stickers button + saveProgress(); + showRewards(earned); +} + +// A Journey game: moving up a level (its boards and dice unlock), or what it +// still takes. Returns the achievements it earned. +function renderLevelResult(humanWon) { + const you = game.players.find((p) => !p.cpu); + const r = Progress.recordLevelGame(progress, game.level, { won: humanWon, answered: you.answered + you.stats.timeouts, firstTry: you.firstTry, helped: you.stats.helped }); + const message = Journey.resultMessage(r); + if (message) { + const line = make('p', 'journey-result' + (r.advanced ? ' up' : '')); + line.append(make('strong', null, message.title), ' ', message.text); + el.rewards.append(line); + } + if (r.advanced) { + el.againBtn.textContent = r.to >= Journey.DONE ? 'Play again' : `Level ${r.to} ›`; + // the speed-ups this board brings: on (Settings turns them back) + for (const [key, value] of r.switches) settings[key] = value; + if (r.switches.length) { + saveSettings(); + toast(...Journey.switchToast(r.switches)); + } + syncSettingsUI(); + syncBoardLocks(); + renderJourney(); + } + return r.earned; +} + +// End of a game: new achievements get their banners. Points and stickers go +// in quietly: the Shop / Wardrobe / Stickers buttons below show what's new. +function showRewards(earned) { + queueAchievements(earned); +} + +// Single-player and class games (and practice) count towards Stats and achievements. +function countsForStats() { + if (game && game.classroom && game.classroom.session.teacher) return false; // the teacher playing an odd one out + return game && ['single', 'class', 'pair'].includes(game.mode); +} + +// Whose times tables an answer counts towards. In a class game you play under +// your first name, but it's still you on this device. +function statsName(p) { + return p.statsName || p.name; +} + +// ---- end-of-game stats + +function make(tag, cls, text) { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (text !== undefined) node.textContent = text; + return node; +} + +// Games saved before wrong answers were recorded store plain '6 × 7' strings. +function normalizeMiss(miss) { + return typeof miss === 'string' ? { fact: miss, answers: [], timeout: false } : miss; +} + +// Merge misses of the same fact: how often, which wrong answers, how many time-outs. +function groupMisses(misses, keyOf) { + const groups = new Map(); + for (const raw of misses) { + const miss = normalizeMiss(raw); + const key = keyOf(miss.fact); + const g = groups.get(key) || { fact: key, count: 0, answers: [], timeouts: 0 }; + g.count++; + g.answers.push(...miss.answers); + if (miss.timeout) g.timeouts++; + groups.set(key, g); + } + return [...groups.values()].sort((a, b) => b.count - a.count); +} + +// "6 × 7 = 42 (×2)" with "said 48, 36 · ⏰ ran out of time" underneath. +function renderPractice(name, color, groups, noneText) { + const block = make('div', 'practice'); + block.style.setProperty('--c', color); + block.append(make('div', 'practice-name', name)); + if (!groups.length) { + block.append(make('span', 'practice-none', noneText)); + return block; + } + const list = make('div', 'facts'); + for (const g of groups) { + const [a, b] = g.fact.split('×').map((n) => Number(n.trim())); + const item = make('div', 'fact'); + item.append(make('span', 'fact-eq', `${g.fact} = ${a * b}${g.count > 1 ? ` (×${g.count})` : ''}`)); + const details = []; + const said = [...new Set(g.answers)].slice(0, 5); + if (said.length) details.push(`said ${said.join(', ')}`); + if (g.timeouts) details.push(g.timeouts > 1 ? `⏰ ran out of time ×${g.timeouts}` : '⏰ ran out of time'); + if (g.rate) details.push(g.rate); + if (details.length) item.append(make('span', 'fact-said', details.join(' · '))); + list.append(item); + } + block.append(list); + return block; +} + +// ---- lifetime times-table grid + +// Biggest times table the player can reach: 6 (Easy), 8 (Medium) or 12 (Hard). +const unlockedTableSize = () => Views.tableSize(progress); + + +const FACT_STATUS = Stats.FACT_STATUS; + +// Which statuses show a best time instead of the icon (Settings → Times-table grid). +const TIMES_SETTING = { mastered: 'timesMastered', learning: 'timesLearning', practice: 'timesPractice' }; + +function showsTime(status) { + return TIMES_SETTING[status] && settings[TIMES_SETTING[status]] === 'time'; +} + +function renderFactLegend() { + const legend = make('div', 'fact-legend'); + for (const [status, { mark, label }] of Object.entries(FACT_STATUS)) { + const item = make('span', 'fact-legend-item'); + item.append(make('span', `fact-cell ${status}`, mark), document.createTextNode(label)); + legend.append(item); + } + return legend; +} + +// Rows and columns 1..N (6, or up to 12 once bigger dice are in play); hover a cell for details. +// Single player and practice play as "You" (personal accounts will replace this). +const isDefaultPlayer = Stats.isDefaultPlayer; + +// size: a fixed size (a teacher or parent looking at a student), else what this player reached +function renderFactGrid(name, facts, size = null) { + const g = Stats.factGrid(facts, { size, unlocked: unlockedTableSize(), showTime: showsTime }); + const wrap = make('div', 'fact-grid-wrap'); + if (name) wrap.append(make('div', 'practice-name', name)); // no label for the default "You" + const grid = make('div', 'fact-grid'); + grid.style.setProperty('--n', g.max + 1); + // Mastery badge: a gold ★ on a line's header once the whole line is mastered (both orders) + const head = ({ n, mastered }) => { + const cell = make('span', 'fact-head' + (mastered ? ' line-mastered' : ''), mastered ? `★${n}` : String(n)); + if (mastered) cell.title = `Every ${n} times fact is mastered!`; + return cell; + }; + grid.append(make('span', 'fact-head', '×')); + for (const h of g.heads) grid.append(head(h)); + g.cells.forEach((row, r) => { + grid.append(head(g.heads[r])); + for (const c of row) { + const cell = make('span', `fact-cell ${c.status}${c.timed ? ' timed' : ''}`, c.mark); + cell.title = c.detail; + grid.append(cell); + } + }); + wrap.append(grid); + return wrap; +} + +function renderStats() { + const dash = '—'; + const secs = (ms) => `${(ms / 1000).toFixed(1)} s`; + const human = (fn) => (p) => (p.cpu ? dash : fn(p)); + const asked = (p) => p.answered + p.stats.timeouts; + const rows = [ + ['Points', (p) => p.score], + ['Squares', (p) => p.squares], + ['Bonus points', human((p) => p.bonus)], + ['Best streak', human((p) => p.bestStreak)], + ['Rectangles drawn', (p) => p.stats.placed], + ['Biggest rectangle', (p) => (p.stats.biggest ? `${p.stats.biggest.a} × ${p.stats.biggest.b} = ${p.stats.biggest.area}` : dash)], + ['Right on first try', human((p) => (asked(p) ? `${p.firstTry} of ${asked(p)}` : dash))], + ['Wrong answers', human((p) => p.stats.wrong)], + ['Used “Help me count”', human((p) => p.stats.helped)], + game.answerTime ? ['Ran out of time', human((p) => p.stats.timeouts)] : null, + ['Average answer time', human((p) => (p.stats.times.length ? secs(p.stats.times.reduce((s, t) => s + t, 0) / p.stats.times.length) : dash))], + ['Fastest answer', human((p) => (p.stats.times.length ? secs(Math.min(...p.stats.times)) : dash))], + ['Passes', (p) => p.stats.passes], + ].filter(Boolean); + + el.stats.innerHTML = ''; + + // Game summary + const total = game.size * game.size; + const filled = total - Core.emptyCount(game.board); + const elapsed = Math.round((Date.now() - game.startedAt) / 1000); + const time = elapsed >= 60 ? `${Math.floor(elapsed / 60)} min ${elapsed % 60} s` : `${elapsed} s`; + el.stats.append( + make('p', 'stats-summary', `⏱ ${time} · ${game.turns} turns · ${filled} of ${total} squares filled (${Math.round((filled / total) * 100)}%)`) + ); + + // Per-player table + const wrap = make('div', 'stats-table-wrap'); + const table = make('table', 'stats-table'); + const head = table.createTHead().insertRow(); + head.append(make('th')); + for (const p of game.players) { + const th = make('th'); + th.style.setProperty('--c', p.color); + th.append(make('span', 'dot'), document.createTextNode(p.name)); + head.append(th); + } + const body = table.createTBody(); + for (const [label, value] of rows) { + const tr = body.insertRow(); + tr.append(make('th', null, label)); + for (const p of game.players) tr.append(make('td', null, String(value(p)))); + } + wrap.append(table); + el.stats.append(wrap); + + // Facts to practise + const humans = game.players.filter((p) => !p.cpu); + if (humans.some((p) => asked(p) > 0)) { + el.stats.append(make('h3', 'stats-heading', 'Facts to practice')); + for (const p of humans) { + const none = asked(p) ? 'Nothing to practice. Every answer right first time! 🌟' : 'No answers this game.'; + el.stats.append(renderPractice(p.name, p.color, groupMisses(p.stats.missed, (f) => f), none)); + } + } +} + +// (game is also changed from other sections) +function setGame(value) { + return (game = value); +} + +// (gameId is also changed from other sections) +function setGameId(value) { + return (gameId = value); +} +export { FACT_STATUS, answerDecided, cancelContinue, continueResolve, currentPlayer, game, gameId, helpCount, humanRoll, isDefaultPlayer, make, placeInstructions, placeRect, pressContinue, renderFactGrid, renderFactLegend, renderPractice, renderStats, resetTurn, rollDice, setGame, setGameId, showRewards, showStreak, startAnswer, startGame, startPlacing, statsName, stopTimer, typeKey, unlockedTableSize, waitInGame }; diff --git a/apps/web/src/multiplication/game/practice.js b/apps/web/src/multiplication/game/practice.js new file mode 100644 index 0000000..392e870 --- /dev/null +++ b/apps/web/src/multiplication/game/practice.js @@ -0,0 +1,447 @@ +// Practice mode: part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import * as Shop from '@blockout/progress/shop'; +import * as Setup from '@blockout/progress/setup'; +import * as Practice from '@blockout/progress/practice'; +import * as Journey from '@blockout/progress/journey'; +import { COUNT_STEP, TRY_AGAIN, el } from './base.js'; +import { BONUS_TOASTS, capitalize, celebrate, progress, saveProgress, toast } from './wallet.js'; +import { settings } from './settings.js'; +import { icon, openSettings, selectIn, setLockBadge } from './wardrobe.js'; +import { FACT_STATUS, answerDecided, cancelContinue, gameId, make, renderFactGrid, renderFactLegend, setGame, setGameId, showStreak, stopTimer, waitInGame } from './play.js'; +import { recordPracticeRound } from './stats.js'; +import { confirmUnlock, refreshNudges } from './shop.js'; +import { completeDaily } from './daily.js'; +import { practiceJourneyDifficulty, practiceJourneyOn, renderJourney } from './journey.js'; + + +// (a round's rules, words and setup are shared with the phone app: @blockout/progress/practice) + +let practiceRoundSize = 5; + +// Whose unlocks Practice's Expert, timers and lengths go by: on the Journey +// the table's difficulty, on Auto the biggest reached table's +const practiceDifficulty = () => practiceJourneyDifficulty() || Practice.autoDifficulty(progress); + +// 5 comes with Practice mode; 10 and 20 are unlocked for each difficulty +const countUnlock = (count) => Practice.countItem(practiceDifficulty(), count); + +let practiceLevel = 'learn'; // 'expert' (shop unlock): no picture, no help button until 2 misses, ×2 bonus + +const EXPERT_BONUS = Practice.PRACTICE_EXPERT_BONUS; + +const LEVEL_HELP = Practice.LEVEL_HELP; + +function renderPracticeLevel() { + // Expert is unlocked for each difficulty + const expertId = Practice.levelItem(practiceDifficulty(), 'expert'); + if (!Progress.isUnlocked(progress, expertId) && practiceLevel === 'expert') practiceLevel = 'learn'; + for (const btn of el.practiceLevel.querySelectorAll('button')) { + btn.classList.toggle('selected', btn.dataset.level === practiceLevel); + setLockBadge(btn, btn.dataset.level === 'expert' ? expertId : null); + } + el.practiceLevelHelp.textContent = LEVEL_HELP[practiceLevel]; + renderPracticeTimer(); +} + +// Expert practice's own timer (Off, then 30 s → 10 s → 5 s from the shop), +// unlocked separately for each difficulty +let practiceTimerSecs = 0; + +const timerUnlock = (secs) => Practice.timerItem(practiceDifficulty(), secs); + +function renderPracticeTimer() { + el.practiceTimerField.hidden = practiceLevel !== 'expert'; + if (!Progress.isUnlocked(progress, timerUnlock(practiceTimerSecs))) practiceTimerSecs = 0; + for (const btn of el.practiceTimer.querySelectorAll('button')) { + btn.classList.toggle('selected', Number(btn.dataset.secs) === practiceTimerSecs); + setLockBadge(btn, timerUnlock(btn.dataset.secs)); + } + // The timer bonus (Expert doubles it): all of it for an instant answer, none when time runs out + el.practiceTimerHelp.textContent = Practice.timerHelp(practiceTimerSecs); +} + +let practice = null; + +let prTimer = null; + +function playerFacts(name) { + return (progress.facts[Progress.playerKey(name)] || { facts: {} }).facts; +} + +// Master difficulty's dice: how often each fact comes up, from the player's +// record. Like practice, but mastered facts are rarer still: whoever reaches +// Master has mastered most of the table, and with practice's weights only +// about a third of the rolls would be weak facts (this makes it ~70%). +const weakFactWeight = (player) => Setup.masterWeight(progress, player ? player.name : 'You'); + +// "×3 and ×4" +const tablesLabel = Shop.tablesLabel; + +// Practice is the Journey (a table a level) or Auto (the facts you need, from +// every table you've reached): the menu shows what Auto will ask +function updatePracticePreview() { + if (el.practiceSetup.hidden) return; + renderPracticeLevel(); + renderPracticeCount(); + if (practiceJourneyOn()) return renderJourney(); // (the level picks the table) + el.startBtn.disabled = false; + el.practicePreview.textContent = Practice.autoPreview(progress, el.practiceName.value.trim() || 'You'); +} + +function renderPracticeCount() { + if (!Progress.isUnlocked(progress, countUnlock(practiceRoundSize))) practiceRoundSize = 5; + for (const btn of el.practiceCount.querySelectorAll('button')) { + btn.classList.toggle('selected', Number(btn.dataset.count) === practiceRoundSize); + setLockBadge(btn, countUnlock(btn.dataset.count)); + } + // The finishing bonus for the chosen length (doubled on Expert) + el.practiceCountHelp.textContent = Practice.countHelp(practiceRoundSize, practiceLevel === 'expert'); +} + +// A Practice Journey round: the level reached sets the ×n table, the length, +// Learn or Expert and the timer +function startPracticeJourney(name) { + const level = Journey.practiceLevel(progress); + const round = Journey.practiceSetup(level); + startPractice(name, round.count, [], round.expert, round.timer, Journey.practiceTier(level), level); +} + +// An Auto round: the facts you need from every table you've reached, with +// the menu's length, Learn or Expert and timer (as far as they're unlocked) +function startPracticeAuto(name) { + const d = Practice.autoDifficulty(progress); + const c = Practice.practiceChoice(progress, d, { level: practiceLevel, timer: practiceTimerSecs, count: practiceRoundSize }); + startPractice(name, c.count, [], c.level === 'expert', c.timer, d, null, true); +} + +// difficulty: whose dice the questions come from (today's practice may pick one) +// journeyLevel: a Practice Journey round on the ×n table; auto: an Auto round +function startPractice(name, count, tables = [], expert = false, timer = 0, difficulty = settings.difficulty, journeyLevel = null, auto = false) { + stopPracticeTimer(); + cancelContinue(); + stopTimer(); + setGameId(gameId + 1); // cancels anything still waiting from a game + setGame(null); + practice = Practice.createRound(progress, { name, difficulty, count, tables, level: expert ? 'expert' : 'learn', timer, journeyLevel, auto }); + practice.earned = []; + el.startScreen.hidden = true; + el.gameScreen.hidden = true; + el.practiceDone.hidden = true; + el.practiceScreen.hidden = false; + nextPracticeQuestion(); +} + +function nextPracticeQuestion() { + stopPracticeTimer(); + if (!practice.current) return finishPractice(); + const { a, b } = practice.current; + // (what only the screen needs: the typing, the clock, the counting) + Object.assign(practice.current, { entry: '', counting: false, startedAt: performance.now(), pausedMs: 0 }); + el.prCounter.textContent = Practice.counter(practice); + showStreak(el.prStreak, practice.streak); + el.prBar.style.width = `${(practice.index / practice.queue.length) * 100}%`; + el.prQ.textContent = `${a} × ${b}`; + el.prAnswer.textContent = ''; + el.prAnswer.className = 'answer-box'; + el.prTrail.textContent = ''; + el.prFeedback.textContent = ''; + el.prFeedback.className = 'math-feedback'; + el.prHelp.disabled = false; + el.prHelp.hidden = practice.expert; // Expert: no help button (the picture still helps after 2 misses) + el.prKeypad.querySelectorAll('button').forEach((btn) => (btn.disabled = false)); + renderPracticeArray(a, b); + el.prArray.hidden = practice.expert; // Expert: no picture to count + startPracticeTimer(); +} + +// The fact as a rectangle: a rows of b squares, with a slot for each row's running total. +function renderPracticeArray(a, b) { + el.prArray.innerHTML = ''; + el.prArray.classList.remove('counting'); + el.prArray.style.setProperty('--cols', b); + for (let r = 0; r < a; r++) { + for (let c = 0; c < b; c++) { + const cell = make('span', 'cell'); + cell.dataset.row = r; + el.prArray.append(cell); + } + const total = make('span', 'row-total', String((r + 1) * b)); + total.dataset.row = r; + el.prArray.append(total); + } +} + +function typePractice(key) { + const q = practice && practice.current; + if (!q || q.done || el.practiceScreen.hidden) return; + if (key === 'check') return checkPractice(); + if (key === 'clear') q.entry = ''; + else if (key === 'back') q.entry = q.entry.slice(0, -1); + else if (q.entry.length < 3) q.entry = (q.entry === '0' ? '' : q.entry) + key; + el.prAnswer.textContent = q.entry; + el.prAnswer.className = 'answer-box'; + if (answerDecided(q.entry, q.area)) checkPractice(); // right, too big, or all digits typed +} + +function practiceLocked(on) { + el.prKeypad.querySelectorAll('button').forEach((btn) => (btn.disabled = on)); + el.prHelp.disabled = on; +} + +async function checkPractice() { + const q = practice.current; + if (!q || q.done || q.entry === '') return; + const timerLeft = prTimer && practice.timer ? { secs: practice.timer, left: prTimer.remaining / prTimer.total } : null; + const ms = performance.now() - q.startedAt - q.pausedMs; + const res = Practice.answer(progress, practice, q.entry, { ms, timer: timerLeft }); + if (res.correct) { + stopPracticeTimer(); + practiceLocked(true); + for (const part of res.bonus.parts) { + const extra = practice.expert ? ` (×${EXPERT_BONUS} Expert)` : ''; + toast(`${BONUS_TOASTS[part.label.split(' ')[0]]} ${capitalize(part.label)}`, `+${part.points} bonus points${extra}`); + } + practice.earned.push(...celebrate(res.earned)); + saveProgress(); + el.prAnswer.className = 'answer-box right'; + el.prFeedback.className = 'math-feedback good'; + el.prFeedback.textContent = res.text; + showStreak(el.prStreak, practice.streak); + await waitInGame(1100); + Practice.next(practice); + nextPracticeQuestion(); + return; + } + q.entry = ''; + showStreak(el.prStreak, 0); + el.prAnswer.className = 'answer-box'; + void el.prAnswer.offsetWidth; // restart the shake + el.prAnswer.className = 'answer-box wrong'; + el.prAnswer.textContent = ''; + el.prFeedback.className = 'math-feedback try'; + el.prFeedback.textContent = TRY_AGAIN[(res.attempts - 1) % TRY_AGAIN.length]; + // Learn: count it out after two misses. Expert: no help at all. + if (res.helpNow) { + el.prFeedback.textContent = "Let's count them together!"; + practiceHelp(); + } +} + +// Light up the rectangle one row at a time with running totals. +async function practiceHelp() { + const q = practice && practice.current; + if (!q || q.done || q.helped || practice.expert) return; // Expert gets no help + const h = Practice.help(progress, practice); + if (!h) return; + practice.earned.push(...celebrate(h.earned)); + saveProgress(); + q.counting = true; + el.prHelp.disabled = true; + el.prArray.hidden = false; // Expert hides it until now + const started = performance.now(); + el.prArray.classList.add('counting'); + const totals = []; + for (let r = 0; r < q.a; r++) { + await waitInGame(COUNT_STEP); + if (practice.current !== q) return; + el.prArray.querySelectorAll(`.cell[data-row="${r}"]`).forEach((c) => c.classList.add('counted')); + el.prArray.querySelector(`.row-total[data-row="${r}"]`).classList.add('shown'); + totals.push((r + 1) * q.b); + el.prTrail.textContent = `${q.a} ${q.a === 1 ? 'row' : 'rows'} of ${q.b}: ${totals.join(', ')}`; + } + q.counting = false; + q.pausedMs += performance.now() - started; + if (!q.done) { + el.prFeedback.className = 'math-feedback'; + el.prFeedback.textContent = `So what is ${q.a} × ${q.b}?`; + } +} + +// Answer timer from Settings; paused while counting or while Settings is open. +// Only Expert practice is timed; Learn never shows a clock. +// "7s", or "4:32" for a minute or more +function timerText(ms) { + const secs = Math.ceil(ms / 1000); + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`; +} + +function startPracticeTimer() { + const secs = practice.expert ? practice.timer : 0; // Expert practice's own timer + el.prTimer.hidden = !secs; + if (!secs) return; + const q = practice.current; + const total = Progress.timerLength(progress, secs) * 1000; + prTimer = { total, remaining: total, last: performance.now() }; + const draw = () => { + const left = Math.max(0, prTimer.remaining); + el.prTimerNum.textContent = timerText(left); + el.prTimerFill.style.width = `${(left / total) * 100}%`; + el.prTimer.classList.toggle('low', left <= 5000); + }; + draw(); + prTimer.interval = setInterval(() => { + if (!practice || practice.current !== q || q.done) return stopPracticeTimer(); + const now = performance.now(); + if (!q.counting && el.settingsDialog.hidden) prTimer.remaining -= now - prTimer.last; + prTimer.last = now; + draw(); + if (prTimer.remaining <= 0) practiceTimeout(); + }, 100); +} + +function stopPracticeTimer() { + if (prTimer) clearInterval(prTimer.interval); + prTimer = null; +} + +async function practiceTimeout() { + stopPracticeTimer(); + const q = practice.current; + const out = Practice.timeout(progress, practice); + if (!out) return; + practiceLocked(true); + saveProgress(); + el.prAnswer.textContent = q.area; + el.prAnswer.className = 'answer-box timeout'; + el.prFeedback.className = 'math-feedback try'; + el.prFeedback.textContent = out.text; + await waitInGame(2200); + Practice.next(practice); + nextPracticeQuestion(); +} + +function finishPractice() { + stopPracticeTimer(); + el.prBar.style.width = '100%'; + const pr = practice; + const end = Practice.finish(progress, pr); + if (end.finishToast) toast(...end.finishToast); + recordPracticeRound(end.record); + pr.earned.push(...celebrate(end.earned)); + pr.earned.push(...completeDaily({ kind: 'practice', tables: pr.tables, count: pr.count })); // (today's practice?) + saveProgress(); + el.pdSub.textContent = end.sub; + // a Journey round: on to the next table, or what it still takes + const message = end.journey && Journey.practiceResultMessage(end.journey); + el.pdJourney.hidden = !message; + el.pdJourney.classList.toggle('up', Boolean(end.journey && end.journey.advanced)); + if (message) el.pdJourney.replaceChildren(make('strong', null, message.title), ' ', message.text); + // moved up: on to the next round or table ("Expert ›", "×4 ›") + const up = end.journey && end.journey.advanced && !Journey.practiceDone(progress); + el.pdAgain.textContent = up ? Journey.practiceNextLabel(end.journey.to) : 'Practice again'; + if (end.journey && end.journey.advanced) renderJourney(); + + // Which facts moved, e.g. "3 × 4: Needs practice → Learning" + el.pdBody.innerHTML = ''; + const facts = playerFacts(pr.name); + el.pdBody.append(make('h3', 'stats-heading', 'What changed')); + if (!end.changes.length) el.pdBody.append(make('p', 'practice-none', 'No changes this time. Keep practicing and your facts will move up!')); + for (const ch of end.changes) { + const row = make('div', 'practice-change'); + row.append(make('span', null, ch.fact), make('span', ch.up ? 'up' : 'down', `${FACT_STATUS[ch.was].label} → ${FACT_STATUS[ch.now].label}`)); + el.pdBody.append(row); + } + el.pdBody.append(make('h3', 'stats-heading centered', 'Times table')); + el.pdBody.append(renderFactLegend()); + el.pdBody.append(renderFactGrid('', facts)); + + setPracticeDetailsOpen(false); + refreshNudges('pd'); + el.practiceDone.hidden = false; + el.practiceDone.querySelector('.dialog').scrollTop = 0; + el.pdAgain.focus(); +} + +function leavePractice() { + stopPracticeTimer(); + setGameId(gameId + 1); + practice = null; + el.practiceDone.hidden = true; + el.practiceScreen.hidden = true; + el.startScreen.hidden = false; + updatePracticePreview(); +} + +// "What changed" and the times table sit behind Show details, like the game results. +function setPracticeDetailsOpen(open) { + el.pdBody.hidden = !open; + el.pdDetailsBtn.replaceChildren(icon('chart-column'), open ? ' Hide details' : ' Show details'); + el.pdDetailsBtn.setAttribute('aria-expanded', String(open)); +} + +// (practice is also changed from other sections) +function setPractice(value) { + return (practice = value); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.practiceTimer.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + practiceTimerSecs = Number(btn.dataset.secs); + renderPracticeTimer(); + }); + + el.practiceLevel.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + practiceLevel = btn.dataset.level; + renderPracticeLevel(); + renderPracticeCount(); // Expert doubles the finishing bonus + }); + + el.practiceName.addEventListener('input', updatePracticePreview); + + el.practiceCount.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + practiceRoundSize = Number(btn.dataset.count); + selectIn(el.practiceCount, btn); + renderPracticeCount(); + }); + + el.prKeypad.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (btn) typePractice(btn.dataset.key); + }); + + el.prHelp.addEventListener('click', practiceHelp); + + el.pdAgain.addEventListener('click', () => { + // a Journey round goes on with the level reached (the next table, after a good round) + if (practice.journeyLevel && !Journey.practiceDone(progress)) return startPracticeJourney(practice.name); + if (practice.auto) return startPracticeAuto(practice.name); // (picked again: what you need now) + startPractice(practice.name, practice.count, practice.tables, practice.expert, practice.timer, practice.difficulty); + }); + + el.pdMenu.addEventListener('click', leavePractice); + + el.pdDetailsBtn.addEventListener('click', () => setPracticeDetailsOpen(el.pdBody.hidden)); + + el.practiceMenuBtn.addEventListener('click', () => { + if (practice && practice.index < practice.queue.length && !confirm('Stop practicing and go back to the menu?')) return; + leavePractice(); + }); + + el.practiceSettingsBtn.addEventListener('click', () => openSettings(el.practiceSettingsBtn)); + + document.addEventListener('keydown', (e) => { + if (!practice || el.practiceScreen.hidden || !el.practiceDone.hidden) return; + if (!el.settingsDialog.hidden || !el.shopDialog.hidden) return; + if (e.target.closest('.overlay, input')) return; // e.g. Esc that just closed a dialog + if (/^[0-9]$/.test(e.key)) typePractice(e.key); + else if (e.key === 'Backspace') typePractice('back'); + else if (e.key === 'Escape') typePractice('clear'); + else if (e.key === 'Enter') typePractice('check'); + else return; + e.preventDefault(); + }); +} + +export { playerFacts, practiceLevel, practiceRoundSize, practiceTimerSecs, setPractice, startPractice, startPracticeAuto, startPracticeJourney, stopPracticeTimer, tablesLabel, timerText, updatePracticePreview, weakFactWeight }; diff --git a/apps/web/src/multiplication/game/render.js b/apps/web/src/multiplication/game/render.js new file mode 100644 index 0000000..6e8eee8 --- /dev/null +++ b/apps/web/src/multiplication/game/render.js @@ -0,0 +1,559 @@ +// Rendering: part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import { PIPS, ctx, el } from './base.js'; +import { settings } from './settings.js'; +import { look } from './wardrobe.js'; +import { currentPlayer, game, make } from './play.js'; +import { ordinal, pairLiveTick } from './classroom.js'; +import { dragRect, judgeDrawn } from './input.js'; + +function setMsg(text) { + el.turnMsg.textContent = text; + el.turnMsg.classList.remove('oops'); +} + +// Pips for a normal d6; 8- and 12-sided dice show the number. +// Pips for a normal d6 (dots, or the Wardrobe's hearts/stars/…); 8- and +// 12-sided dice show the number. `skin` defaults to the Wardrobe's dice. +function renderDie(die, value, sides = game ? game.sides : 6, skin = settings.cosmetics.dice) { + die.innerHTML = ''; + for (const c of [...die.classList]) if (c.startsWith('skin-')) die.classList.remove(c); + die.classList.add(`skin-${skin}`); + die.classList.toggle('blank', !value); + die.classList.toggle('numbered', Boolean(value) && sides > 6); + die.setAttribute('role', 'img'); + die.setAttribute('aria-label', value ? `Die showing ${value}` : 'Die not rolled'); + if (!value) return; + if (sides > 6) { + const num = document.createElement('span'); + num.className = 'die-num'; + num.textContent = value; + die.append(num); + return; + } + const on = PIPS[value]; + const glyph = look('pips').glyph; // e.g. '❤️'; plain dots when undefined + for (let i = 0; i < 9; i++) { + const cell = document.createElement('span'); + if (on.includes(i)) { + cell.className = glyph ? 'pip pip-glyph' : 'pip'; + if (glyph) cell.textContent = glyph; + } + die.append(cell); + } +} + +function render() { + if (!game) return; + const p = currentPlayer(); + el.turnPanel.style.setProperty('--turn-color', p.color); + el.turnLabel.textContent = + game.phase === 'over' + ? 'Game over' + : game.mode === 'class' + ? game.classroom.round + ? `Roll ${game.classroom.round} of ${game.classroom.view.settings.rounds}` + : 'Class game' + : game.mode === 'pair' + ? game.current === game.classroom.me + ? 'Your turn' + : `${p.name}'s turn` + : game.mode === 'single' && !p.cpu + ? 'Your turn' + : `${p.name}'s turn`; + el.rollBtn.disabled = game.phase !== 'roll'; + el.rollBtn.hidden = p.cpu || game.mode === 'class' || !['roll', 'rolling'].includes(game.phase); + el.progress.hidden = !game.showProgress; + if (game.showProgress) renderProgress(); + el.canvas.style.cursor = game.phase === 'place' ? 'crosshair' : 'default'; + renderScores(); + drawBoard(); + pairLiveTick(); // pairs: your partner watches your move +} + +// "Board: 24 of 400 filled", with a bar split into each player's colour. +function renderProgress() { + const total = game.size * game.size; + const filled = total - Core.emptyCount(game.board); + el.progressText.innerHTML = + `Board: ${filled} of ${total} filled` + + `${filled} / ${total} filled`; + el.progressBar.setAttribute('aria-valuemin', '0'); + el.progressBar.setAttribute('aria-valuemax', String(total)); + el.progressBar.setAttribute('aria-valuenow', String(filled)); + const perPlayer = game.players.map(() => 0); + for (const rect of game.board.rects) perPlayer[rect.player] += rect.area; + // Reuse the segments so their widths animate as the board fills. + if (el.progressBar.children.length !== game.players.length) { + el.progressBar.innerHTML = ''; + for (let i = 0; i < game.players.length; i++) el.progressBar.append(document.createElement('span')); + } + game.players.forEach((p, i) => { + const seg = el.progressBar.children[i]; + seg.style.width = `${(perPlayer[i] / total) * 100}%`; + seg.style.background = p.color; + }); +} + +function renderScores() { + el.scores.innerHTML = ''; + game.players.forEach((p, i) => { + const card = document.createElement('div'); + card.className = 'score-card' + (i === game.current && game.phase !== 'over' ? ' active' : ''); + card.style.setProperty('--c', p.color); + + const head = document.createElement('div'); + head.className = 'score-head'; + const name = document.createElement('span'); + name.className = 'score-name'; + name.textContent = p.avatar ? `${p.avatar} ${p.name}` : p.name; + if (p.title) { + name.append(make('span', 'player-title', p.title)); + } + const pts = document.createElement('span'); + pts.className = 'score-pts'; + pts.textContent = p.score; + head.append(name, pts); + card.append(head); + if (game.mode === 'class' && game.classroom.view && game.classroom.view.of > 1) { + const { you, of } = game.classroom.view; + card.append(make('div', 'score-rank', `🏫 ${you.tied ? 'Tied for ' : ''}${ordinal(you.rank)} of ${of} in the class`)); + } + + if (p.log.length) { + const log = document.createElement('ul'); + log.className = 'score-log'; + for (const entry of p.log) { + const li = document.createElement('li'); + if (entry.pass) { + li.className = 'pass'; + li.textContent = `${entry.a}×${entry.b} pass`; + } else if (entry.timeout) { + li.className = 'timeout'; + li.textContent = `${entry.a}×${entry.b} ⏰`; + } else { + li.textContent = `${entry.a}×${entry.b}=${entry.area}${entry.bonus ? ` +${entry.bonus}` : ''}`; + if (entry.star) li.className = 'star'; + } + log.append(li); + } + card.append(log); + } + el.scores.append(card); + }); +} + +let cssSize = 0; + +function resizeBoard() { + if (!game) return; + const wrapStyle = getComputedStyle(el.boardWrap); + const pad = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight); + const available = el.boardWrap.clientWidth - pad; + const narrow = window.innerWidth <= 820; + // Leave room for the header, and on phones for the dice panel below the board. + const maxByHeight = window.innerHeight - (narrow ? 300 : 110); + cssSize = Math.max(200, Math.floor(Math.min(available, maxByHeight, 760))); + const dpr = window.devicePixelRatio || 1; + el.canvas.style.width = el.canvas.style.height = `${cssSize}px`; + el.canvas.width = el.canvas.height = Math.round(cssSize * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawBoard(); +} + +// Board colours come from the CSS theme, so the canvas follows light/dark mode. +let pal = {}; + +function readPalette() { + const css = getComputedStyle(document.documentElement); + const v = (name) => css.getPropertyValue(name).trim(); + pal = { + bg: v('--board-bg'), + grid: v('--board-grid'), + edge: v('--board-edge'), + fade: v('--board-fade'), + wrong: v('--board-wrong'), + bad: v('--bad'), + tagBg: v('--invert-bg'), + tagInk: v('--invert-ink'), + }; + Object.assign(pal, BOARD_THEMES[settings.cosmetics.board] || {}); // Wardrobe board +} + +// Wardrobe boards: colours plus a little decoration. Graph paper follows the theme. +const BOARD_THEMES = { + graph: null, + notebook: { bg: '#fdfdf6', grid: '#b9d3ea', edge: '#2b2a33', hint: '#8a8f99', fade: 'rgba(253, 253, 246, 0.65)', decor: 'notebook' }, + chalkboard: { bg: '#2f4f3a', grid: 'rgba(255, 255, 255, 0.16)', edge: '#1b3024', hint: '#d7e8dc', fade: 'rgba(47, 79, 58, 0.7)', wrong: '#c9d6cc', decor: 'chalk' }, + beach: { bg: '#f4e1b0', grid: 'rgba(150, 110, 50, 0.25)', edge: '#b58a4a', hint: '#8a6a3a', fade: 'rgba(244, 225, 176, 0.65)', decor: 'sand' }, + space: { bg: '#10143a', grid: 'rgba(255, 255, 255, 0.12)', edge: '#3a3f7a', hint: '#aab0e0', fade: 'rgba(16, 20, 58, 0.7)', wrong: '#aab0e0', decor: 'stars' }, + snow: { bg: '#eef6ff', grid: '#c8dcf0', edge: '#7a9cc0', hint: '#6d86a4', fade: 'rgba(238, 246, 255, 0.65)', decor: 'flakes' }, +}; + +// Repeatable pseudo-random numbers so decorations don't jump around on every redraw. +function decorRandom(seed) { + let a = seed; + return () => { + a = (a * 1103515245 + 12345) % 2147483648; + return a / 2147483648; + }; +} + +function drawBoardDecor(kind, cell) { + const r = decorRandom(7); + ctx.save(); + if (kind === 'notebook') { + ctx.strokeStyle = 'rgba(230, 110, 110, 0.7)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cell * 0.5, 0); + ctx.lineTo(cell * 0.5, cssSize); + ctx.stroke(); + } else if (kind === 'stars' || kind === 'flakes' || kind === 'sand' || kind === 'chalk') { + const count = kind === 'sand' ? 260 : kind === 'chalk' ? 40 : 90; + for (let i = 0; i < count; i++) { + const x = r() * cssSize; + const y = r() * cssSize; + const size = kind === 'chalk' ? 6 + r() * 14 : 0.6 + r() * (kind === 'flakes' ? 2.2 : 1.6); + ctx.fillStyle = + kind === 'stars' ? `rgba(255, 255, 255, ${0.35 + r() * 0.6})` : + kind === 'flakes' ? 'rgba(160, 190, 225, 0.6)' : + kind === 'sand' ? 'rgba(160, 120, 60, 0.35)' : 'rgba(255, 255, 255, 0.05)'; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); +} + +// Wardrobe rectangle patterns: light marks over your own rectangles. +const patternCache = {}; + +function rectPattern(kind) { + if (patternCache[kind]) return patternCache[kind]; + const tile = document.createElement('canvas'); + tile.width = tile.height = 16; + const g = tile.getContext('2d'); + g.strokeStyle = g.fillStyle = 'rgba(255, 255, 255, 0.35)'; + g.lineWidth = 3; + if (kind === 'stripes') { + g.beginPath(); + g.moveTo(-4, 20); + g.lineTo(20, -4); + g.moveTo(-4, 4); + g.lineTo(4, -4); + g.moveTo(12, 20); + g.lineTo(20, 12); + g.stroke(); + } else if (kind === 'dots') { + g.beginPath(); + g.arc(4, 4, 2.5, 0, Math.PI * 2); + g.arc(12, 12, 2.5, 0, Math.PI * 2); + g.fill(); + } else if (kind === 'bricks') { + g.lineWidth = 1.5; + g.strokeRect(0, 0.75, 16, 7.5); + g.beginPath(); + g.moveTo(8, 8); + g.lineTo(8, 16); + g.stroke(); + } else if (kind === 'checker') { + g.fillRect(0, 0, 8, 8); + g.fillRect(8, 8, 8, 8); + } else if (kind === 'wood') { + g.lineWidth = 1.5; + g.beginPath(); + g.moveTo(0, 4); + g.bezierCurveTo(5, 2, 11, 6, 16, 4); + g.moveTo(0, 11); + g.bezierCurveTo(5, 13, 11, 9, 16, 11); + g.stroke(); + } + return (patternCache[kind] = ctx.createPattern(tile, 'repeat')); +} + +// ---- placing effects: redraw the board for a moment after a rectangle lands +const PLACE_FX_MS = 650; + +let boardAnimUntil = 0; + +function animateBoard(ms) { + const already = boardAnimUntil > performance.now(); + boardAnimUntil = Math.max(boardAnimUntil, performance.now() + ms); + if (already) return; + const step = () => { + drawBoard(); + if (performance.now() < boardAnimUntil) requestAnimationFrame(step); + }; + requestAnimationFrame(step); +} + +const easeOutBack = (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2); + +function drawBoard() { + if (!game || !cssSize) return; + readPalette(); + const n = game.size; + const cell = cssSize / n; + ctx.clearRect(0, 0, cssSize, cssSize); + ctx.fillStyle = pal.bg; + ctx.fillRect(0, 0, cssSize, cssSize); + if (pal.decor) drawBoardDecor(pal.decor, cell); + + // grid + ctx.strokeStyle = pal.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i <= n; i++) { + const v = Math.round(i * cell) + 0.5; + ctx.moveTo(v, 0); + ctx.lineTo(v, cssSize); + ctx.moveTo(0, v); + ctx.lineTo(cssSize, v); + } + ctx.stroke(); + + for (const rect of game.board.rects) { + drawRect(rect, game.players[rect.player].color, cell, rect === game.lastRect); + } + + // pairs: your partner's move as they make it + if (game.mode === 'pair' && game.partnerLive && game.partnerLive.rect) { + drawPartnerLive(game.partnerLive, game.players[1 - game.classroom.me].color, cell); + } + + if (game.cpuPreview) drawPreview({ ...game.cpuPreview, valid: true }, currentPlayer().color, cell); + if (game.phase === 'place' && game.placeMode === 'draw') { + if (game.drag) drawDragging(dragRect(), currentPlayer().color, cell); + else if (game.hover) drawHoverCell(game.hover, currentPlayer().color, cell); + } + + ctx.strokeStyle = pal.edge; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, cssSize - 2, cssSize - 2); +} + +// Your partner's rectangle: dashed while they're still moving it, then solid +// with a "?" while they work out the answer. +function drawPartnerLive(live, color, cell) { + const { x, y, w, h } = live.rect; + ctx.save(); + if (live.phase === 'answer') { + ctx.fillStyle = color; + ctx.globalAlpha = 0.55; + ctx.fillRect(x * cell, y * cell, w * cell, h * cell); + ctx.globalAlpha = 1; + ctx.fillStyle = '#fff'; + ctx.font = `900 ${Math.max(14, Math.min(w, h) * cell * 0.5)}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('?', (x + w / 2) * cell, (y + h / 2) * cell); + } else { + ctx.fillStyle = color; + ctx.globalAlpha = 0.18; + ctx.fillRect(x * cell, y * cell, w * cell, h * cell); + ctx.globalAlpha = 1; + } + ctx.setLineDash(live.phase === 'answer' ? [] : [6, 4]); + ctx.strokeStyle = shade(color, -0.3); + ctx.lineWidth = 2.5; + ctx.strokeRect(x * cell + 1.5, y * cell + 1.5, w * cell - 3, h * cell - 3); + ctx.restore(); +} + +function drawRect(rect, color, cell, highlight) { + const x = rect.x * cell; + const y = rect.y * cell; + const w = rect.w * cell; + const h = rect.h * cell; + const t = rect.fx ? Math.min(1, (performance.now() - rect.placedAt) / PLACE_FX_MS) : 1; + if (rect.fx === 'pop' && t < 1) return drawPopIn(rect, color, cell, t); + const owner = game.players[rect.player]; + ctx.save(); + if (rect.fx === 'glow' && t < 1) { + ctx.shadowColor = color; + ctx.shadowBlur = 36 * (1 - t); + } + ctx.fillStyle = color; + ctx.globalAlpha = 0.82; + ctx.fillRect(x, y, w, h); + ctx.restore(); + if (!owner.cpu && settings.cosmetics.pattern !== 'none') { + ctx.fillStyle = rectPattern(settings.cosmetics.pattern); + ctx.fillRect(x, y, w, h); + } + ctx.strokeStyle = shade(color, -0.35); + ctx.lineWidth = highlight ? 3.5 : 2; + ctx.strokeRect(x + 1, y + 1, w - 2, h - 2); + if (rect.fx === 'ripple' && t < 1) { + const grow = t * cell * 0.9; + ctx.save(); + ctx.globalAlpha = 1 - t; + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.strokeRect(x - grow, y - grow, w + grow * 2, h + grow * 2); + ctx.restore(); + } + if (game.counting && game.counting.rect === rect) drawCounting(game.counting, color, cell); + else drawLabel(rect, x, y, w, h, cell); +} + +// Pop-in: the squares spring up one after another. +function drawPopIn(rect, color, cell, t) { + const total = rect.w * rect.h; + ctx.fillStyle = color; + for (let k = 0; k < total; k++) { + const local = Math.max(0, Math.min(1, (t - (k / total) * 0.55) / 0.45)); + if (!local) continue; + const s = easeOutBack(local); + const cx = (rect.x + (k % rect.w) + 0.5) * cell; + const cy = (rect.y + Math.floor(k / rect.w) + 0.5) * cell; + const half = (cell / 2) * s; + ctx.globalAlpha = 0.82; + ctx.fillRect(cx - half, cy - half, half * 2, half * 2); + } + ctx.globalAlpha = 1; +} + +// Skip counting: rows (or columns) already counted stay bright and show the +// running total; the rest are faded out. +function drawCounting({ rect, byRows, shown }, color, cell) { + const stripes = byRows ? rect.h : rect.w; + const group = byRows ? rect.w : rect.h; + for (let i = 0; i < stripes; i++) { + const sx = (rect.x + (byRows ? 0 : i)) * cell; + const sy = (rect.y + (byRows ? i : 0)) * cell; + const sw = (byRows ? rect.w : 1) * cell; + const sh = (byRows ? 1 : rect.h) * cell; + if (i >= shown) { + ctx.fillStyle = pal.fade; + ctx.fillRect(sx, sy, sw, sh); + continue; + } + ctx.strokeStyle = shade(color, -0.45); + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, sw - 4, sh - 4); + const size = Math.max(10, Math.min(cell * 0.55, 24)); + ctx.font = `900 ${size}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 4; + ctx.strokeStyle = shade(color, -0.5); + ctx.fillStyle = '#ffffff'; + const text = String((i + 1) * group); + ctx.strokeText(text, sx + sw / 2, sy + sh / 2); + ctx.fillText(text, sx + sw / 2, sy + sh / 2); + } +} + +function drawLabel(rect, x, y, w, h, cell) { + const size = Math.max(9, Math.min(cell * 0.5, 22)); + ctx.font = `800 ${size}px system-ui, sans-serif`; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const cx = x + w / 2; + const cy = y + h / 2; + if (!rect.solved) { + ctx.font = `900 ${Math.max(12, Math.min(cell * 0.9, 36))}px system-ui, sans-serif`; + ctx.fillText('?', cx, cy); + return; + } + const full = `${rect.a}×${rect.b}=${rect.area}`; + if (ctx.measureText(full).width <= w - 8) { + ctx.fillText(full, cx, cy); + } else if (h > w && ctx.measureText(full).width <= h - 8) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-Math.PI / 2); + ctx.fillText(full, 0, 0); + ctx.restore(); + } else if (ctx.measureText(String(rect.area)).width <= w - 4) { + ctx.fillText(String(rect.area), cx, cy); + } +} + +// The rectangle a human is dragging out, labelled with its current size. +// Player colour when it matches the roll and fits, grey when the size is +// wrong, red when it overlaps. +function drawDragging(r, color, cell) { + const verdict = judgeDrawn(r); + const tint = !verdict.fits ? pal.bad : verdict.ok ? color : pal.wrong; + const x = r.x * cell; + const y = r.y * cell; + const w = r.w * cell; + const h = r.h * cell; + ctx.fillStyle = tint; + ctx.globalAlpha = verdict.ok ? 0.55 : 0.3; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.strokeStyle = shade(tint, -0.3); + ctx.lineWidth = 2.5; + ctx.strokeRect(x + 1.5, y + 1.5, w - 3, h - 3); + + const label = `${r.w} × ${r.h}${verdict.ok ? ' ✓' : ''}`; + const size = Math.max(12, Math.min(cell * 0.6, 24)); + ctx.font = `900 ${size}px system-ui, sans-serif`; + const tw = ctx.measureText(label).width + 12; + const th = size + 8; + // Tag just above the rectangle (or inside the top if there's no room). + const tx = Math.max(2, Math.min(x + w / 2 - tw / 2, cssSize - tw - 2)); + const ty = y - th - 4 >= 0 ? y - th - 4 : y + 4; + ctx.fillStyle = pal.tagBg; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(tx, ty, tw, th, 6); + else ctx.rect(tx, ty, tw, th); + ctx.fill(); + ctx.fillStyle = pal.tagInk; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, tx + tw / 2, ty + th / 2 + 1); +} + +function drawHoverCell({ c, r }, color, cell) { + ctx.strokeStyle = color; + ctx.lineWidth = 2.5; + ctx.strokeRect(c * cell + 2, r * cell + 2, cell - 4, cell - 4); +} + +function drawPreview(p, color, cell) { + const x = p.x * cell; + const y = p.y * cell; + const w = p.w * cell; + const h = p.h * cell; + ctx.fillStyle = p.valid ? color : pal.bad; + ctx.globalAlpha = p.valid ? 0.35 : 0.22; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.setLineDash([6, 4]); + ctx.strokeStyle = p.valid ? shade(color, -0.3) : pal.bad; + ctx.lineWidth = 2.5; + ctx.strokeRect(x + 1.5, y + 1.5, w - 3, h - 3); + ctx.setLineDash([]); + if (!p.valid) { + ctx.fillStyle = pal.bad; + ctx.font = `800 ${Math.max(10, Math.min(cell * 0.5, 20))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('✕', x + w / 2, y + h / 2); + } +} + +// Darken (negative amount) or lighten a #rrggbb colour. +function shade(hex, amount) { + const n = parseInt(hex.slice(1), 16); + const mix = (v) => Math.round(amount < 0 ? v * (1 + amount) : v + (255 - v) * amount); + const r = mix((n >> 16) & 255); + const g = mix((n >> 8) & 255); + const b = mix(n & 255); + return `rgb(${r}, ${g}, ${b})`; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + window.addEventListener('resize', resizeBoard); +} + +export { PLACE_FX_MS, animateBoard, drawBoard, render, renderDie, resizeBoard, setMsg }; diff --git a/apps/web/src/multiplication/game/settings.js b/apps/web/src/multiplication/game/settings.js new file mode 100644 index 0000000..e72640f --- /dev/null +++ b/apps/web/src/multiplication/game/settings.js @@ -0,0 +1,35 @@ +// Settings: part of the multiplication game (split from the old js/game.js). + +import { CPU_STEP_MS, DEFAULT_SETTINGS, SETTINGS_KEY } from './base.js'; +import { game } from './play.js'; + +let settings; + +function loadSettings() { + try { + const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY)); + if (saved) return { ...DEFAULT_SETTINGS, ...saved }; + } catch (_) { + // storage unavailable or corrupt: fall back to defaults + } + return { ...DEFAULT_SETTINGS }; +} + +function saveSettings() { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } catch (_) { + // not fatal: settings just won't persist + } +} + +function cpuStep() { + return CPU_STEP_MS[(game || settings).cpuSpeed] || CPU_STEP_MS.normal; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + settings = loadSettings(); +} + +export { cpuStep, saveSettings, settings }; diff --git a/apps/web/src/multiplication/game/shop.js b/apps/web/src/multiplication/game/shop.js new file mode 100644 index 0000000..22a740d --- /dev/null +++ b/apps/web/src/multiplication/game/shop.js @@ -0,0 +1,324 @@ +// Shop: part of the multiplication game (split from the old js/game.js). + +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Progress from '@blockout/progress'; +import * as Shop from '@blockout/progress/shop'; +import { $, el } from './base.js'; +import { celebrate, formatPoints, progress, saveProgress, toast } from './wallet.js'; +import { applyTheme, chooseCosmetic, icon, openStickers, openWardrobe, setWardrobeFilter, syncBoardLocks, syncModeLocks, syncSettingsUI, wardrobeAvailable, wardrobeFilter } from './wardrobe.js'; +import { make } from './play.js'; +import { updatePracticePreview } from './practice.js'; + +// (the Shop's rules and words are shared with the phone app: @blockout/progress/shop) + +// Opened from a results screen, the Shop / Wardrobe / Stickers show only what +// their button promised; this line says so and can switch to everything. +function filterNote(text, showAll) { + const note = make('p', 'filter-note', `${text} · `); + const btn = make('button', 'link-btn', 'Show everything'); + btn.type = 'button'; + btn.addEventListener('click', showAll); + note.append(btn); + return note; +} + +// Bottom bar of the Shop and Wardrobe: points you have, what an item would +// leave you with while it's hovered, the All / Available toggle and Done. +function renderWalletBar(prefix, onlyAvailable) { + $(`${prefix}-wallet`).textContent = formatPoints(progress.wallet); + previewCost(prefix, null); + for (const b of $(`${prefix}-view`).querySelectorAll('button')) { + const on = (b.dataset.view === 'available') === onlyAvailable; + b.classList.toggle('selected', on); + b.setAttribute('aria-pressed', on); + } +} + +function previewCost(prefix, cost) { + const after = $(`${prefix}-wallet-after`); + after.replaceChildren(); + after.className = 'wallet-after'; + if (cost == null) return; + const left = progress.wallet - cost; + if (left >= 0) { + after.append(' ', make('span', 'wallet-cost', `− ${formatPoints(cost)}`), ' = ', make('strong', 'wallet-left', formatPoints(left))); + } else { + after.classList.add('short'); + after.append(` · need ${formatPoints(-left)} more`); + } +} + +let shopFilter = null; // Set of item ids (affordable when picked), or null for everything + +let shopHighlight = null; + +function renderShop(highlight = shopHighlight) { + shopHighlight = highlight; + el.shopBody.innerHTML = ''; + renderWalletBar('shop', !!shopFilter); + if (shopFilter && !shopFilter.size) el.shopBody.append(make('p', 'filter-note', 'Nothing to unlock right now. Keep playing to earn points!')); + for (const group of Shop.shopGroups(progress, shopFilter)) { + el.shopBody.append(make('h3', 'stats-heading', group.name)); + for (const { item, owned, hidden, canBuy, why } of group.rows) { + const row = make('div', 'shop-item' + (item.id === highlight ? ' highlight' : '')); + if (hidden) row.classList.add('skeleton-row'); + const name = make('span', 'shop-name', hidden ? 'Locked' : item.name); + if (!hidden && item.perk) name.append(make('span', 'shop-perk', `🎁 ${item.perk}`)); + row.append(name); + if (owned) { + row.classList.add('owned'); + const mark = make('span', 'shop-owned'); + mark.append(icon('check'), ' Unlocked'); + row.append(mark); + } else { + const btn = make('button', 'btn btn-small' + (canBuy ? ' btn-primary' : ''), `🪙 ${formatPoints(item.price)}`); + btn.type = 'button'; + btn.dataset.buy = item.id; + btn.dataset.cost = item.price; + btn.disabled = !canBuy; + if (hidden) { + btn.replaceChildren(icon('lock')); + btn.setAttribute('aria-label', 'Locked'); + } + row.append(btn); + if (why) row.append(make('span', 'shop-why', why)); + } + el.shopBody.append(row); + } + } +} + +const shopAvailable = () => Shop.availableIds(progress); + +// What was tapped to open the shop, so buying it switches that exact choice on. +let shopSource = null; // { item, el } + +// onlyAffordable: from a results screen, list just what can be bought right now +function openShop(highlight, sourceEl = null, onlyAffordable = false) { + shopSource = highlight && sourceEl ? { item: highlight, el: sourceEl } : null; + shopFilter = onlyAffordable ? shopAvailable() : null; + renderShop(highlight); + el.shopDialog.hidden = false; + const target = highlight && el.shopBody.querySelector('.shop-item.highlight'); + if (target) target.scrollIntoView({ block: 'center' }); + else el.shopDialog.querySelector('.dialog').scrollTop = 0; + el.shopDone.focus({ preventScroll: true }); +} + +function closeShop() { + el.shopDialog.hidden = true; +} + +// Buy an item and switch it on; used by the shop and the quick unlock. +function purchase(item) { + const bought = Shop.purchase(progress, item.id); + if (!bought.ok) return false; + toast(...bought.toast); + celebrate(bought.earned); + saveProgress(); + refreshAvailableFilters(); + if (!el.shopDialog.hidden) renderShop(item.id); + syncSettingsUI(); + syncBoardLocks(); + syncModeLocks(); + updatePracticePreview(); + refreshOpenNudges(); + activatePurchase(item); + return true; +} + +// After a purchase, "Available" lists catch up: newly revealed tiers appear, +// things you can't afford any more go, and what you've bought stays (✓ Unlocked). +function refreshAvailableFilters() { + if (shopFilter) { + const bought = [...shopFilter].filter((id) => Progress.isUnlocked(progress, id)); + shopFilter = new Set([...shopAvailable(), ...bought]); + } + if (wardrobeFilter) { + const owned = [...wardrobeFilter].filter((key) => { + const [cat, opt] = key.split(':'); + return cat === 'title' || Progress.isUnlocked(progress, Cosmetics.itemFor(cat, opt)); + }); + setWardrobeFilter(new Set([...wardrobeAvailable(), ...owned])); + } +} + +// ---- quick unlock: tapping a locked thing asks "Unlock X for 🪙 N?" (no whole shop) + +let unlockAsk = null; // { item, el } + +function confirmUnlock(itemId, sourceEl) { + const offer = Shop.unlockOffer(progress, itemId); + if (offer.kind === 'earn') { + // earned, not bought: a win on the board before, or knowing the times table + unlockAsk = null; + el.unlockTitle.textContent = offer.title; + el.unlockPrice.textContent = ''; + el.unlockMessage.textContent = offer.message; + el.unlockMessage.hidden = false; + el.unlockYes.hidden = true; + el.unlockNo.textContent = 'OK'; + el.unlockDialog.hidden = false; + el.unlockNo.focus(); + return; + } + const yes = offer.kind === 'offer'; + // (what gets switched on is what was tapped, unless a prerequisite is offered instead) + unlockAsk = yes ? { item: offer.item, el: offer.same ? sourceEl : null } : null; + el.unlockTitle.textContent = offer.title; + el.unlockPrice.textContent = offer.price; + el.unlockMessage.textContent = offer.message || ''; + el.unlockMessage.hidden = !offer.message; + el.unlockYes.hidden = !yes; + if (yes) el.unlockYes.textContent = `Unlock for 🪙 ${formatPoints(offer.item.price)}`; + el.unlockNo.textContent = yes ? 'Cancel' : 'OK'; + el.unlockDialog.hidden = false; + (yes ? el.unlockYes : el.unlockNo).focus(); +} + +function closeUnlock() { + el.unlockDialog.hidden = true; + unlockAsk = null; +} + +// ---- "you can afford something" button on the results screens + +const affordableCount = () => Shop.affordableCount(progress); +const wardrobeKeys = () => Shop.wardrobeKeys(progress); +const seenList = (kind) => Shop.seenList(progress, kind); +const newWardrobe = () => Shop.newWardrobe(progress); +const affordableLooks = () => Shop.affordableLooks(progress); +const newStickers = () => Shop.newStickers(progress); +const spareStickers = () => Shop.spareStickers(progress); + +function markSeen(kind, keys) { + Shop.markSeen(progress, kind, keys); + saveProgress(); +} + +// Shop / Wardrobe / Stickers buttons on a results screen ('results' or 'pd'), only when there's something new. +function refreshNudges(prefix) { + const shop = $(`${prefix}-shop-btn`); + const wardrobe = $(`${prefix}-wardrobe-btn`); + const stickers = $(`${prefix}-stickers-btn`); + const n = affordableCount(); + shop.hidden = n === 0; + if (n) shop.replaceChildren(icon('shopping-cart'), ` Shop · ${n} new ${n === 1 ? 'unlock' : 'unlocks'}`); + const w = newWardrobe(); + const looks = affordableLooks(); + wardrobe.hidden = !w && !looks; + const wParts = [w && `${w} new`, looks && `${looks} to unlock`].filter(Boolean); + if (!wardrobe.hidden) wardrobe.replaceChildren(icon('shirt'), ` Wardrobe · ${wParts.join(' · ')}`); + const fresh = newStickers(); + const spares = spareStickers(); + stickers.hidden = !fresh && !spares; + const parts = [fresh && `${fresh} new`, spares && `${spares} ${spares === 1 ? 'spare' : 'spares'}`].filter(Boolean); + if (!stickers.hidden) stickers.replaceChildren(icon('sticker'), ` Stickers · ${parts.join(' · ')}`); +} + +function refreshOpenNudges() { + if (!el.gameOver.hidden) refreshNudges('results'); + if (!el.practiceDone.hidden) refreshNudges('pd'); +} + +// ---- switch a purchase on straight away + +// Click a control (even inside a closed dialog) as if the player had tapped it. +function tap(selector) { + const target = document.querySelector(selector); + if (target && !target.dataset.unlock) target.click(); +} + +const setOption = (key, value) => () => tap(`[data-setting="${key}"] [data-value="${value}"]`); + +// Bought straight from the shop list: switch on the obvious choice +// (Shop.activation says which), by tapping the menu's own controls. +function applyActivation(a) { + if (!a) return; + if (a.mode) tap(`[data-mode="${a.mode}"]`); + if (a.mode === 'multi') { + tap('#multi-kind [data-kind="local"]'); + if (a.players > 2) tap(`#player-count [data-count="${a.players}"]`); + } + const where = a.mode === 'practice' ? 'practice' : 'game'; + if (a.level) tap(`#${where}-level [data-level="${a.level}"]`); + if (a.timer) tap(`#${where}-timer [data-secs="${a.timer}"]`); + if (a.count) tap(`#practice-count [data-count="${a.count}"]`); + if (a.size) tap(`#board-size [data-size="${a.size}"]`); + if (a.difficulty) setOption('difficulty', a.difficulty)(); + if (a.setting) setOption(...a.setting)(); + if (a.mode === 'practice') updatePracticePreview(); +} + +function activatePurchase(item) { + const source = shopSource; + shopSource = null; + if (item.cosmetic) chooseCosmetic(item.cosmetic[0], item.cosmetic[1]); // put it on + else if (source && source.item === item.id && source.el.isConnected) source.el.click(); // the exact thing tapped + else applyActivation(Shop.activation(item.id)); + applyTheme(false); // e.g. golden dice +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + for (const [prefix, selector] of [['shop', '[data-cost]'], ['wardrobe', '.wardrobe-chip[data-cost]']]) { + const dialog = $(prefix); + const show = (e) => { + const target = e.target.closest(selector); + previewCost(prefix, target ? Number(target.dataset.cost) : null); + }; + dialog.addEventListener('pointerover', show); + dialog.addEventListener('focusin', show); + dialog.addEventListener('pointerleave', () => previewCost(prefix, null)); + } + + el.shopBtn.addEventListener('click', () => openShop()); + + el.shopDone.addEventListener('click', closeShop); + + el.resultsShopBtn.addEventListener('click', () => openShop(null, null, true)); + + el.pdShopBtn.addEventListener('click', () => openShop(null, null, true)); + + for (const prefix of ['results', 'pd']) { + $(`${prefix}-wardrobe-btn`).addEventListener('click', () => openWardrobe(true)); + $(`${prefix}-stickers-btn`).addEventListener('click', () => openStickers(true)); + } + + el.shopDialog.addEventListener('click', (e) => { + if (e.target === el.shopDialog) return closeShop(); + const view = e.target.closest('#shop-view [data-view]'); + if (view) { + shopFilter = view.dataset.view === 'available' ? shopAvailable() : null; + renderShop(null); + el.shopDialog.querySelector('.dialog').scrollTop = 0; + return; + } + const btn = e.target.closest('[data-buy]'); + if (btn) purchase(Progress.shopItem(btn.dataset.buy)); + }); + + el.unlockYes.addEventListener('click', () => { + const ask = unlockAsk; + closeUnlock(); + if (!ask) return; + shopSource = ask.el ? { item: ask.item.id, el: ask.el } : null; + purchase(ask.item); + }); + + el.unlockNo.addEventListener('click', closeUnlock); + + el.unlockDialog.addEventListener('click', (e) => { + if (e.target === el.unlockDialog) closeUnlock(); + }); + + el.unlockDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeUnlock(); + }); + + el.shopDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeShop(); + }); +} + +export { affordableCount, affordableLooks, confirmUnlock, filterNote, markSeen, newStickers, newWardrobe, refreshAvailableFilters, refreshNudges, refreshOpenNudges, renderShop, renderWalletBar, seenList, spareStickers, tap, wardrobeKeys }; diff --git a/apps/web/src/multiplication/game/stats.js b/apps/web/src/multiplication/game/stats.js new file mode 100644 index 0000000..cba7b6e --- /dev/null +++ b/apps/web/src/multiplication/game/stats.js @@ -0,0 +1,166 @@ +// All-time stats: part of the multiplication game (split from the old js/game.js). + +import * as Stats from '@blockout/progress/stats'; +import { $, el } from './base.js'; +import { progress, saveProgress } from './wallet.js'; +import { game, make, renderFactGrid, renderFactLegend, renderPractice } from './play.js'; +import { playerFacts } from './practice.js'; + +let howto; + +// Finished games are kept in this browser only, newest last. +const HISTORY_KEY = 'blockout.history'; + + +function loadHistory() { + try { + const saved = JSON.parse(localStorage.getItem(HISTORY_KEY)); + return Array.isArray(saved) ? saved : []; + } catch (_) { + return []; + } +} + +function saveHistory(history) { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-Stats.HISTORY_LIMIT))); + } catch (_) { + // not fatal: stats just won't persist + } +} + +function recordGame(winnerIndexes) { + saveHistory(Stats.addToHistory(loadHistory(), Stats.gameRecord(game, winnerIndexes))); +} + +// Practice rounds share the history list, marked kind: 'practice'. +function recordPracticeRound(record) { + saveHistory(Stats.addToHistory(loadHistory(), record)); +} + +function renderHistory() { + const history = loadHistory(); + const body = el.historyBody; + body.innerHTML = ''; + const report = Stats.statsReport(history, progress); + el.resetHistory.hidden = report.empty; + if (report.empty) { + body.append(make('p', 'history-empty', 'No games or practice yet. Play one and your stats will show up here!')); + return; + } + if (report.tiles.length) { + const tiles = make('div', 'tiles'); + for (const t of report.tiles) { + const tile = make('div', 'tile'); + tile.append(make('span', 'tile-value', t.value), make('span', 'tile-label', t.label)); + tiles.append(tile); + } + body.append(tiles); + } + for (const table of report.tables) { + body.append(make('h3', 'stats-heading', table.heading)); + body.append(statsTable(['', ...table.columns], table.rows)); + } + + // Lifetime times tables for everyone who has answered anything, in games or practice + body.append(make('h3', 'stats-heading centered', 'Times Table')); + body.append(renderFactLegend()); + for (const { name, label } of report.players) { + const facts = playerFacts(name); + body.append(renderFactGrid(label, facts)); + const none = Object.keys(facts).length ? 'Nothing missed so far! 🌟' : 'No answers yet.'; + body.append(renderPractice(label ? `${label}: facts to practice` : 'Facts to practice', 'var(--line)', Stats.factsToPractice(facts), none)); + } + + if (report.recent.length) { + body.append(make('h3', 'stats-heading', 'Recent games')); + const recent = make('ol', 'recent'); + for (const line of report.recent) { + const li = make('li'); + li.append(make('span', 'meta', line.meta), document.createTextNode(`${line.text} · `), make('span', 'winner', line.winner)); + recent.append(li); + } + body.append(recent); + } +} + +// A table whose first column is a two-line label (bold title, muted details). +function statsTable(headings, rows) { + const wrap = make('div', 'stats-table-wrap'); + const table = make('table', 'stats-table by-type'); + const head = table.createTHead().insertRow(); + for (const label of headings) head.append(make('th', null, label)); + const tbody = table.createTBody(); + for (const row of rows) { + const tr = tbody.insertRow(); + const th = make('th'); + th.append(make('span', 'row-title', row.title), make('span', 'row-sub', row.sub)); + tr.append(th); + for (const v of row.values) tr.append(make('td', null, String(v))); + } + wrap.append(table); + return wrap; +} + +function openHistory() { + renderHistory(); + el.historyDialog.hidden = false; + el.historyDialog.querySelector('.dialog').scrollTop = 0; + el.historyDone.focus(); +} + +function closeHistory() { + el.historyDialog.hidden = true; + el.historyBtn.focus(); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.historyBtn.addEventListener('click', openHistory); + + // How to play popup + howto = $('howto'); + + $('howto-btn').addEventListener('click', () => { + howto.hidden = false; + howto.querySelector('.dialog').scrollTop = 0; + $('howto-done').focus(); + }); + + $('howto-done').addEventListener('click', () => { + howto.hidden = true; + $('howto-btn').focus(); + }); + + howto.addEventListener('click', (e) => { + if (e.target === howto) howto.hidden = true; + }); + + howto.addEventListener('keydown', (e) => { + if (e.key === 'Escape') howto.hidden = true; + }); + + el.historyDone.addEventListener('click', closeHistory); + + el.historyDialog.addEventListener('click', (e) => { + if (e.target === el.historyDialog) closeHistory(); + }); + + el.historyDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeHistory(); + }); + + el.resetHistory.addEventListener('click', () => { + if (!confirm('Delete all game history and times-table stats on this device? Points, unlocks and achievements are kept. This cannot be undone.')) return; + try { + localStorage.removeItem(HISTORY_KEY); + } catch (_) { + // nothing saved anyway + } + progress.facts = {}; + saveProgress(); + renderHistory(); + }); +} + +export { loadHistory, recordGame, recordPracticeRound, saveHistory }; diff --git a/apps/web/src/multiplication/game/wallet.js b/apps/web/src/multiplication/game/wallet.js new file mode 100644 index 0000000..520f098 --- /dev/null +++ b/apps/web/src/multiplication/game/wallet.js @@ -0,0 +1,404 @@ +// Progress (wallet, unlocks, achievements): part of the multiplication game (split from the old js/game.js). + +import * as Auth from '@blockout/auth'; +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Progress from '@blockout/progress'; +import * as Shop from '@blockout/progress/shop'; +import * as Views from '@blockout/progress/views'; +import { PROGRESS_KEY, el } from './base.js'; +import { settings } from './settings.js'; +import { look } from './wardrobe.js'; +import { make } from './play.js'; +import { affordableCount, affordableLooks, newStickers, newWardrobe, spareStickers } from './shop.js'; +import { refreshUnlocks } from './cheats.js'; + +let progress, glyphSprites; + +function loadProgress() { + try { + return Progress.normalize(JSON.parse(localStorage.getItem(PROGRESS_KEY))); + } catch (_) { + return Progress.createProgress(); + } +} + +function saveProgress() { + try { + localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); + } catch (_) { + // not fatal: progress just won't persist + } + if (Auth) Auth.queueProgress(progress); // signed in: send it to the account too + renderWallet(); +} + +// Swap in another save (the account's, after signing in) and redraw what shows it. +function replaceProgress(next) { + for (const key of Object.keys(progress)) delete progress[key]; + Object.assign(progress, Progress.normalize(next)); + try { + localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); + } catch (_) { + // not fatal + } + refreshUnlocks(); + renderWallet(); +} + +function renderWallet() { + el.walletAmount.textContent = formatPoints(progress.wallet); + updateMenuBadges(); +} + +// iPhone-style red count badges on the main-menu Shop, Wardrobe and Stickers buttons. +function updateMenuBadges() { + if (!Cosmetics || !el.shopBtn) return; + setBadge(el.shopBtn, 'Shop', affordableCount(), 'you can unlock'); + setBadge(el.wardrobeBtn, 'Wardrobe', newWardrobe() + affordableLooks(), 'new or ready to unlock'); + setBadge(el.stickersBtn, 'Stickers', newStickers() + spareStickers(), 'new or spare'); +} + +function setBadge(btn, label, count, what) { + let badge = btn.querySelector('.badge'); + if (!count) { + if (badge) badge.remove(); + btn.removeAttribute('aria-label'); + return; + } + if (!badge) { + badge = make('span', 'badge'); + badge.setAttribute('aria-hidden', 'true'); + btn.append(badge); + } + badge.textContent = count > 99 ? '99+' : String(count); + btn.setAttribute('aria-label', `${label}, ${count} ${what}`); +} + +// 10000 → "10,000" (in the reader's locale). +const formatPoints = Shop.formatPoints; + +// Show newly earned achievements as toasts; returns them for the caller. +// Every big (non-minor) achievement drops one random sticker. +function grantStickers(earned) { + return Views.grantStickers(progress, earned); +} + +function celebrate(earned) { + queueAchievements(earned); + for (const drop of grantStickers(earned)) toast(...drop.toast); + // one full-screen celebration (bigger for several), not for minor ones like fact speed + const major = earned.filter((a) => !a.minor).length; + if (major) confettiFullScreen(major); + return earned; +} + +// ---- confetti: paper bits that fly, then fall and flutter. Every toast gets a +// burst from itself; achievements also get a full-screen celebration. +// Drawn on a see-through canvas that ignores clicks; skipped for reduced motion. +const CONFETTI_COLORS = ['#e4572e', '#2e86de', '#2a9d5c', '#8e5bd6', '#f2c94c', '#ff8fab']; + +const confetti = { canvas: null, ctx: null, parts: [], frame: null, last: 0 }; + +// Confetti doesn't need retina sharpness; 1× keeps the full-screen canvas small and fast. +const CONFETTI_DPR = 1; + +// Emoji pieces are bigger than paper, so half as many look just as full (and draw faster). +const pieceCount = (n) => Math.round(n * (confettiGlyphs() ? 0.5 : 1)); + +// Make sure the canvas exists and matches the window; false when motion is reduced. +function confettiReady() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false; + if (!confetti.canvas) { + confetti.canvas = document.createElement('canvas'); + confetti.canvas.className = 'confetti'; + confetti.canvas.setAttribute('aria-hidden', 'true'); + document.body.append(confetti.canvas); + confetti.ctx = confetti.canvas.getContext('2d'); + } + const dpr = CONFETTI_DPR; + const w = Math.round(innerWidth * dpr); + const h = Math.round(innerHeight * dpr); + if (confetti.canvas.width !== w || confetti.canvas.height !== h) { + confetti.canvas.width = w; + confetti.canvas.height = h; + } + confetti.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return true; +} + +// Wardrobe confetti: paper, or emoji pieces (stars, hearts, snowflakes, coins…) +const CONFETTI_GLYPHS = { + stars: ['⭐', '🌟'], + hearts: ['💖', '💗', '❤️'], + snow: ['❄️'], + coins: ['🪙'], + blossom: ['🌸', '🌼'], +}; + +const pick = (list) => list[Math.floor(Math.random() * list.length)]; + +function confettiGlyphs() { + const kind = settings.cosmetics.confetti; + return kind === 'emoji' ? look('confetti').glyphs : CONFETTI_GLYPHS[kind] || null; +} + +function glyphSprite(glyph, size) { + const px = Math.round(size); + const key = `${glyph}|${px}`; + let sprite = glyphSprites.get(key); + if (!sprite) { + const dpr = CONFETTI_DPR; + const cssSize = Math.ceil(px * 2.6); + sprite = document.createElement('canvas'); + sprite.width = sprite.height = Math.ceil(cssSize * dpr); + sprite.cssSize = cssSize; + const s = sprite.getContext('2d'); + s.scale(dpr, dpr); + s.font = `${px * 2}px serif`; + s.textAlign = 'center'; + s.textBaseline = 'middle'; + s.fillText(glyph, cssSize / 2, cssSize / 2 + px * 0.1); + glyphSprites.set(key, sprite); + } + return sprite; +} + +// extra: { glyphs, gravity, size } for special effects (balloons, trophies, fireworks) +function addPiece(x, y, angleDeg, speed, maxAge = 200, extra = {}) { + const angle = (angleDeg * Math.PI) / 180; + const glyphs = extra.glyphs !== undefined ? extra.glyphs : confettiGlyphs(); + confetti.parts.push({ + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + size: extra.size || (glyphs ? 9 + Math.random() * 6 : 5 + Math.random() * 6), + spin: Math.random() * Math.PI, + vspin: (Math.random() - 0.5) * (glyphs ? 0.12 : 0.4), + wobble: Math.random() * Math.PI * 2, + color: extra.color || CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)], + round: Math.random() < 0.3, + glyph: glyphs ? pick(glyphs) : null, + gravity: extra.gravity !== undefined ? extra.gravity : 0.28, + age: 0, + maxAge, + }); +} + +function startConfetti() { + if (!confetti.frame) { + confetti.last = performance.now(); + confetti.frame = requestAnimationFrame(stepConfetti); + } +} + +// A burst shooting up out of an element (mostly up, leaning right). +function confettiFrom(element, strength = 1) { + if (!confettiReady()) return; + const box = element.getBoundingClientRect(); + const count = pieceCount(Math.min(90 + 40 * (strength - 1), 200)); + for (let i = 0; i < count; i++) { + addPiece(box.left + Math.random() * box.width, box.top + box.height * 0.3, -90 + (Math.random() - 0.35) * 110, 7 + Math.random() * 9); + } + startConfetti(); +} + +// Winning: the Wardrobe's win celebration. +function celebrateWin() { + if (!confettiReady()) return; + const w = innerWidth; + const h = innerHeight; + const style = settings.cosmetics.win; + if (style === 'fireworks') { + for (let burst = 0; burst < 6; burst++) { + setTimeout(() => { + if (!confettiReady()) return; + const x = w * (0.15 + Math.random() * 0.7); + const y = h * (0.15 + Math.random() * 0.35); + const color = pick(CONFETTI_COLORS); + for (let i = 0; i < 70; i++) { + addPiece(x, y, (i / 70) * 360, 3 + Math.random() * 5, 90 + Math.random() * 40, { glyphs: null, gravity: 0.1, color, size: 4 }); + } + startConfetti(); + }, burst * 380); + } + } else if (style === 'balloons') { + for (let i = 0; i < 36; i++) { + addPiece(Math.random() * w, h + 20 + Math.random() * h * 0.6, -90 + (Math.random() - 0.5) * 16, 2 + Math.random() * 2.5, 520, { glyphs: ['🎈'], gravity: -0.01, size: 14 + Math.random() * 8 }); + } + startConfetti(); + } else if (style === 'trophies') { + for (let i = 0; i < 90; i++) { + addPiece(Math.random() * w, -20 - Math.random() * h * 0.9, 90 + (Math.random() - 0.5) * 20, 1 + Math.random() * 3, 420, { glyphs: ['🏆', '🪙', '⭐', '🥇'] }); + } + startConfetti(); + } else { + confettiFullScreen(3); + } +} + +// Full screen: a shower from the top across the whole width, plus a cannon +// in each bottom corner firing towards the middle. +function confettiFullScreen(strength = 1) { + if (!confettiReady()) return; + const w = innerWidth; + const h = innerHeight; + const shower = pieceCount(Math.min(Math.round((w / 6) * (0.8 + 0.3 * strength)), 420)); + for (let i = 0; i < shower; i++) { + // start above the screen at different heights so it keeps raining for a while + addPiece(Math.random() * w, -20 - Math.random() * h * 0.9, 90 + (Math.random() - 0.5) * 30, 1 + Math.random() * 3, 420); + } + const perCannon = pieceCount(Math.min(70 + 30 * (strength - 1), 150)); + const reach = Math.sqrt(h) * 0.75; // strong enough to reach the upper half on any screen + for (let i = 0; i < perCannon; i++) { + addPiece(0, h, -60 + (Math.random() - 0.5) * 30, reach * (0.7 + Math.random() * 0.5), 300); + addPiece(w, h, -120 + (Math.random() - 0.5) * 30, reach * (0.7 + Math.random() * 0.5), 300); + } + startConfetti(); +} + +function stepConfetti(now) { + const dt = Math.min(2, (now - confetti.last) / 16.7); // in 60fps frames, capped after tab switches + confetti.last = now; + const g = confetti.ctx; + g.clearRect(0, 0, innerWidth, innerHeight); + confetti.parts = confetti.parts.filter((p) => (p.y < innerHeight + 20 || p.vy < 0) && p.y > -120 && p.age < p.maxAge); + const dpr = CONFETTI_DPR; + const air = Math.pow(0.985, dt); + for (const p of confetti.parts) { + p.age += dt; + p.vy += p.gravity * dt; // gravity (balloons have a little negative gravity) + p.vx *= air; + p.vy = Math.min(p.vy, 6); // paper falls slowly + p.wobble += 0.15 * dt; + p.x += (p.vx + Math.sin(p.wobble) * 0.8) * dt; + p.y += p.vy * dt; + p.spin += p.vspin * dt; + // Position each piece with one setTransform (no save/restore per piece: much faster) + const cos = Math.cos(p.spin) * dpr; + const sin = Math.sin(p.spin) * dpr; + g.globalAlpha = Math.min(1, (p.maxAge - p.age) / 40); + if (p.glyph) { + // emoji: stamp a pre-drawn image instead of drawing the text every frame + const sprite = glyphSprite(p.glyph, p.size); + g.setTransform(cos, sin, -sin, cos, p.x * dpr, p.y * dpr); + g.drawImage(sprite, -sprite.cssSize / 2, -sprite.cssSize / 2, sprite.cssSize, sprite.cssSize); + } else if (p.round) { + g.setTransform(dpr, 0, 0, dpr, p.x * dpr, p.y * dpr); + g.fillStyle = p.color; + g.beginPath(); + g.arc(0, 0, p.size / 2.4, 0, Math.PI * 2); + g.fill(); + } else { + const flip = Math.cos(p.wobble); // flipping paper + g.setTransform(cos, sin, -sin * flip, cos * flip, p.x * dpr, p.y * dpr); + g.fillStyle = p.color; + g.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2); + } + } + g.globalAlpha = 1; + g.setTransform(dpr, 0, 0, dpr, 0, 0); + if (confetti.parts.length) confetti.frame = requestAnimationFrame(stepConfetti); + else { + confetti.frame = null; + g.clearRect(0, 0, innerWidth, innerHeight); + } +} + +const BONUS_TOASTS = { first: '✨', speedy: '⚡', streak: '🔥', timer: '⏱️' }; + +function capitalize(text) { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// Small pop-up message in the corner that disappears by itself. +// ---- achievements: a big banner at the bottom, one at a time, with confetti + +const achievementQueue = []; + +let bannerUp = false; + +const BANNER_MS = 3200; + +const MINOR_BANNER_MS = 1800; + +// Minor ones in a row (fact speed badges) share one smaller banner. +function queueAchievements(list) { + for (const a of list) { + const last = achievementQueue[achievementQueue.length - 1]; + if (a.minor && last && last.minor) { + last.count++; + last.reward += a.reward; + continue; + } + achievementQueue.push({ icon: a.icon, name: a.name, reward: a.reward, minor: Boolean(a.minor), count: 1 }); + } + if (!bannerUp) showNextAchievement(); +} + +function showNextAchievement() { + const a = achievementQueue.shift(); + if (!a) { + bannerUp = false; + return; + } + bannerUp = true; + const box = make('div', 'achievement-banner' + (a.minor ? ' minor' : '')); + box.setAttribute('role', 'status'); + const text = make('div', 'banner-text'); + text.append( + make('span', 'banner-kicker', a.minor ? 'Badge earned' : 'Achievement earned'), + make('strong', 'banner-name', a.count > 1 ? `+${a.count} fact speed badges` : a.name), + make('span', 'banner-reward', `+${formatPoints(a.reward)} points`) + ); + box.append(make('span', 'banner-icon', a.icon), text); + document.body.append(box); + requestAnimationFrame(() => box.classList.add('show')); + setTimeout(() => confettiFrom(box), 250); + let gone = false; + const next = () => { + if (gone) return; + gone = true; + box.classList.remove('show'); + box.classList.add('leaving'); + setTimeout(() => { + box.remove(); + showNextAchievement(); + }, 350); + }; + // (clicks go straight through the banner, so it never blocks the buttons under it) + setTimeout(next, a.minor ? MINOR_BANNER_MS : BANNER_MS); +} + +function toast(title, detail = '') { + const box = document.createElement('div'); + box.className = 'toast'; + box.setAttribute('role', 'status'); + const strong = document.createElement('strong'); + strong.textContent = title; + box.append(strong); + if (detail) { + const small = document.createElement('span'); + small.textContent = detail; + box.append(small); + } + el.toasts.append(box); + // Keep at most 4 on screen: the oldest goes first. + const all = el.toasts.querySelectorAll('.toast:not(.leaving)'); + if (all.length > 4) all[0].remove(); + setTimeout(() => box.classList.add('leaving'), 3600); + setTimeout(() => box.remove(), 4000); + confettiFrom(box); // every toast gets a little burst + return box; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + progress = loadProgress(); + + // Emoji drawn once per glyph and size into a small canvas, then stamped with + // drawImage. Drawing colour emoji as text every frame is very slow. + glyphSprites = new Map(); +} + +export { BONUS_TOASTS, capitalize, celebrate, celebrateWin, confettiFullScreen, formatPoints, grantStickers, progress, queueAchievements, renderWallet, replaceProgress, saveProgress, toast, updateMenuBadges }; diff --git a/apps/web/src/multiplication/game/wardrobe.js b/apps/web/src/multiplication/game/wardrobe.js new file mode 100644 index 0000000..bd62e5e --- /dev/null +++ b/apps/web/src/multiplication/game/wardrobe.js @@ -0,0 +1,676 @@ +// Wardrobe (cosmetics): part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import * as Views from '@blockout/progress/views'; +import * as Settings from '@blockout/progress/settings'; +import * as Wardrobe from '@blockout/progress/wardrobe'; +import * as Setup from '@blockout/progress/setup'; +import { BOARD_UNLOCK, DEFAULT_NAMES, el, setup } from './base.js'; +import { formatPoints, progress, saveProgress, toast, updateMenuBadges } from './wallet.js'; +import { saveSettings, settings } from './settings.js'; +import { game, make, startGame } from './play.js'; +import { confirmUnlock, filterNote, markSeen, refreshOpenNudges, renderWalletBar, seenList, wardrobeKeys } from './shop.js'; +import { startPracticeAuto, startPracticeJourney, updatePracticePreview } from './practice.js'; +import { checkClassroom } from './classroom.js'; +import { journeyDifficulty, practiceJourneyOn, renderJourney } from './journey.js'; +import { drawBoard, render, renderDie } from './render.js'; + +// settings.cosmetics: { dice, pips, roll, colors, pattern, board, place, confetti, +// win, streak, cpu, avatar, keypad, title }. Only owned choices are kept. +function loadCosmetics() { + const chosen = { ...(settings.cosmetics || {}) }; + if (settings.diceStyle === 'golden' && !chosen.dice) chosen.dice = 'golden'; // old Dice style setting + delete settings.diceStyle; + settings.cosmetics = Wardrobe.chosenLooks(chosen, progress); +} + +const look = (categoryId) => Wardrobe.look(settings.cosmetics, categoryId); + +function chooseCosmetic(categoryId, optionId) { + settings.cosmetics[categoryId] = optionId; + // bought or worn while the Wardrobe is open: you've seen it, so it isn't "new" + if (!el.wardrobeDialog.hidden) markSeen('wardrobe', [`${categoryId}:${optionId}`]); + saveSettings(); + applyCosmeticsToPage(); + if (!el.wardrobeDialog.hidden) renderWardrobe(); +} + +// Keypads get a skin class; dice get theirs when drawn; the board is redrawn. +function applyCosmeticsToPage(redraw = true) { + for (const pad of document.querySelectorAll('.keypad')) { + pad.className = `keypad skin-${settings.cosmetics.keypad}`; + } + if (redraw) drawBoard(); +} + +// ---- Wardrobe dialog + +function chipPreview(cat, opt) { + const box = make('span', 'chip-preview'); + if (cat.id === 'dice') { + const die = make('span', `die mini skin-${opt.id}`); + renderDie(die, 5, 6, opt.id); + box.append(die); + } else if (cat.id === 'colors') { + for (const c of opt.colors) { + const sw = make('span', 'swatch'); + sw.style.background = c; + box.append(sw); + } + } else if (cat.id === 'pattern' || cat.id === 'board') { + box.append(make('span', `tile-preview ${cat.id}-${opt.id}`)); + } else if (cat.id === 'keypad') { + box.append(make('span', `key-preview skin-${opt.id}`, '7')); + } else if (cat.id === 'roll' || cat.id === 'place') { + box.append(make('span', `motion-preview ${cat.id}-${opt.id}`, cat.id === 'roll' ? '🎲' : '▦')); + } else if (cat.id === 'cpu') { + box.append(make('span', 'emoji-preview', opt.avatar)); + } else { + box.append(make('span', 'emoji-preview', opt.preview || '')); + } + return box; +} + +let wardrobeFilter = null; // Set of 'category:option' keys (new or unlockable when picked), or null for all + +let wardrobeNew = []; // keys that were new when the Wardrobe opened + +const wardrobeAvailable = () => Wardrobe.availableKeys(progress, wardrobeNew); + +function renderWardrobe() { + const body = el.wardrobeBody; + body.innerHTML = ''; + renderWalletBar('wardrobe', !!wardrobeFilter); + if (wardrobeFilter && !wardrobeFilter.size) body.append(make('p', 'filter-note', 'Nothing new to wear yet. Keep playing to earn points!')); + for (const { category: cat, options } of Wardrobe.wardrobeRows(progress, settings.cosmetics, wardrobeFilter)) { + body.append(make('h3', 'stats-heading', cat.name)); + const row = make('div', 'wardrobe-row'); + for (const o of options) { + const opt = o.option; + const chip = make('button', 'wardrobe-chip' + (o.worn ? ' selected' : '') + (o.owned ? '' : ' locked')); + chip.type = 'button'; + chip.dataset.cat = cat.id; + chip.dataset.opt = opt.id; + chip.append(chipPreview(cat, opt), make('span', 'chip-name', opt.name)); + if (!o.owned) { + chip.dataset.unlock = o.item; + const tag = make('span', 'lock-badge'); + if (o.hidden) { + // further down this category's chain: a placeholder, like the main menu + chip.classList.add('skeleton'); + chip.setAttribute('aria-label', 'Locked: unlock the one before it first'); + tag.append(icon('lock')); + } else if (o.season) tag.append(icon('lock'), ` ${o.season}`); + else { + chip.dataset.cost = o.price; + tag.append(icon('lock'), ` ${formatPoints(o.price)}`); + } + chip.append(tag); + } + row.append(chip); + } + body.append(row); + } + // Titles are earned, not bought + const titles = Wardrobe.titleRow(progress, settings.cosmetics, wardrobeFilter); + if (!titles.length) return; + body.append(make('h3', 'stats-heading', 'Your title')); + const row = make('div', 'wardrobe-row'); + for (const { title: t, earned, worn } of titles) { + const chip = make('button', 'wardrobe-chip title-chip' + (worn ? ' selected' : '') + (earned ? '' : ' locked')); + chip.type = 'button'; + chip.dataset.cat = 'title'; + chip.dataset.opt = t.id; + chip.disabled = !earned; + chip.append(make('span', 'chip-name', t.name)); + if (!earned) chip.append(make('span', 'chip-need', t.need)); + row.append(chip); + } + body.append(row); +} + +// onlyNew: from a results screen, show just the looks and titles you haven't seen yet +function openWardrobe(onlyNew = false) { + loadCosmetics(); + const keys = wardrobeKeys(); + wardrobeNew = keys.filter((k) => !seenList('wardrobe').includes(k)); + wardrobeFilter = onlyNew === true ? wardrobeAvailable() : null; + renderWardrobe(); + markSeen('wardrobe', keys); + el.wardrobeDialog.hidden = false; + el.wardrobeDialog.querySelector('.dialog').scrollTop = 0; + el.wardrobeDone.focus({ preventScroll: true }); +} + +function closeWardrobe() { + el.wardrobeDialog.hidden = true; + refreshOpenNudges(); + updateMenuBadges(); +} + +// ---- Sticker album + +let stickersFilter = null; // Set of sticker ids (new or with spares when opened), or null + +function renderStickers() { + const body = el.stickersBody; + body.innerHTML = ''; + const album = Views.stickerAlbum(progress, stickersFilter); + body.append(make('p', 'stats-summary', `${album.got} of ${album.total} collected`)); + if (stickersFilter) { + body.append(filterNote('Showing your new stickers and spares', () => { + stickersFilter = null; + renderStickers(); + })); + } + for (const section of album.sections) { + body.append(make('h3', 'stats-heading', section.name)); + const grid = make('div', 'sticker-grid'); + for (const st of section.slots) { + const slot = make('div', `sticker ${section.rarity}${st.count ? '' : ' missing'}`); + slot.append(make('span', 'sticker-emoji', st.emoji || '?')); + if (st.count > 1) slot.append(make('span', 'sticker-count', `×${st.count}`)); + slot.title = st.count ? `${st.id} (${section.rarity})` : 'Not found yet'; + grid.append(slot); + } + body.append(grid); + } + el.stickersTrade.disabled = album.spareValue === 0; + el.stickersTrade.textContent = album.spareValue ? `Swap spares for 🪙 ${formatPoints(album.spareValue)}` : 'No spares to swap'; +} + +// onlyNew: from a results screen, show just the new stickers and the ones with spares +function openStickers(onlyNew = false) { + const have = progress.stickers || {}; + stickersFilter = onlyNew === true ? Views.newOrSpareStickers(progress) : null; + renderStickers(); + markSeen('stickers', Object.keys(have)); + el.stickersDialog.hidden = false; + el.stickersDone.focus(); +} + +function closeStickers() { + el.stickersDialog.hidden = true; + refreshOpenNudges(); + updateMenuBadges(); +} + +// Shop item that unlocks a setting's choice; null when it's free (the default). +const unlockFor = Settings.unlockFor; + +// Anything saved before it was locked (or not yet bought) falls back to the default. +// (and settings that are gone dropped, choices that are gone back to the default) +function enforceLocks() { + const out = Settings.enforce(settings, progress); + for (const key of Settings.GONE) delete settings[key]; + Object.assign(settings, out); +} + +// A Lucide icon from the sprite in index.html, e.g. icon('lock'). +function icon(name) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'icon'); + svg.setAttribute('aria-hidden', 'true'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', `#i-${name}`); + svg.append(use); + return svg; +} + +// Mark a button as locked with a "🔒 price" badge, or clear it. +// Locked options show their price; ones further down a chain (see SHOP +// `requires`) are a blurred placeholder with just a lock, so only the next +// step shows what it is and what it costs. +function setLockBadge(btn, itemId) { + const locked = !Progress.isUnlocked(progress, itemId); + const hidden = locked && Progress.isHidden(progress, itemId); + // boards, difficulties and tables the Journeys haven't reached yet aren't shown + // on the menu at all (settings show their lock: tapping it says where it comes) + const item = itemId && Progress.shopItem(itemId); + if (item && (item.journey || item.practiceJourney) && btn.closest('#setup-form')) btn.hidden = locked; + btn.classList.toggle('locked', locked); + btn.classList.toggle('skeleton', hidden); + btn.dataset.unlock = locked ? itemId : ''; + if (hidden) btn.setAttribute('aria-label', 'Locked: unlock the one before it first'); + else if (btn.getAttribute('aria-label') === 'Locked: unlock the one before it first') btn.removeAttribute('aria-label'); + let badge = btn.querySelector('.lock-badge'); + if (locked && !badge) { + badge = document.createElement('span'); + badge.className = 'lock-badge'; + btn.append(badge); + } + if (badge && !locked) badge.remove(); + if (badge && locked) badge.replaceChildren(icon('lock'), hidden ? '' : ` ${formatPoints(Progress.shopItem(itemId).price)}`); +} + +function syncSettingsUI() { + for (const group of document.querySelectorAll('.segmented[data-setting]')) { + const key = group.dataset.setting; + for (const btn of group.querySelectorAll('button')) { + btn.classList.toggle('selected', btn.dataset.value === String(settings[key])); + setLockBadge(btn, unlockFor(key, btn.dataset.value)); + } + } +} + +// Setting buttons live in the Settings dialog and (difficulty) on the main menu. +function onSettingClick(e) { + const btn = e.target.closest('.segmented[data-setting] button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + const key = btn.parentElement.dataset.setting; + const raw = btn.dataset.value; + settings[key] = raw === 'true' ? true : raw === 'false' ? false : raw; + saveSettings(); + syncSettingsUI(); + updatePracticePreview(); + renderGameLevel(); + renderBoardHint(); +} + +function applyTheme(redraw = true) { + const root = document.documentElement; + if (settings.theme === 'light' || settings.theme === 'dark') root.dataset.theme = settings.theme; + else delete root.dataset.theme; + applyCosmeticsToPage(false); + if (redraw) drawBoard(); +} + +let settingsOpener = null; // button to return focus to + +function openSettings(opener) { + settingsOpener = opener; + const inGame = !el.gameScreen.hidden && game && game.phase !== 'over'; + el.settingsNote.hidden = !inGame; + if (inGame) { + el.settingsNote.textContent = game.invite + ? 'You’re playing an invite, so its rules stay the same until the game ends. Theme changes apply now.' + : 'Theme, CPU speed and the progress bar change now. Other changes start from the next turn.'; + } + syncSettingsUI(); + el.settingsDialog.hidden = false; + el.settingsDone.focus(); +} + +// Settings changed during a game (not an invite): these are safe to switch at once. +function applySettingsNow() { + if (!game || game.invite || game.phase === 'over') return; + game.cpuSpeed = settings.cpuSpeed; + game.cpuEnd = settings.cpuEnd; + game.showProgress = settings.showProgress; + render(); +} + +// ...and these wait for the start of the next turn, so a move is never changed halfway. +function applySettingsForTurn() { + if (!game || game.invite || game.fixed) return; + applySettingsNow(); + game.placeMode = settings.placeMode; + game.autoRoll = settings.autoRoll === 'auto'; + game.cpuInstant = settings.cpuSteps === 'instant'; + // single player uses its own Expert timer; the Settings timer is for Home multiplayer + game.answerTime = game.mode === 'single' ? game.levelTimer : Number(settings.answerTime) || 0; +} + +function selectIn(container, button) { + container.querySelectorAll('button').forEach((b) => b.classList.toggle('selected', b === button)); +} + +// Single player and Classroom are free; Local multiplayer, 3/4 players and +// Practice are shop unlocks. +const MODE_UNLOCK = { single: null, multi: null, practice: 'practice' }; + +const PLAYERS_UNLOCK = { 2: null, 3: 'players3', 4: 'players4' }; + +function syncModeLocks() { + el.modeCards.forEach((card) => setLockBadge(card, MODE_UNLOCK[card.dataset.mode])); + for (const btn of el.playerCount.querySelectorAll('button')) setLockBadge(btn, PLAYERS_UNLOCK[btn.dataset.count]); + setLockBadge(el.multiKind.querySelector('[data-kind="local"]'), 'multiplayer'); +} + +// Which parts of the setup form show for the chosen mode (and, for +// multiplayer, Local or Classroom). +function renderSetupSections() { + const classroom = setup.mode === 'multi' && setup.multiKind === 'classroom'; + el.singleSetup.hidden = setup.mode !== 'single'; + el.multiSetup.hidden = setup.mode !== 'multi'; + el.practiceSetup.hidden = setup.mode !== 'practice'; + if (setup.mode !== 'practice') renderBoardHint(); + el.localSetup.hidden = setup.multiKind !== 'local'; + el.classroomSetup.hidden = setup.multiKind !== 'classroom'; + el.boardSizeField.hidden = setup.mode === 'practice' || classroom; // practice has no board + el.difficultyField.hidden = classroom; // the teacher picks these when hosting + el.startBtn.hidden = classroom; // Join / Host instead + el.startBtn.textContent = setup.mode === 'practice' ? 'Start practice' : 'Start game'; + if (classroom) checkClassroom(); + renderJourney(); // (single player and Practice: the Journey picks the board and dice, or the table) +} + +function selectMultiKind(kind) { + setup.multiKind = kind; + for (const b of el.multiKind.querySelectorAll('[data-kind]')) { + const on = b.dataset.kind === kind; + b.classList.toggle('selected', on); + b.setAttribute('aria-checked', String(on)); + } + renderSetupSections(); +} + +const CUSTOM_MIN = 6; + +const CUSTOM_MAX = 30; + +function syncBoardLocks() { + for (const btn of el.boardSize.querySelectorAll('button')) setLockBadge(btn, BOARD_UNLOCK[btn.dataset.size]); + renderBoardHint(); + renderGameLevel(); // (difficulty or purchases change it too) + renderJourney(); +} + +// ---- single player: Level (Learn / Expert) and Expert's timer, per difficulty, like Practice +const GAME_EXPERT_BONUS = Setup.GAME_EXPERT_BONUS; + +const GAME_LEVEL_HELP = Setup.GAME_LEVEL_HELP; + +function renderGameLevel() { + if (!el.gameLevel) return; + const d = journeyDifficulty() || settings.difficulty; // a Journey level has its own + const expertId = Progress.gameExpertId(d); + if (!Progress.isUnlocked(progress, expertId)) setup.level = 'learn'; + for (const btn of el.gameLevel.querySelectorAll('button')) { + btn.classList.toggle('selected', btn.dataset.level === setup.level); + setLockBadge(btn, btn.dataset.level === 'expert' ? expertId : null); + } + el.gameLevelHelp.textContent = GAME_LEVEL_HELP[setup.level]; + el.gameTimerField.hidden = setup.level !== 'expert'; + const timerId = (secs) => (Number(secs) ? Progress.gameTimerId(Number(secs), d) : null); + if (!Progress.isUnlocked(progress, timerId(setup.timer))) setup.timer = 0; + for (const btn of el.gameTimer.querySelectorAll('button')) { + btn.classList.toggle('selected', Number(btn.dataset.secs) === setup.timer); + setLockBadge(btn, timerId(btn.dataset.secs)); + } + el.gameTimerHelp.textContent = Setup.timerHelp(setup.timer); +} + +// Under the board sizes: the finishing bonus for the chosen size +function renderBoardHint() { + if (!el.boardSizeHelp) return; + // Legend rolls a teen (11–19), so it needs a board it fits on + const warning = Setup.boardWarning(settings.difficulty, setup.size); + if (setup.mode !== 'practice') el.startBtn.disabled = Boolean(warning); + el.boardSizeHelp.classList.toggle('warn', Boolean(warning)); + if (warning) { + el.boardSizeHelp.textContent = warning; + return; + } + const bonus = Progress.boardFinishBonus(setup.size); + if (settings.difficulty === 'legend' && setup.size < 19 && setup.mode !== 'practice') { + el.boardSizeHelp.textContent = `Legend: half the rolls are teens like 14 × 7, the rest are Tricky facts. On ${setup.size}×${setup.size} the teens go up to ${setup.size}; bigger boards bring bigger teens (all the way to 19 on 20×20).`; + return; + } + el.boardSizeHelp.textContent = bonus + ? `🏁 Finish a game against the CPU on ${setup.size}×${setup.size} for +${bonus} bonus points.` + : `🏁 Bigger boards earn a finishing bonus against the CPU: +${Progress.boardFinishBonus(8)} for 8×8, up to +${Progress.boardFinishBonus(24)} for 24×24.`; +} + +function clampCustom(n) { + n = Math.round(Number(n)); + return Number.isFinite(n) ? Math.max(CUSTOM_MIN, Math.min(n, CUSTOM_MAX)) : 14; +} + +// Current custom size (clamped), mirrored next to the input. +function readCustomSize() { + const size = clampCustom(el.customSizeInput.value); + el.customSizeEcho.textContent = size; + return size; +} + +function setCustomSize(size) { + el.customSizeInput.value = clampCustom(size); + setup.size = readCustomSize(); + renderBoardHint(); +} + +function saveNameInputs() { + el.nameInputs.querySelectorAll('input').forEach((input, i) => { + if (setup.types[i] === 'human') setup.names[i] = input.value; + }); +} + +// CPU slots are numbered in order: CPU 1, CPU 2, … +function cpuName(index) { + const nth = setup.types.slice(0, index + 1).filter((t) => t === 'cpu').length; + return `CPU ${nth}`; +} + +function renderNameInputs() { + el.nameInputs.innerHTML = ''; + for (let i = 0; i < setup.count; i++) { + const isCpu = setup.types[i] === 'cpu'; + const row = document.createElement('div'); + row.className = 'name-row'; + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.background = look('colors').colors[i]; + const input = document.createElement('input'); + input.type = 'text'; + input.maxLength = 14; + input.value = isCpu ? cpuName(i) : setup.names[i]; + input.placeholder = DEFAULT_NAMES[i]; + input.addEventListener('input', () => input.classList.remove('missing')); + input.disabled = isCpu; + input.setAttribute('aria-label', `Player ${i + 1} name`); + const kind = document.createElement('button'); + kind.type = 'button'; + kind.className = 'kind-toggle' + (isCpu ? ' cpu' : ''); + kind.dataset.index = i; + kind.textContent = isCpu ? '🤖 CPU' : '👤 Human'; + kind.setAttribute('aria-label', `Player ${i + 1}: ${isCpu ? 'CPU' : 'human'}. Click to switch.`); + row.append(swatch, input, kind); + el.nameInputs.append(row); + } +} + +// (wardrobeFilter is also changed from other sections) +function setWardrobeFilter(value) { + return (wardrobeFilter = value); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + loadCosmetics(); + + el.wardrobeBtn.addEventListener('click', () => openWardrobe()); + + el.wardrobeDone.addEventListener('click', closeWardrobe); + + el.wardrobeDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeWardrobe(); + }); + + el.wardrobeDialog.addEventListener('click', (e) => { + if (e.target === el.wardrobeDialog) return closeWardrobe(); + const view = e.target.closest('#wardrobe-view [data-view]'); + if (view) { + wardrobeFilter = view.dataset.view === 'available' ? wardrobeAvailable() : null; + renderWardrobe(); + el.wardrobeDialog.querySelector('.dialog').scrollTop = 0; + return; + } + const chip = e.target.closest('.wardrobe-chip'); + if (!chip || chip.disabled) return; + if (chip.dataset.unlock) return confirmUnlock(chip.dataset.unlock, chip); + chooseCosmetic(chip.dataset.cat, chip.dataset.opt); + }); + + el.stickersBtn.addEventListener('click', () => openStickers()); + + el.stickersDone.addEventListener('click', closeStickers); + + el.stickersDialog.addEventListener('click', (e) => { + if (e.target === el.stickersDialog) closeStickers(); + }); + + el.stickersDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeStickers(); + }); + + el.stickersTrade.addEventListener('click', () => { + const swap = Views.swapSpares(progress, formatPoints); + if (!swap.points) return; + toast(...swap.toast); + saveProgress(); + renderStickers(); + }); + + enforceLocks(); + + el.settingsDialog.addEventListener('click', (e) => { + if (e.target === el.settingsDialog) return el.settingsDone.click(); // backdrop + onSettingClick(e); + applyTheme(); + }); + + applyTheme(false); // the board doesn't exist yet during setup + + // In Auto, redraw the board when the device switches between light and dark. + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => drawBoard()); + + el.setupForm.addEventListener('click', onSettingClick); + + el.settingsBtn.addEventListener('click', () => openSettings(el.settingsBtn)); + + el.gameSettingsBtn.addEventListener('click', () => openSettings(el.gameSettingsBtn)); + + el.settingsDone.addEventListener('click', () => { + el.settingsDialog.hidden = true; + applySettingsNow(); + if (settingsOpener) settingsOpener.focus(); + }); + + el.settingsDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') el.settingsDone.click(); + }); + + syncSettingsUI(); + + syncModeLocks(); + + el.modeCards.forEach((card) => { + card.addEventListener('click', () => { + if (card.dataset.unlock) return confirmUnlock(card.dataset.unlock, card); + setup.mode = card.dataset.mode; + // Local multiplayer is bought in the shop; until then Multiplayer opens on Classroom + if (setup.mode === 'multi' && !Progress.isUnlocked(progress, 'multiplayer')) selectMultiKind('classroom'); + el.modeCards.forEach((c) => { + c.classList.toggle('selected', c === card); + c.setAttribute('aria-checked', String(c === card)); + }); + renderSetupSections(); + updatePracticePreview(); + }); + }); + + el.multiKind.addEventListener('click', (e) => { + const btn = e.target.closest('[data-kind]'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + selectMultiKind(btn.dataset.kind); + }); + + el.playerCount.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + saveNameInputs(); + setup.count = Number(btn.dataset.count); + selectIn(el.playerCount, btn); + renderNameInputs(); + }); + + el.gameLevel.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + setup.level = btn.dataset.level; + renderGameLevel(); + }); + + el.gameTimer.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + setup.timer = Number(btn.dataset.secs); + renderGameLevel(); + }); + + syncBoardLocks(); + + el.boardSize.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + selectIn(el.boardSize, btn); + const custom = btn.dataset.size === 'custom'; + el.customSize.hidden = !custom; + setup.size = custom ? readCustomSize() : Number(btn.dataset.size); + renderBoardHint(); + }); + + el.customSizeInput.addEventListener('input', () => { + if (el.customSizeInput.value !== '') setup.size = readCustomSize(); + renderBoardHint(); + }); + + el.customSizeInput.addEventListener('change', () => setCustomSize(readCustomSize())); + + el.customSize.addEventListener('click', (e) => { + const btn = e.target.closest('[data-step]'); + if (btn) setCustomSize(readCustomSize() + Number(btn.dataset.step)); + }); + + renderNameInputs(); + + el.nameInputs.addEventListener('click', (e) => { + const btn = e.target.closest('.kind-toggle'); + if (!btn) return; + saveNameInputs(); + const i = Number(btn.dataset.index); + setup.types[i] = setup.types[i] === 'cpu' ? 'human' : 'cpu'; + renderNameInputs(); + }); + + el.setupForm.addEventListener('submit', (e) => { + e.preventDefault(); + if (setup.mode === 'multi' && setup.multiKind === 'classroom') return; // not built yet + if (setup.mode === 'practice') { + const name = el.practiceName.value.trim() || 'You'; + return practiceJourneyOn() ? startPracticeJourney(name) : startPracticeAuto(name); + } + saveNameInputs(); + if (!el.customSize.hidden) setCustomSize(readCustomSize()); + let players; + if (setup.mode === 'single') { + players = [ + { name: el.singleName.value.trim() || 'You', cpu: false }, + { name: 'CPU', cpu: true }, + ]; + } else { + // every human player needs a real name + const inputs = [...el.nameInputs.querySelectorAll('input')]; + const missing = inputs.filter((input, i) => setup.types[i] !== 'cpu' && !input.value.trim()); + inputs.forEach((input) => input.classList.toggle('missing', missing.includes(input))); + el.namesError.hidden = !missing.length; + if (missing.length) { + missing[0].focus(); + return; + } + players = setup.names.slice(0, setup.count).map((name, i) => + setup.types[i] === 'cpu' ? { name: cpuName(i), cpu: true } : { name: name.trim(), cpu: false } + ); + } + startGame(players, setup.size, setup.mode); + }); +} + +export { GAME_EXPERT_BONUS, applySettingsForTurn, applyTheme, chooseCosmetic, clampCustom, enforceLocks, icon, look, openSettings, openStickers, openWardrobe, renderBoardHint, renderGameLevel, renderSetupSections, renderWardrobe, saveNameInputs, selectIn, setLockBadge, setWardrobeFilter, syncBoardLocks, syncModeLocks, syncSettingsUI, wardrobeAvailable, wardrobeFilter }; diff --git a/apps/web/src/multiplication/invite.js b/apps/web/src/multiplication/invite.js new file mode 100644 index 0000000..2420c4b --- /dev/null +++ b/apps/web/src/multiplication/invite.js @@ -0,0 +1,112 @@ +// Blockout invites: a teacher or parent packs a game setup (players, board, +// difficulty, settings, a note and a dice seed) into a link or code. +// An ES module. + +const VERSION = 1; +const NOTE_MAX = 140; +const NAME_MAX = 14; + +// Every setting an invite may set, with its allowed values. +const SETTING_VALUES = { + difficulty: ['easy', 'medium', 'hard', 'tricky', 'master', 'legend'], + placeMode: ['draw', 'auto'], + autoRoll: ['manual', 'auto'], + answerTime: ['0', '30', '10', '5', '20'], // 20 s is no longer offered but old invites still open + cpuSpeed: ['slow', 'normal', 'fast'], + cpuSteps: ['show', 'instant'], + cpuEnd: ['auto', 'button'], + showProgress: [false, true], +}; + +// Settings the game no longer has (the corner rule, real dice, when rolls fit): +// old invites may still carry them, so they're ignored and those links still open. +const REMOVED_SETTINGS = ['firstInCorner', 'diceMode', 'fitRolls']; + +// Invite shape (short keys keep links short): +// { v: 1, m: 'single'|'multi', b: boardSize, p: [{ n: name, c: isCpu }], +// s: { setting: value }, t: note, r: dice seed } +// Returns a clean copy, or null if anything is missing or out of range. +function validate(raw) { + if (!raw || typeof raw !== 'object' || raw.v !== VERSION) return null; + if (raw.m !== 'single' && raw.m !== 'multi') return null; + if (!Number.isInteger(raw.b) || raw.b < 6 || raw.b > 30) return null; + if (raw.s && raw.s.difficulty === 'legend' && raw.b < 12) return null; // Legend's teens need room + if (!Array.isArray(raw.p)) return null; + const count = raw.p.length; + if (raw.m === 'single' ? count !== 1 : count < 2 || count > 4) return null; + const players = []; + for (const p of raw.p) { + if (!p || typeof p !== 'object') return null; + const cpu = raw.m === 'multi' && p.c === true; + const name = typeof p.n === 'string' ? p.n.trim().slice(0, NAME_MAX) : ''; + players.push({ n: name, c: cpu }); + } + const s = {}; + for (const [key, given] of Object.entries(raw.s || {})) { + if (REMOVED_SETTINGS.includes(key)) continue; + const value = key === 'placeMode' && given === 'click' ? 'draw' : given; // Click to place is gone: draw it + if (!SETTING_VALUES[key] || !SETTING_VALUES[key].includes(value)) return null; + s[key] = value; + } + const clean = { v: VERSION, m: raw.m, b: raw.b, p: players, s }; + if (typeof raw.t === 'string' && raw.t.trim()) clean.t = raw.t.trim().slice(0, NOTE_MAX); + if (raw.r !== undefined) { + if (!Number.isInteger(raw.r) || raw.r < 0 || raw.r > 0xffffffff) return null; + clean.r = raw.r; + } + return clean; +} + +// base64url of the UTF-8 JSON, so names and notes can use any language. +function encode(invite) { + const bytes = new TextEncoder().encode(JSON.stringify(invite)); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function decode(token) { + try { + const b64 = token.trim().replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)); + const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)); + return validate(JSON.parse(new TextDecoder().decode(bytes))); + } catch (_) { + return null; + } +} + +// Accepts a bare code, a "#invite=CODE" fragment or a full link. +function tokenFrom(text) { + const match = String(text).match(/[#&?]invite=([A-Za-z0-9_-]+)/); + if (match) return match[1]; + const bare = String(text).trim(); + return /^[A-Za-z0-9_-]+$/.test(bare) ? bare : null; +} + +// Small, fast seeded generator (mulberry32): same seed, same dice. +function seededRandom(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function newSeed() { + return Math.floor(Math.random() * 0xffffffff); +} + +export { + VERSION, + SETTING_VALUES, + validate, + encode, + decode, + tokenFrom, + seededRandom, + newSeed, +}; diff --git a/apps/web/src/multiplication/main.js b/apps/web/src/multiplication/main.js new file mode 100644 index 0000000..d95d40a --- /dev/null +++ b/apps/web/src/multiplication/main.js @@ -0,0 +1,6 @@ +// The multiplication game: styles, the game itself, then the account screens. +import './style.css'; +import './game/index.js'; +import { run as accounts } from './accounts.js'; + +accounts(); diff --git a/apps/web/src/multiplication/style.css b/apps/web/src/multiplication/style.css new file mode 100644 index 0000000..e9e7210 --- /dev/null +++ b/apps/web/src/multiplication/style.css @@ -0,0 +1,16 @@ +/* The multiplication game's styles, in their original order: shared pieces from + @blockout/ui and this game's own (./css). Order matters for the cascade. */ +@import '@blockout/ui/css/tokens.css'; +@import '@blockout/ui/css/base.css'; +@import '@blockout/ui/css/menu.css'; +@import '@blockout/ui/css/game.css'; +@import './css/classroom.css'; +@import '@blockout/ui/css/play.css'; +@import '@blockout/ui/css/dice-skins.css'; +@import '@blockout/ui/css/results.css'; +@import '@blockout/ui/css/panels.css'; +@import '@blockout/ui/css/rewards.css'; +@import '@blockout/ui/css/small-screens.css'; +@import '@blockout/ui/css/wardrobe.css'; +@import '@blockout/ui/css/players.css'; +@import './css/accounts.css'; diff --git a/apps/web/src/operation.js b/apps/web/src/operation.js new file mode 100644 index 0000000..dd1679f --- /dev/null +++ b/apps/web/src/operation.js @@ -0,0 +1,6 @@ +// The other operations' games (/addition/, …): the shared game kit with the +// rules named by the page's . +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame(document.body.dataset.variant); diff --git a/apps/web/src/snake/main.js b/apps/web/src/snake/main.js new file mode 100644 index 0000000..e77d2f6 --- /dev/null +++ b/apps/web/src/snake/main.js @@ -0,0 +1,379 @@ +// Number Snake: the page. The rules are in ./rules.js; this draws the board, +// runs the clock and reads the keys, swipes and on-screen arrows. +import './style.css'; +import { toast, confettiFrom, confettiFullScreen } from '@blockout/ui/rewards'; +import * as R from './rules.js'; + +const $ = (id) => document.getElementById(id); +const SAVE_KEY = 'blockout.snake'; // { settings: { mode, level }, best: { 'answer.easy': 120, … } } + +const el = { + start: $('start-screen'), + gameScreen: $('game-screen'), + canvas: $('board'), + task: $('task'), + fact: $('fact'), + hearts: $('hearts'), + score: $('score'), + best: $('best'), + pause: $('pause-btn'), + over: $('over'), + ask: $('ask'), + askQ: $('ask-q'), + askBox: $('ask-box'), + dpad: document.querySelector('.dpad'), +}; +const ctx = el.canvas.getContext('2d'); + +// ---------------------------------------------------------------- saved settings and best scores + +const DEFAULTS = { mode: 'answer', level: 'easy', movement: 'auto', speed: 'slow', walls: 'bump' }; +const manual = () => save.settings.movement === 'manual'; +const save = (() => { + try { + const saved = JSON.parse(localStorage.getItem(SAVE_KEY)) || {}; + return { best: {}, ...saved, settings: { ...DEFAULTS, ...(saved.settings || {}) } }; + } catch (e) { + return { settings: { ...DEFAULTS }, best: {} }; + } +})(); +const writeSave = () => { + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(save)); + } catch (e) {} +}; +const bestKey = () => `${save.settings.mode}.${save.settings.level}`; + +// ---------------------------------------------------------------- menu + +// what each difficulty means depends on the way to play +const levelSub = (l) => (save.settings.mode === 'sum' ? `add ${l.add[0]}–${l.add[1]} · to ${l.goal}` : l.sub); + +function renderMenu() { + $('level').innerHTML = Object.entries(R.LEVELS) + .map(([id, l]) => ``) + .join(''); + for (const b of document.querySelectorAll('.mode-card')) { + const on = b.dataset.mode === save.settings.mode; + b.classList.toggle('selected', on); + b.setAttribute('aria-checked', on); + } + for (const b of document.querySelectorAll('#level button')) b.classList.toggle('selected', b.dataset.level === save.settings.level); + $('movement').innerHTML = Object.entries(R.MOVEMENTS) + .map(([id, m]) => ``) + .join(''); + $('speed-field').hidden = manual(); // manual has no clock + $('speed').innerHTML = Object.entries(R.SPEEDS) + .map(([id, sp]) => ``) + .join(''); + $('walls').innerHTML = Object.entries(R.WALLS) + .map(([id, w]) => ``) + .join(''); + el.best.textContent = save.best[bestKey()] || 0; + writeSave(); +} +document.querySelector('.mode-picker').addEventListener('click', (e) => { + const b = e.target.closest('[data-mode]'); + if (b) (save.settings.mode = b.dataset.mode), renderMenu(); +}); +$('level').addEventListener('click', (e) => { + const b = e.target.closest('[data-level]'); + if (b) (save.settings.level = b.dataset.level), renderMenu(); +}); +$('movement').addEventListener('click', (e) => { + const b = e.target.closest('[data-movement]'); + if (b) (save.settings.movement = b.dataset.movement), renderMenu(); +}); +$('speed').addEventListener('click', (e) => { + const b = e.target.closest('[data-speed]'); + if (b) (save.settings.speed = b.dataset.speed), renderMenu(); +}); +$('walls').addEventListener('click', (e) => { + const b = e.target.closest('[data-walls]'); + if (b) (save.settings.walls = b.dataset.walls), renderMenu(); +}); +$('setup-form').addEventListener('submit', (e) => { + e.preventDefault(); + start(); +}); +renderMenu(); + +// ---------------------------------------------------------------- the game loop + +let game = null; +let clock = null; +let paused = false; + +function start() { + game = R.createGame({ mode: save.settings.mode, level: save.settings.level, walls: save.settings.walls }); + paused = false; + el.pause.textContent = 'Pause'; + el.pause.hidden = manual(); // nothing to pause when it only moves on a press + el.over.hidden = true; + el.start.hidden = true; + el.gameScreen.hidden = false; + el.fact.textContent = ''; + el.fact.className = 'snake-fact'; + resize(); + render(); + run(); +} + +// the clock speeds up as the snake eats (auto movement only) +function run() { + clearInterval(clock); + if (!manual()) clock = setInterval(step, R.tickMs(game, save.settings.speed)); +} + +function step() { + if (!game || paused || game.asking) return; + moved(R.tick(game)); +} + +// Manual movement: an arrow press moves one square that way +function stepTo(dir) { + if (!game || game.asking) return; + moved(R.step(game, dir)); +} + +// after the snake moved (or bumped, or ate): messages, speed, the end +function moved(last) { + if (last && last.asking) return startAsking(); + if (last && last.bump) { + el.fact.textContent = last.hurt ? 'Ouch! That cost a heart. Pick a way to go.' : 'Bump! Pick a way to go.'; + el.fact.className = `snake-fact ${last.hurt ? 'bad' : ''}`; + } + if (last && last.ate !== null) { + el.fact.textContent = last.good ? `Yum! ${last.fact}` : `Oops: ${last.fact}`; + el.fact.className = `snake-fact ${last.good ? 'good' : 'bad'}`; + if (last.cleared) { + toast(`🎉 Every multiple of ${last.cleared}!`, `+${R.POINTS.cleared} bonus points`); + confettiFrom(el.canvas, 1); + } + if (last.good) run(); // a little faster + } + render(); + if (game.over) finish(); +} + +// ---------------------------------------------------------------- add it up: the new total + +let typed = ''; +function startAsking() { + typed = ''; + el.fact.textContent = ''; + render(); +} + +function press(key) { + if (!game || !game.asking) return; + if (key === 'back') typed = typed.slice(0, -1); + else if (key === 'check') return typed && submit(); + else if (/^\d$/.test(key) && typed.length < 4) typed += key; + render(); +} + +function submit() { + const last = R.answer(game, Number(typed)); + typed = ''; + el.fact.textContent = last.good ? `Yes! ${last.fact}` : `Oops: ${last.fact}`; + el.fact.className = `snake-fact ${last.good ? 'good' : 'bad'}`; + if (last.goal) { + toast(`🎉 You reached ${last.goal}!`, `+${R.POINTS.goal} bonus points · a new round starts at 0`); + confettiFrom(el.canvas, 1); + } + if (last.good) run(); + render(); + if (game.over) finish(); +} +document.getElementById('keypad').addEventListener('click', (e) => { + const b = e.target.closest('[data-key]'); + if (b) press(b.dataset.key); +}); + +function finish() { + clearInterval(clock); + const key = bestKey(); + const best = save.best[key] || 0; + const record = game.score > best; + if (record) save.best[key] = game.score; + writeSave(); + $('over-title').textContent = { wall: 'Bonk! That’s the wall.', self: 'Oops, you bit your tail!', hearts: 'Out of hearts!' }[game.why]; + $('over-sub').textContent = `${game.score} points · ${game.eaten} right${record && game.score ? ' · a new best!' : ` · best ${Math.max(best, game.score)}`}`; + const extra = $('over-extra'); + extra.innerHTML = ''; + if (game.missed.length) { + const p = document.createElement('p'); + p.className = 'missed-title'; + p.textContent = 'Worth another look:'; + extra.append(p); + const list = document.createElement('div'); + list.className = 'missed'; + for (const m of game.missed) { + const chip = document.createElement('span'); + chip.className = 'missed-chip'; + chip.textContent = m.answer !== null ? `${m.text} = ${m.answer} (not ${m.ate})` : `${m.ate}: not a ${m.text}`; + list.append(chip); + } + extra.append(list); + } + el.over.hidden = false; + $('over-again').focus(); + if (record && game.score) confettiFullScreen(); +} + +function quit() { + clearInterval(clock); + game = null; + el.over.hidden = true; + el.gameScreen.hidden = true; + el.start.hidden = false; + renderMenu(); +} + +function togglePause() { + if (!game || game.over) return; + paused = !paused; + el.pause.textContent = paused ? 'Go' : 'Pause'; + render(); +} + +$('quit-btn').addEventListener('click', quit); +$('over-menu').addEventListener('click', quit); +$('over-again').addEventListener('click', start); +el.pause.addEventListener('click', togglePause); + +// ---------------------------------------------------------------- steering + +const KEYS = { ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right', w: 'up', s: 'down', a: 'left', d: 'right' }; +document.addEventListener('keydown', (e) => { + if (!game || el.gameScreen.hidden) return; + if (e.key === ' ') { + e.preventDefault(); + return togglePause(); + } + if (e.key === 'Enter' && game.over) return start(); + if (game.asking) { + if (/^\d$/.test(e.key)) press(e.key); + else if (e.key === 'Backspace') press('back'); + else if (e.key === 'Enter') press('check'); + else return; + return e.preventDefault(); + } + const dir = KEYS[e.key] || KEYS[e.key.toLowerCase()]; + if (!dir) return; + e.preventDefault(); + if (manual()) return stepTo(dir); + if (paused) togglePause(); + R.turn(game, dir); +}); +document.querySelector('.dpad').addEventListener('click', (e) => { + const b = e.target.closest('[data-dir]'); + if (b && game) manual() ? stepTo(b.dataset.dir) : R.turn(game, b.dataset.dir); +}); +// swipe on the board +let touch = null; +el.canvas.addEventListener('touchstart', (e) => (touch = { x: e.touches[0].clientX, y: e.touches[0].clientY }), { passive: true }); +el.canvas.addEventListener('touchmove', (e) => e.preventDefault(), { passive: false }); +el.canvas.addEventListener('touchend', (e) => { + if (!touch || !game) return; + const dx = e.changedTouches[0].clientX - touch.x; + const dy = e.changedTouches[0].clientY - touch.y; + touch = null; + if (Math.max(Math.abs(dx), Math.abs(dy)) < 20) return; + const dir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'down' : 'up'; + manual() ? stepTo(dir) : R.turn(game, dir); +}); + +// ---------------------------------------------------------------- drawing + +let cell = 30; +function resize() { + if (!game) return; + const wrap = el.canvas.parentElement.getBoundingClientRect(); + const room = Math.min(wrap.width - 24, window.innerHeight - 140); + cell = Math.max(16, Math.floor(room / game.size)); + const px = cell * game.size; + const dpr = window.devicePixelRatio || 1; + el.canvas.width = px * dpr; + el.canvas.height = px * dpr; + el.canvas.style.width = `${px}px`; + el.canvas.style.height = `${px}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); +} +window.addEventListener('resize', () => (resize(), render())); + +const css = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + +function render() { + if (!game) return; + const n = game.size; + const px = cell * n; + // board: soft checkerboard + ctx.fillStyle = css('--surface') || '#fffdf7'; + ctx.fillRect(0, 0, px, px); + ctx.fillStyle = css('--soft') || '#f4efe6'; + for (let y = 0; y < n; y++) for (let x = (y % 2); x < n; x += 2) ctx.fillRect(x * cell, y * cell, cell, cell); + // numbers + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const num of game.numbers) { + const cx = num.x * cell + cell / 2; + const cy = num.y * cell + cell / 2; + ctx.fillStyle = css('--gold-bg') || '#fff6d6'; + ctx.strokeStyle = css('--gold-line') || '#e6c35c'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(cx, cy, cell * 0.46, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = css('--ink') || '#2b2a33'; + const digits = String(num.value).length; + ctx.font = `800 ${Math.round(cell * (digits > 2 ? 0.36 : digits > 1 ? 0.46 : 0.56))}px system-ui, sans-serif`; + ctx.fillText(String(num.value), cx, cy + 1); + } + // snake: head darker, with eyes looking the way it goes + const body = css('--good') || '#2a9d5c'; + game.snake.forEach((s, i) => { + ctx.fillStyle = i === 0 ? css('--good-ink') || '#1d6e41' : body; + const inset = i === 0 ? 1 : 3; + roundRect(s.x * cell + inset, s.y * cell + inset, cell - inset * 2, cell - inset * 2, cell * 0.3); + }); + const h = game.snake[0]; + const [ex, ey] = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[game.dir]; + ctx.fillStyle = '#fff'; + for (const side of [-1, 1]) { + const x = h.x * cell + cell / 2 + ex * cell * 0.18 + ey * side * cell * 0.18; + const y = h.y * cell + cell / 2 + ey * cell * 0.18 + ex * side * cell * 0.18; + ctx.beginPath(); + ctx.arc(x, y, cell * 0.09, 0, Math.PI * 2); + ctx.fill(); + } + if (paused) { + ctx.fillStyle = 'rgba(0, 0, 0, 0.35)'; + ctx.fillRect(0, 0, px, px); + ctx.fillStyle = '#fff'; + ctx.font = `800 ${Math.round(cell * 1.2)}px system-ui, sans-serif`; + ctx.fillText('Paused', px / 2, px / 2); + } + // side panel + el.task.textContent = + game.task.kind === 'multiples' ? `${game.task.text} (${game.task.left} to go)` : game.task.kind === 'sum' ? `Total ${game.total} · goal ${game.task.goal}` : game.task.text; + el.ask.hidden = !game.asking; + el.dpad.hidden = Boolean(game.asking); + if (game.asking) { + el.askQ.textContent = game.asking.text.replace(' = ?', ' ='); + el.askBox.textContent = typed; + } + el.hearts.textContent = '❤️'.repeat(Math.max(0, game.hearts)) + '🤍'.repeat(Math.max(0, R.HEARTS - game.hearts)); + el.score.textContent = game.score; +} + +function roundRect(x, y, w, h, r) { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + ctx.fill(); +} + +// For automated checks: the game's state +window.SnakeGame = { state: () => game, turn: (d) => game && R.turn(game, d), tick: () => step(), step: (d) => stepTo(d), pause: () => togglePause() }; diff --git a/apps/web/src/snake/rules.js b/apps/web/src/snake/rules.js new file mode 100644 index 0000000..8a8eb45 --- /dev/null +++ b/apps/web/src/snake/rules.js @@ -0,0 +1,302 @@ +// Number Snake: the rules, with no drawing or timers, so they can be tested. +// A snake moves one square per tick. Instead of apples there are numbers on the +// board: eat the right one to grow, the wrong one costs a heart. +// +// Three ways to play: +// answer a question (6 × 7 = ?); one number on the board is the answer, +// the others are near misses (6 × 6, 6 × 8, 7 × 7 …) +// multiples "eat the multiples of 4": several multiples and some that aren't; +// eat every multiple to move on to the next table +// sum "add it up": eat any number you like; it's added to a running +// total and the snake waits until you say the new total. Bigger +// numbers are harder sums and score more. Reach the goal to start again. +// +// Everything random goes through `rng`, so a game can be replayed in tests. + +export const LEVELS = { + // add: the numbers on the board in "add it up", and the total that finishes a round + easy: { label: 'Easy', sub: '×2, ×5, ×10', tables: [2, 5, 10], upTo: 10, size: 12, add: [1, 9], goal: 30 }, + medium: { label: 'Medium', sub: '×1 to ×6', tables: [1, 2, 3, 4, 5, 6], upTo: 10, size: 14, add: [5, 25], goal: 100 }, + hard: { label: 'Hard', sub: '×1 to ×12', tables: [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], upTo: 12, size: 16, add: [11, 99], goal: 500 }, +}; + +// How fast the snake goes, whatever the maths: ms per square at the start, +// a little faster with every right answer (speedUp ms), never faster than fastest. +export const SPEEDS = { + slow: { label: '🐌 Slow', ms: 450, speedUp: 2, fastest: 350 }, + normal: { label: '🐢 Normal', ms: 320, speedUp: 3, fastest: 220 }, + fast: { label: '🐇 Fast', ms: 220, speedUp: 3, fastest: 140 }, +}; +export const tickMs = (game, speed) => Math.max(SPEEDS[speed].fastest, SPEEDS[speed].ms - game.eaten * SPEEDS[speed].speedUp); + +// What happens at the wall +// bump the snake stops and waits for you to turn (no loss) +// wrap it comes back in on the other side +// soft it stops and you lose a heart +// classic game over +export const WALLS = { + bump: { label: 'Stop', sub: 'wait for a turn' }, + wrap: { label: 'Wrap', sub: 'come out the other side' }, + soft: { label: 'Lose a heart', sub: 'and stop' }, + classic: { label: 'Game over', sub: 'the classic rule' }, +}; +export const MODES = ['answer', 'multiples', 'sum']; +export const SUM_CHOICES = 4; // numbers on the board in "add it up" +export const HEARTS = 3; +export const POINTS = { right: 10, cleared: 25, goal: 50 }; // a right number; every multiple of a table eaten; a sum round finished +const DIRS = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }; +const OPPOSITE = { up: 'down', down: 'up', left: 'right', right: 'left' }; + +const pick = (rng, list) => list[Math.floor(rng() * list.length)]; +const key = (x, y) => `${x},${y}`; + +export function createGame({ mode = 'answer', level = 'easy', walls = 'bump', rng = Math.random } = {}) { + const L = LEVELS[level]; + const mid = Math.floor(L.size / 2); + const game = { + mode, + level, + walls: WALLS[walls] ? walls : 'bump', + atWall: false, // stopped at a wall, waiting for a turn + size: L.size, + rng, + // head first, moving right + snake: [ + { x: mid, y: mid }, + { x: mid - 1, y: mid }, + { x: mid - 2, y: mid }, + ], + dir: 'right', + queued: [], // turns pressed since the last tick (at most 2 kept) + grow: 0, // squares still to grow + hearts: HEARTS, + score: 0, + eaten: 0, // right numbers + missed: [], // { text, answer, ate } for the results + numbers: [], // { x, y, value, good } + task: null, // { kind, text, … } + over: false, + why: null, // 'wall' | 'self' | 'hearts' + last: null, // what the last tick did, for the page: { ate, good, fact } + total: 0, // "add it up": the running total + asking: null, // "add it up": { before, add, answer } while the snake waits for the new total + }; + newTask(game); + return game; +} + +// ---------------------------------------------------------------- tasks + +function newTask(game) { + const L = LEVELS[game.level]; + if (game.mode === 'sum') { + game.task = { kind: 'sum', goal: L.goal, text: `Total: ${game.total}` }; + const values = new Set(); + while (values.size < SUM_CHOICES) values.add(L.add[0] + Math.floor(game.rng() * (L.add[1] - L.add[0] + 1))); + placeNumbers(game, [...values].map((value) => ({ value, good: true }))); + return; + } + if (game.mode === 'answer') { + const a = pick(game.rng, L.tables); + const b = 1 + Math.floor(game.rng() * L.upTo); + const answer = a * b; + // near misses: the facts next door, all different from the answer + const wrong = new Set(); + for (const v of [a * (b + 1), a * (b - 1), (a + 1) * b, (a - 1) * b, answer + 1, answer - 1, answer + 10]) { + if (v > 0 && v !== answer) wrong.add(v); + } + const distractors = shuffle(game.rng, [...wrong]).slice(0, 3); + game.task = { kind: 'answer', a, b, answer, text: `${a} × ${b} = ?` }; + placeNumbers(game, [{ value: answer, good: true }, ...distractors.map((value) => ({ value, good: false }))]); + } else { + const table = pick(game.rng, L.tables.filter((t) => t > 1 || L.tables.length === 1)); + const multiples = shuffle(game.rng, range(1, L.upTo).map((n) => n * table)).slice(0, 4); + const others = new Set(); + while (others.size < 3) { + const v = 1 + Math.floor(game.rng() * table * L.upTo); + if (v % table !== 0) others.add(v); + } + game.task = { kind: 'multiples', table, left: multiples.length, text: `Eat the multiples of ${table}` }; + placeNumbers(game, [...multiples.map((value) => ({ value, good: true })), ...[...others].map((value) => ({ value, good: false }))]); + } +} + +function range(a, b) { + return Array.from({ length: b - a + 1 }, (_, i) => a + i); +} + +function shuffle(rng, list) { + const out = [...list]; + for (let i = out.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [out[i], out[j]] = [out[j], out[i]]; + } + return out; +} + +// Free squares in random order: not on the snake, another number, or right in +// front of the snake's head. Unless the walls wrap, not along the edges either, +// so a number never lures you into a wall. +function freeSpots(game) { + const taken = new Set([...game.snake, ...game.numbers].map((s) => key(s.x, s.y))); + const head = game.snake[0]; + const [dx, dy] = DIRS[game.dir]; + for (let i = 1; i <= 2; i++) taken.add(key(head.x + dx * i, head.y + dy * i)); + const edge = game.walls === 'wrap' ? 0 : 1; + const free = []; + for (let y = edge; y < game.size - edge; y++) for (let x = edge; x < game.size - edge; x++) if (!taken.has(key(x, y))) free.push({ x, y }); + return shuffle(game.rng, free); +} + +function placeNumbers(game, items) { + game.numbers = []; + const spots = freeSpots(game); + game.numbers = items.slice(0, spots.length).map((item, i) => ({ ...spots[i], ...item })); +} + +// ---------------------------------------------------------------- moving + +// A turn from the arrows or a swipe; can't reverse straight into yourself. +export function turn(game, dir) { + if (!DIRS[dir] || game.over) return false; + const last = game.queued.length ? game.queued[game.queued.length - 1] : game.dir; + if (dir === last || dir === OPPOSITE[last] || game.queued.length >= 2) return false; + game.queued.push(dir); + return true; +} + +// How the snake moves: on its own (auto), or one square per arrow press (manual) +export const MOVEMENTS = { + auto: { label: 'Auto', sub: 'it keeps going' }, + manual: { label: 'Manual', sub: 'one square per press' }, +}; + +// Manual movement: face that way (not straight back) and take one step. +export function step(game, dir) { + if (game.over || game.asking || !DIRS[dir] || dir === OPPOSITE[game.dir]) return null; + game.queued = []; + game.dir = dir; + return tick(game); +} + +// One square forward. Returns game.last. +export function tick(game) { + if (game.over || game.asking) return null; // "add it up": waiting for the new total + if (game.queued.length) game.dir = game.queued.shift(); + const [dx, dy] = DIRS[game.dir]; + const head = { x: game.snake[0].x + dx, y: game.snake[0].y + dy }; + game.last = { ate: null, good: null, fact: null }; + if (head.x < 0 || head.y < 0 || head.x >= game.size || head.y >= game.size) { + if (game.walls === 'wrap') { + head.x = (head.x + game.size) % game.size; + head.y = (head.y + game.size) % game.size; + } else if (game.walls === 'classic') return end(game, 'wall'); + else { + // stop and wait for a turn; a soft wall costs a heart (once per bump) + if (!game.atWall && game.walls === 'soft') { + game.hearts--; + game.last.hurt = true; + if (game.hearts <= 0) return end(game, 'hearts'); + } + game.atWall = true; + game.last.bump = true; + return game.last; + } + } + game.atWall = false; + // the tail moves out of the way this tick unless the snake is growing + const body = game.grow > 0 ? game.snake : game.snake.slice(0, -1); + if (body.some((s) => s.x === head.x && s.y === head.y)) return end(game, 'self'); + game.snake.unshift(head); + if (game.grow > 0) game.grow--; + else game.snake.pop(); + + const n = game.numbers.find((m) => m.x === head.x && m.y === head.y); + if (n) eat(game, n); + return game.last; +} + +function eat(game, n) { + const t = game.task; + game.last.ate = n.value; + game.last.good = n.good; + game.numbers = game.numbers.filter((m) => m !== n); + if (t.kind === 'sum') { + // stop and ask for the new total (answer() carries on) + game.asking = { before: game.total, add: n.value, answer: game.total + n.value, text: `${game.total} + ${n.value} = ?` }; + game.last.asking = game.asking; + game.last.good = null; + return; + } + if (n.good) { + game.grow += 1; + game.eaten++; + game.score += POINTS.right; + if (t.kind === 'answer') { + game.last.fact = `${t.a} × ${t.b} = ${t.answer}`; + newTask(game); + } else { + game.last.fact = `${n.value} = ${n.value / t.table} × ${t.table}`; + t.left--; + if (t.left === 0) { + game.score += POINTS.cleared; + game.last.cleared = t.table; + newTask(game); + } + } + return; + } + // wrong: a heart, and the fact to learn from it + game.hearts--; + if (t.kind === 'answer') { + game.last.fact = `${t.a} × ${t.b} = ${t.answer}, not ${n.value}`; + game.missed.push({ text: `${t.a} × ${t.b}`, answer: t.answer, ate: n.value }); + } else { + game.last.fact = `${n.value} isn’t in the ×${t.table} table`; + game.missed.push({ text: `multiple of ${t.table}`, answer: null, ate: n.value }); + } + if (game.snake.length > 2) game.snake.pop(); // a wrong number shrinks you too + if (game.hearts <= 0) end(game, 'hearts'); +} + +// "Add it up": the player's new total. Right: grow and score the number eaten; +// wrong: a heart (the total still becomes the right one, so the game goes on). +export function answer(game, value) { + const q = game.asking; + if (!q || game.over) return null; + const right = Number(value) === q.answer; + const L = LEVELS[game.level]; + game.asking = null; + game.total = q.answer; + game.last = { ate: q.add, good: right, fact: `${q.before} + ${q.add} = ${q.answer}${right ? '' : `, not ${value}`}`, answered: true }; + if (right) { + game.grow += 1; + game.eaten++; + game.score += q.add; + } else { + game.hearts--; + game.missed.push({ text: `${q.before} + ${q.add}`, answer: q.answer, ate: Number(value) }); + if (game.snake.length > 2) game.snake.pop(); + if (game.hearts <= 0) return end(game, 'hearts'); + } + if (game.total >= L.goal) { + game.score += POINTS.goal; + game.last.goal = L.goal; + game.total = 0; + } + // the other numbers stay where they are; one new one replaces the eaten one + game.task = { kind: 'sum', goal: L.goal, text: `Total: ${game.total}` }; + const values = new Set(game.numbers.map((m) => m.value)); + let fresh; + do fresh = L.add[0] + Math.floor(game.rng() * (L.add[1] - L.add[0] + 1)); + while (values.has(fresh) && values.size < L.add[1] - L.add[0] + 1); + const spot = freeSpots(game)[0]; + if (spot) game.numbers.push({ ...spot, value: fresh, good: true }); + return game.last; +} + +function end(game, why) { + game.over = true; + game.why = why; + return game.last; +} diff --git a/apps/web/src/snake/style.css b/apps/web/src/snake/style.css new file mode 100644 index 0000000..7579438 --- /dev/null +++ b/apps/web/src/snake/style.css @@ -0,0 +1,36 @@ +/* Number Snake: the shared Blockout look, plus the snake's panel and arrows. */ +@import '@blockout/ui/css/tokens.css'; +@import '@blockout/ui/css/base.css'; +@import '@blockout/ui/css/menu.css'; +@import '@blockout/ui/css/game.css'; +@import '@blockout/ui/css/play.css'; +@import '@blockout/ui/css/results.css'; +@import '@blockout/ui/css/rewards.css'; +@import '@blockout/ui/css/small-screens.css'; +@import '@blockout/game-kit/style.css'; + +#board { cursor: default; border-radius: 10px; touch-action: none; } +.snake-panel { text-align: center; } +.snake-task { font-size: 1.6rem; font-weight: 900; line-height: 1.2; } +.snake-fact { min-height: 2.6em; margin: 10px 0; font-weight: 800; color: var(--muted); } +.snake-fact.good { color: var(--good-ink); } +.snake-fact.bad { color: var(--bad); } +.snake-stats { display: flex; justify-content: space-between; align-items: center; font-weight: 900; font-size: 1.2rem; } +.snake-score small { font-size: 0.8rem; color: var(--muted); } +.snake-keys { margin-top: 14px; } +.dpad { display: grid; grid-template-columns: repeat(3, 64px); grid-template-rows: repeat(2, 56px); gap: 6px; justify-content: center; } +.dpad [data-dir="up"] { grid-column: 2; grid-row: 1; } +.dpad [data-dir="left"] { grid-column: 1; grid-row: 2; } +.dpad [data-dir="down"] { grid-column: 2; grid-row: 2; } +.dpad [data-dir="right"] { grid-column: 3; grid-row: 2; } +.dpad .btn { font-size: 1.3rem; padding: 0; } +#level small, #walls small, #movement small { display: block; } +#walls { display: grid; grid-template-columns: repeat(4, 1fr); } +@media (max-width: 480px) { #walls { grid-template-columns: repeat(2, 1fr); } } +@media (max-width: 480px) { + .game-header .logo.small { font-size: 1.25rem; } +} + +.snake-ask { margin-top: 10px; display: flex; flex-direction: column; align-items: center; gap: 10px; } +.snake-ask .math-q { font-size: 1.6rem; font-weight: 900; display: flex; align-items: center; gap: 8px; } +.snake-ask .answer-box { min-width: 3.2em; } diff --git a/apps/web/subtraction/index.html b/apps/web/subtraction/index.html new file mode 100644 index 0000000..092986e --- /dev/null +++ b/apps/web/subtraction/index.html @@ -0,0 +1,12 @@ + + + + + + Subtraction · Blockout + + + + + + diff --git a/apps/web/test/demos/demos.test.js b/apps/web/test/demos/demos.test.js new file mode 100644 index 0000000..4f94753 --- /dev/null +++ b/apps/web/test/demos/demos.test.js @@ -0,0 +1,53 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import { DEMOS, GROUPS } from '../../src/demos/catalog.js'; +import { LEVELS, makeFact, nearMisses, factorPair, isPrime } from '../../src/demos/kit.js'; + +// a repeatable "random" for tests +function seeded(seed = 1) { + let s = seed; + return () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648); +} + +test('every demo in the catalog has a game module that mounts, and every module is listed', () => { + const files = fs.readdirSync(new URL('../../src/demos/games/', import.meta.url)).filter((f) => f.endsWith('.js')); + const ids = DEMOS.map((d) => d.id); + assert.equal(new Set(ids).size, ids.length, 'ids are unique'); + for (const id of ids) { + assert.ok(files.includes(`${id}.js`), `${id}.js exists`); + const src = fs.readFileSync(new URL(`../../src/demos/games/${id}.js`, import.meta.url), 'utf8'); + assert.match(src, /export function mount\(stage, ctx\)/, `${id} exports mount(stage, ctx)`); + } + for (const f of files) assert.ok(ids.includes(f.replace('.js', '')), `${f} is in the catalog`); + for (const d of DEMOS) { + assert.ok(GROUPS.some((g) => g.id === d.group), `${d.id} is in a group`); + assert.ok(d.name && d.classic && d.idea && d.how.length >= 2, `${d.id} has its text`); + } + assert.equal(DEMOS.length, 26); +}); + +test('facts come from the level’s tables and are right; near misses are never the answer', () => { + for (const level of Object.keys(LEVELS)) { + const rng = seeded(3); + for (let i = 0; i < 200; i++) { + const f = makeFact(level, rng); + assert.equal(f.answer, f.a * f.b); + assert.equal(f.text, `${f.a} × ${f.b}`); + assert.ok(LEVELS[level].tables.includes(f.a) || LEVELS[level].tables.includes(f.b), `${f.text} on ${level}`); + const wrong = nearMisses(rng, f.answer, 3, f); + assert.equal(wrong.length, 3); + assert.ok(wrong.every((w) => w > 0 && w !== f.answer)); + assert.equal(new Set(wrong).size, 3); + } + } +}); + +test('factor pairs and primes', () => { + const rng = seeded(5); + assert.equal(factorPair(rng, 7), null); + const [a, b] = factorPair(rng, 24); + assert.equal(a * b, 24); + assert.ok(a > 1 && b > 1); + assert.deepEqual([2, 3, 4, 9, 11, 1].map(isPrime), [true, true, false, false, true, false]); +}); diff --git a/test/invite.test.js b/apps/web/test/multiplication/invite.test.js similarity index 85% rename from test/invite.test.js rename to apps/web/test/multiplication/invite.test.js index 6927580..849e8db 100644 --- a/test/invite.test.js +++ b/apps/web/test/multiplication/invite.test.js @@ -1,6 +1,6 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const I = require('../js/invite.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as I from '../../src/multiplication/invite.js'; const sample = { v: 1, @@ -37,8 +37,6 @@ test('invalid or tampered invites are rejected', () => { assert.equal(bad({ r: -1 }), null); assert.equal(bad({ s: { autoRoll: 'sometimes' } }), null); assert.ok(bad({ s: { autoRoll: 'auto' } })); - assert.ok(bad({ s: { fitRolls: 'always' } })); - assert.equal(bad({ s: { fitRolls: 'sometimes' } }), null); assert.equal(I.decode('%%%'), null); assert.equal(I.decode(I.encode({ hello: 'world' })), null); }); @@ -66,3 +64,8 @@ test('invites: difficulties after Hard, and Legend only on a board it fits', () } assert.equal(I.validate({ ...sample, b: 8, s: { ...sample.s, difficulty: 'legend' } }), null); }); + +test('old invites with settings the game no longer has still open', () => { + const old = I.validate({ ...sample, s: { difficulty: 'easy', firstInCorner: true, diceMode: 'real', fitRolls: 'never', placeMode: 'click' } }); + assert.deepEqual(old.s, { difficulty: 'easy', placeMode: 'draw' }); +}); diff --git a/apps/web/test/snake/rules.test.js b/apps/web/test/snake/rules.test.js new file mode 100644 index 0000000..14b3482 --- /dev/null +++ b/apps/web/test/snake/rules.test.js @@ -0,0 +1,207 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import * as R from '../../src/snake/rules.js'; + +// a repeatable "random" for tests +function seeded(seed = 1) { + let s = seed; + return () => ((s = (s * 1103515245 + 12345) % 2147483648) / 2147483648); +} + +// Steer the head straight to a square (right/left first, then up/down), one tick at a time. +function goTo(game, target) { + for (let i = 0; i < 100 && !game.over; i++) { + const h = game.snake[0]; + if (h.x === target.x && h.y === target.y) return; + const want = h.x < target.x ? 'right' : h.x > target.x ? 'left' : h.y < target.y ? 'down' : 'up'; + const back = { up: 'down', down: 'up', left: 'right', right: 'left' }[game.dir]; + // can't reverse: step sideways first (towards the middle of the board) + if (want === back) R.turn(game, ['left', 'right'].includes(want) ? (h.y < game.size / 2 ? 'down' : 'up') : h.x < game.size / 2 ? 'right' : 'left'); + else R.turn(game, want); + R.tick(game); + } +} + +test('answer mode: one right number among near misses, all on free squares', () => { + for (let seed = 1; seed < 40; seed++) { + const g = R.createGame({ mode: 'answer', level: 'hard', rng: seeded(seed) }); + const good = g.numbers.filter((n) => n.good); + assert.equal(good.length, 1); + assert.equal(good[0].value, g.task.a * g.task.b); + assert.equal(g.numbers.length, 4); + assert.equal(new Set(g.numbers.map((n) => n.value)).size, 4, 'no repeats'); + for (const n of g.numbers) { + assert.ok(n.value > 0); + assert.ok(!g.snake.some((s) => s.x === n.x && s.y === n.y), 'not on the snake'); + } + } +}); + +test('multiples mode: the good ones are multiples, the others are not', () => { + const g = R.createGame({ mode: 'multiples', level: 'medium', rng: seeded(3) }); + for (const n of g.numbers) assert.equal(n.value % g.task.table === 0, n.good, `${n.value} for ×${g.task.table}`); + assert.equal(g.task.left, g.numbers.filter((n) => n.good).length); +}); + +test('eating the right number grows the snake and scores; a new question comes', () => { + const g = R.createGame({ mode: 'answer', level: 'easy', rng: seeded(7) }); + const before = g.task.text; + const right = g.numbers.find((n) => n.good); + // clear the wrong ones out of the way so the path is safe + g.numbers = [right]; + goTo(g, right); + assert.equal(g.over, false); + assert.equal(g.score, R.POINTS.right); + assert.equal(g.eaten, 1); + assert.equal(g.last.good, true); + assert.match(g.last.fact, /=/); + const len = g.snake.length; + R.tick(g); + assert.equal(g.snake.length, len + 1, 'grew by one'); + assert.ok(g.task.text !== before || g.numbers.length === 4); +}); + +test('a wrong number costs a heart and shows the right fact; three ends the game', () => { + const g = R.createGame({ mode: 'answer', level: 'easy', rng: seeded(11) }); + for (let i = 0; i < 3; i++) { + const wrong = g.numbers.find((n) => !n.good); + // put the wrong number right in front of the head + const h = g.snake[0]; + const [dx, dy] = { up: [0, -1], down: [0, 1], left: [-1, 0], right: [1, 0] }[g.dir]; + wrong.x = h.x + dx; + wrong.y = h.y + dy; + R.tick(g); + assert.equal(g.last.good, false); + assert.match(g.last.fact, /not \d+/); + assert.equal(g.hearts, R.HEARTS - 1 - i); + } + assert.equal(g.over, true); + assert.equal(g.why, 'hearts'); + assert.equal(g.missed.length, 3); +}); + +test('classic walls and your own tail end the game; you can’t turn straight back', () => { + const g = R.createGame({ level: 'easy', walls: 'classic', rng: seeded(2) }); + g.numbers = []; + assert.equal(R.turn(g, 'left'), false, 'no reversing'); + for (let i = 0; i < 20 && !g.over; i++) R.tick(g); + assert.equal(g.why, 'wall'); + + const s = R.createGame({ level: 'easy', rng: seeded(2) }); + s.numbers = []; + s.grow = 5; + for (let i = 0; i < 5; i++) R.tick(s); // long enough to bite itself + for (const d of ['down', 'left', 'up']) { + R.turn(s, d); + R.tick(s); + } + assert.equal(s.why, 'self'); +}); + +test('turns are queued: two quick presses both happen, in order', () => { + const g = R.createGame({ level: 'easy', rng: seeded(5) }); + g.numbers = []; + assert.equal(R.turn(g, 'up'), true); + assert.equal(R.turn(g, 'left'), true); + assert.equal(R.turn(g, 'down'), false, 'only two kept'); + R.tick(g); + assert.equal(g.dir, 'up'); + R.tick(g); + assert.equal(g.dir, 'left'); +}); + +test('add it up: any number can be eaten, then the snake waits for the new total', () => { + const g = R.createGame({ mode: 'sum', level: 'medium', rng: seeded(9) }); + assert.equal(g.numbers.length, R.SUM_CHOICES); + assert.ok(g.numbers.every((n) => n.good && n.value >= 5 && n.value <= 25)); + const n = g.numbers[0]; + const h = g.snake[0]; + n.x = h.x + 1; + n.y = h.y; + R.tick(g); + assert.deepEqual([g.asking.before, g.asking.add, g.asking.answer], [0, n.value, n.value]); + const head = { ...g.snake[0] }; + R.tick(g); + assert.deepEqual(g.snake[0], head, 'the snake waits while you answer'); + const others = g.numbers.map((m) => `${m.x},${m.y},${m.value}`); + const last = R.answer(g, n.value); + for (const o of others) assert.ok(g.numbers.some((m) => `${m.x},${m.y},${m.value}` === o), 'the other numbers stay put'); + assert.equal(last.good, true); + assert.equal(g.total, n.value); + assert.equal(g.score, n.value, 'the number eaten is the points'); + assert.equal(g.asking, null); + assert.equal(g.numbers.length, R.SUM_CHOICES, 'a new number replaces it'); + assert.equal(g.task.text, `Total: ${n.value}`); +}); + +test('add it up: a wrong total costs a heart but the total is still right; the goal pays and restarts', () => { + const g = R.createGame({ mode: 'sum', level: 'easy', rng: seeded(4) }); + g.total = 25; + g.asking = { before: 25, add: 7, answer: 32, text: '25 + 7 = ?' }; + const last = R.answer(g, 31); + assert.equal(last.good, false); + assert.match(last.fact, /25 \+ 7 = 32, not 31/); + assert.equal(g.hearts, R.HEARTS - 1); + assert.equal(last.goal, 30, 'reached the goal of 30'); + assert.equal(g.total, 0, 'a new round starts from 0'); + assert.equal(g.score, R.POINTS.goal); +}); + +// run straight at the right-hand wall +function toWall(g) { + g.numbers = []; + for (let i = 0; i < g.size && !g.atWall && !g.over; i++) R.tick(g); +} + +test('bump walls: the snake stops at the wall, loses nothing, and goes on after a turn', () => { + const g = R.createGame({ level: 'easy', walls: 'bump', rng: seeded(2) }); + toWall(g); + assert.equal(g.over, false); + assert.equal(g.atWall, true); + assert.equal(g.hearts, R.HEARTS); + const head = { ...g.snake[0] }; + R.tick(g); + assert.deepEqual(g.snake[0], head, 'waits'); + assert.equal(g.last.bump, true); + R.turn(g, 'down'); + R.tick(g); + assert.deepEqual(g.snake[0], { x: head.x, y: head.y + 1 }); + assert.equal(g.atWall, false); +}); + +test('soft walls cost one heart per bump; wrap walls come back in on the other side', () => { + const g = R.createGame({ level: 'easy', walls: 'soft', rng: seeded(2) }); + toWall(g); + R.tick(g); + R.tick(g); + assert.equal(g.hearts, R.HEARTS - 1, 'once, not every tick'); + const w = R.createGame({ level: 'easy', walls: 'wrap', rng: seeded(2) }); + w.numbers = []; + for (let i = 0; i < w.size - Math.floor(w.size / 2) + 1; i++) R.tick(w); // past the right edge + assert.equal(w.over, false); + assert.ok(w.snake[0].x < w.size / 2, 'came in on the left'); +}); + +test('numbers stay off the edges (unless the walls wrap); speeds get only a little faster', () => { + for (let seed = 1; seed < 30; seed++) { + const g = R.createGame({ mode: 'multiples', level: 'hard', walls: 'bump', rng: seeded(seed) }); + for (const n of g.numbers) assert.ok(n.x > 0 && n.y > 0 && n.x < g.size - 1 && n.y < g.size - 1, `${n.x},${n.y}`); + } + const g = R.createGame({ level: 'easy', rng: seeded(1) }); + assert.equal(R.tickMs(g, 'slow'), R.SPEEDS.slow.ms); + g.eaten = 1000; + assert.equal(R.tickMs(g, 'slow'), R.SPEEDS.slow.fastest); + assert.ok(R.SPEEDS.slow.ms > R.SPEEDS.normal.ms && R.SPEEDS.normal.ms > R.SPEEDS.fast.ms); +}); + +test('manual movement: one square per press, never straight back', () => { + const g = R.createGame({ level: 'easy', rng: seeded(6) }); + g.numbers = []; + const h = { ...g.snake[0] }; + R.step(g, 'right'); + assert.deepEqual(g.snake[0], { x: h.x + 1, y: h.y }); + R.step(g, 'down'); + assert.deepEqual(g.snake[0], { x: h.x + 1, y: h.y + 1 }); + assert.equal(R.step(g, 'up'), null, 'no reversing'); + assert.deepEqual(g.snake[0], { x: h.x + 1, y: h.y + 1 }); +}); diff --git a/apps/web/vite.config.js b/apps/web/vite.config.js new file mode 100644 index 0000000..b52c954 --- /dev/null +++ b/apps/web/vite.config.js @@ -0,0 +1,26 @@ +// Every Blockout page, one multi-page app: index.html is the multiplication game +// at /, and each /index.html is a page at // (ADR-0015). Vite gives +// each page its own bundle, so the games never load Admin's code. +import fs from 'node:fs'; +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +const root = import.meta.dirname; +const NOT_PAGES = new Set(['dist', 'node_modules', 'src', 'test']); +const pages = Object.fromEntries([ + ['main', `${root}/index.html`], + ...fs.readdirSync(root, { withFileTypes: true }) + .filter((d) => d.isDirectory() && !NOT_PAGES.has(d.name) && fs.existsSync(`${root}/${d.name}/index.html`)) + .map((d) => [d.name, `${root}/${d.name}/index.html`]), +]); + +export default defineConfig({ + base: '/', + plugins: [blockoutPage()], + server: { + port: 5173, + // the API and live classroom updates come from apps/server (npm run dev starts it on 8080) + proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: false } }, + }, + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022', rollupOptions: { input: pages } }, +}); diff --git a/css/style.css b/css/style.css deleted file mode 100644 index f2e7c74..0000000 --- a/css/style.css +++ /dev/null @@ -1,1436 +0,0 @@ -/* Theme colours. Light by default; dark follows the device unless the - player picks a theme (html[data-theme="light" | "dark"]). */ -:root { - --bg: #f6f1e7; - --paper: #fffdf8; - --surface: #ffffff; - --soft: #f3eee4; - --soft-2: #efeae0; - --ink: #2b2a33; - --muted: #6d6a75; - --line: #ddd5c6; - --edge: #2b2a33; - --edge-shadow: #2b2a33; - --invert-bg: #2b2a33; - --invert-ink: #ffffff; - --accent: #e4572e; - --accent-ink: #fff; - --accent-soft: #fff1ec; - --accent-ring: #ffd9cc; - --good: #2a9d5c; - --good-ink: #1d6e41; - --good-bg: #e6f6ec; - --good-line: #9fd6b3; - --bad: #b0342a; - --bad-bg: #fbe9e7; - --danger: #d64533; - --gold-line: #e0b93c; - --gold-bg: #fff6d6; - --gold-ink: #8a5a00; - --star: #e0a800; - --learning: #f2c94c; - --learning-ink: #5c4400; - --placeholder: #c9c1b2; - --overlay: rgba(43, 42, 51, 0.45); - --dot: rgba(43, 42, 51, 0.06); - --swatch-edge: rgba(0, 0, 0, 0.15); - /* board canvas */ - --board-bg: #ffffff; - --board-grid: #d9d2c3; - --board-hint: #9a9589; - --board-edge: #2b2a33; - --board-fade: rgba(255, 255, 255, 0.62); - --board-wrong: #8a8578; - --radius: 14px; - --shadow: 0 2px 0 rgba(43, 42, 51, 0.08), 0 8px 24px rgba(43, 42, 51, 0.08); - color-scheme: light; - font-family: "Nunito", "Segoe UI", system-ui, -apple-system, sans-serif; - color: var(--ink); -} -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --bg: #16151b; - --paper: #211f27; - --surface: #2a2731; - --soft: #322e3a; - --soft-2: #2e2a35; - --ink: #ece9f2; - --muted: #a8a3b4; - --line: #3f3a49; - --edge: #57525f; - --edge-shadow: #09080c; - --invert-bg: #ece9f2; - --invert-ink: #16151b; - --accent: #ef6a44; - --accent-ink: #fff; - --accent-soft: #3b2620; - --accent-ring: #6a3526; - --good: #34a466; - --good-ink: #86dba9; - --good-bg: #1d3528; - --good-line: #2f6b48; - --bad: #e26c5e; - --bad-bg: #3d2320; - --danger: #cf4634; - --gold-line: #a8842a; - --gold-bg: #342c1c; - --gold-ink: #f0c96a; - --star: #f2c14e; - --learning: #d6ae36; - --learning-ink: #2a2000; - --placeholder: #6e6a78; - --overlay: rgba(0, 0, 0, 0.6); - --dot: rgba(255, 255, 255, 0.05); - --swatch-edge: rgba(255, 255, 255, 0.2); - --board-bg: #1d1b22; - --board-grid: #3a3643; - --board-hint: #7d7889; - --board-edge: #57525f; - --board-fade: rgba(22, 21, 27, 0.65); - --board-wrong: #8f8a99; - --shadow: 0 2px 0 rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35); - color-scheme: dark; - } -} -:root[data-theme="dark"] { - --bg: #16151b; - --paper: #211f27; - --surface: #2a2731; - --soft: #322e3a; - --soft-2: #2e2a35; - --ink: #ece9f2; - --muted: #a8a3b4; - --line: #3f3a49; - --edge: #57525f; - --edge-shadow: #09080c; - --invert-bg: #ece9f2; - --invert-ink: #16151b; - --accent: #ef6a44; - --accent-ink: #fff; - --accent-soft: #3b2620; - --accent-ring: #6a3526; - --good: #34a466; - --good-ink: #86dba9; - --good-bg: #1d3528; - --good-line: #2f6b48; - --bad: #e26c5e; - --bad-bg: #3d2320; - --danger: #cf4634; - --gold-line: #a8842a; - --gold-bg: #342c1c; - --gold-ink: #f0c96a; - --star: #f2c14e; - --learning: #d6ae36; - --learning-ink: #2a2000; - --placeholder: #6e6a78; - --overlay: rgba(0, 0, 0, 0.6); - --dot: rgba(255, 255, 255, 0.05); - --swatch-edge: rgba(255, 255, 255, 0.2); - --board-bg: #1d1b22; - --board-grid: #3a3643; - --board-hint: #7d7889; - --board-edge: #57525f; - --board-fade: rgba(22, 21, 27, 0.65); - --board-wrong: #8f8a99; - --shadow: 0 2px 0 rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35); - color-scheme: dark; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - min-height: 100vh; - background: var(--bg); - background-image: radial-gradient(var(--dot) 1px, transparent 1px); - background-size: 22px 22px; -} - -[hidden] { display: none !important; } - -button { font: inherit; color: inherit; } - -/* Lucide line icons: sized to the text and drawn in its colour, so they follow the theme */ -.icon { - width: 1.15em; - height: 1.15em; - flex: none; - vertical-align: -0.2em; - fill: none; - stroke: currentColor; - stroke-width: 2.25; - stroke-linecap: round; - stroke-linejoin: round; -} -.mode-icon .icon { width: 1em; height: 1em; stroke-width: 2; color: var(--accent); } -.menu-btn .icon, .icon-btn .icon { width: 22px; height: 22px; display: block; } -.keypad .icon { width: 24px; height: 24px; stroke-width: 2.75; vertical-align: middle; } -.lock-badge .icon { width: 0.95em; height: 0.95em; stroke-width: 2.5; vertical-align: -0.15em; } -.achievement-icon .icon { width: 1.1em; height: 1.1em; color: var(--muted); } -.invite-dialog h2 .icon { color: var(--accent); } - -/* ---------- Shared ---------- */ -.logo { - margin: 0; - font-size: clamp(2.6rem, 9vw, 4rem); - font-weight: 900; - letter-spacing: -0.03em; - line-height: 1; -} -.logo span { color: var(--accent); } -.logo.small { font-size: 1.6rem; } - -.btn { - border: 2px solid var(--edge); - background: var(--paper); - border-radius: 10px; - padding: 0.55rem 1rem; - font-weight: 800; - cursor: pointer; - box-shadow: 0 3px 0 var(--edge-shadow); - transition: transform 0.08s, box-shadow 0.08s; -} -.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 0 var(--edge-shadow); } -.btn:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--edge-shadow); } -.btn:disabled { opacity: 0.45; cursor: not-allowed; } -.btn-primary { background: var(--accent); color: var(--accent-ink); } -.btn-big { width: 100%; padding: 0.85rem 1rem; font-size: 1.15rem; } - -.screen { padding: 16px; } - -/* ---------- Start screen ---------- */ -#start-screen { - display: flex; - justify-content: center; - align-items: flex-start; - min-height: 100vh; - padding-top: clamp(16px, 6vh, 64px); -} - -.start-card { - width: 100%; - max-width: 520px; - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - padding: clamp(20px, 5vw, 36px); - text-align: center; -} - -.tagline { color: var(--muted); margin: 0.6rem 0 1.5rem; } - -.mode-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 1.25rem; } -@media (max-width: 480px) { - /* Single + Multiplayer side by side, Practice underneath across the full width */ - .mode-picker { grid-template-columns: 1fr 1fr; gap: 8px; } - .mode-card { padding: 12px 6px; } - .mode-card[data-mode="practice"] { - grid-column: 1 / -1; - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto; - column-gap: 12px; - justify-items: start; - text-align: left; - padding: 10px 16px; - } - .mode-card[data-mode="practice"] { grid-template-rows: auto auto auto; } - .mode-card[data-mode="practice"] .mode-icon { grid-row: 1 / -1; align-self: center; } - .mode-card[data-mode="practice"] .lock-badge { grid-column: 2; } - /* Board size: 12, 16, 20 on one row, Custom on its own row */ - /* phones: two per row (How to play · Settings / Stats · Achievements / Shop · Invite) */ - .start-card .menu-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .start-card .menu-actions .btn { font-size: 0.85rem; padding-top: 0.5rem; padding-bottom: 0.5rem; } -} -@media (max-width: 340px) { - .start-card .menu-actions .btn { font-size: 0.76rem; gap: 4px; } -} - -.mode-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 16px 10px; - border: 2px solid var(--line); - border-radius: 12px; - background: var(--surface); - cursor: pointer; - transition: border-color 0.15s, background 0.15s; -} -.mode-card:hover { border-color: var(--muted); } -.mode-card.selected { border-color: var(--accent); background: var(--accent-soft); } -.mode-icon { font-size: 2rem; } -.mode-title { font-weight: 900; font-size: 1.05rem; } -.mode-sub { font-size: 0.85rem; color: var(--muted); } - -#setup-form { display: flex; flex-direction: column; gap: 1rem; text-align: left; } -.setup-section { display: flex; flex-direction: column; gap: 0.75rem; } - -.field { display: flex; flex-direction: column; gap: 6px; } -.field > span { font-weight: 800; font-size: 0.9rem; } -.field input { - font: inherit; - padding: 0.55rem 0.7rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.field input:focus { outline: none; border-color: var(--accent); } - -.name-inputs { display: flex; flex-direction: column; gap: 8px; } -.name-row { display: flex; align-items: center; gap: 8px; } -.name-row input { - flex: 1; - min-width: 0; - font: inherit; - padding: 0.55rem 0.7rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.name-row input:focus { outline: none; border-color: var(--accent); } -.name-row input.missing { border-color: var(--bad); background: var(--bad-bg); } -.name-row input:disabled { background: var(--soft); color: var(--muted); } -.kind-toggle { - flex: none; - min-width: 6.8em; - white-space: nowrap; - padding: 0.55rem 0.6rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); - font-weight: 800; - font-size: 0.85rem; - cursor: pointer; -} -.kind-toggle.cpu { background: var(--invert-bg); border-color: var(--invert-bg); color: var(--invert-ink); } -.swatch { width: 18px; height: 18px; border-radius: 5px; flex: none; border: 2px solid var(--swatch-edge); } - -.segmented { display: flex; border: 2px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--surface); } -.segmented button { - flex: 1; - border: 0; - background: transparent; - padding: 0.55rem 0.3rem; - font-weight: 800; - cursor: pointer; -} -.segmented button + button { border-left: 2px solid var(--line); } -.segmented button.selected { background: var(--invert-bg); color: var(--invert-ink); } -.segmented small { font-weight: 600; opacity: 0.7; } -.segmented[data-setting="difficulty"] small { display: block; } - -.custom-size { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; } -.custom-size .step { width: 40px; padding: 0.3rem 0; font-size: 1.2rem; line-height: 1; } -.custom-size input { - width: 4.2em; - font: inherit; - font-weight: 900; - text-align: center; - padding: 0.4rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.custom-size input:focus { outline: none; border-color: var(--accent); } -.custom-times { font-weight: 900; min-width: 3.2em; } -.custom-hint { flex-basis: 100%; text-align: center; font-size: 0.8rem; color: var(--muted); } - -.keys { color: var(--muted); } -kbd { - font-family: inherit; - font-size: 0.8em; - border: 1px solid var(--line); - border-bottom-width: 2px; - border-radius: 4px; - padding: 0 4px; - background: var(--surface); -} - -/* ---------- Game screen ---------- */ -.game-header { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - max-width: 1100px; - margin: 0 auto 12px; -} -.menu-btn, .icon-btn { - width: 44px; - height: 40px; - padding: 0; - font-size: 1.3rem; - line-height: 1; - flex: none; - /* centre the icon in the button box itself (iOS Safari offsets inline SVG otherwise) */ - display: inline-flex; - align-items: center; - justify-content: center; -} -.menu-btn { justify-self: start; } -.header-right { justify-self: end; display: flex; align-items: center; gap: 12px; min-width: 0; } -/* Cheat codes (~) */ -.cheats-dialog { max-width: 380px; } -.cheats-dialog h2 { margin-bottom: 0.8rem; } -.cheats-input { width: 100%; font: inherit; font-size: 1.4rem; font-weight: 900; letter-spacing: 0.08em; text-align: center; padding: 0.5rem; border: 3px solid var(--edge); border-radius: 10px; background: var(--surface); color: var(--ink); font-family: ui-monospace, "Courier New", monospace; } -.cheats-input:focus { outline: none; border-color: var(--accent); } -.cheats-input.wrong { border-color: var(--bad); animation: shake 0.35s; } -.cheats-msg { min-height: 1.3em; font-weight: 800; color: var(--muted); margin: 10px 0; } -.cheats-msg.good { color: var(--good-ink); } -.cheats-active { margin: 0 0 10px; } -.cheats-active h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 6px; } -.cheat-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border: 2px solid var(--line); border-radius: 10px; font-weight: 700; } -.cheat-row + .cheat-row { margin-top: 6px; } - -/* Board sizes: two rows of four (6 8 10 12 / 16 20 24 Custom) */ -/* Difficulty: Easy, Medium, Hard / Tricky, Master, Legend */ -#difficulty-field .segmented { display: grid; grid-template-columns: repeat(3, 1fr); } -#difficulty-field .segmented button:nth-child(3n + 1) { border-left: 0; } -#difficulty-field .segmented button:nth-child(n + 4) { border-top: 2px solid var(--line); } -.setting-help.warn { color: var(--danger, #c0392b); font-weight: 600; } -#board-size, #host-size, .host-settings-grid [data-key="size"] { display: grid; grid-template-columns: repeat(4, 1fr); } -#board-size button:nth-child(4n + 1), #host-size button:nth-child(4n + 1), .host-settings-grid [data-key="size"] button:nth-child(4n + 1) { border-left: 0; } -#board-size button:nth-child(n + 5), #host-size button:nth-child(n + 5), .host-settings-grid [data-key="size"] button:nth-child(n + 5) { border-top: 2px solid var(--line); } - -/* Multiplayer: Local / Classroom */ -#multi-kind small, #host-type small, #host-difficulty small { display: block; } -#host-type .icon { vertical-align: -0.2em; } -#multi-kind .icon { vertical-align: -0.2em; } -.classroom-actions { display: flex; flex-direction: column; gap: 8px; } -.classroom-note { margin: 0; background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 10px 12px; font-size: 0.88rem; color: var(--ink); } -.classroom-note code, .classroom-note + code { font-size: 0.85em; background: var(--surface); border-radius: 4px; padding: 0 4px; } - -/* ---------- Classroom: join / host dialogs ---------- */ -.class-dialog { max-width: 440px; text-align: left; } -.class-dialog h2 { text-align: center; margin-bottom: 0.6rem; } -.class-dialog form { display: flex; flex-direction: column; gap: 12px; } -.class-dialog > .field { display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px; } -.class-dialog .dialog-actions { margin-top: 8px; } -.class-dialog .field { text-align: left; } -.code-input { font-size: 1.8rem !important; font-weight: 900; letter-spacing: 0.3em; text-transform: uppercase; text-align: center; } -.class-wait-big { font-size: 1.3rem; font-weight: 900; text-align: center; margin: 0.4rem 0; } -.class-wait-text { text-align: center; color: var(--muted); margin: 0 0 12px; } -.score-rank { margin-top: 4px; font-size: 0.85rem; font-weight: 800; color: var(--muted); } -.result-list li.result-you { outline: 3px solid var(--c); } -.result-list li.result-gap { background: none; border: 0; text-align: center; color: var(--muted); padding: 0; margin: -2px 0 4px; display: block; } - -/* ---------- Classroom: the teacher's screen ---------- */ -#class-host-screen .game-header { max-width: 1200px; } -.host-code-pill { background: var(--paper); border: 2px solid var(--edge); border-radius: 999px; padding: 4px 14px; font-weight: 800; } -.host-code-pill strong { letter-spacing: 0.12em; } -.host-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; max-width: 1200px; margin: 0 auto; align-items: start; } -.host-main, .host-side { background: var(--paper); border: 2px solid var(--edge); border-radius: var(--radius); box-shadow: 0 6px 0 var(--edge-shadow); padding: clamp(16px, 3vw, 32px); } -.host-panel { display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center; } -.host-kicker { margin: 0; font-size: 1.6rem; font-weight: 900; } -.host-join { display: flex; align-items: center; justify-content: center; gap: clamp(16px, 4vw, 40px); flex-wrap: wrap; } -.host-qr { width: min(240px, 60vw); height: auto; border-radius: 12px; border: 2px solid var(--edge); background: #fff; image-rendering: pixelated; } -.host-join-text { text-align: left; } -.host-step { margin: 0; color: var(--muted); font-weight: 800; } -.host-url { margin: 0 0 12px; font-size: clamp(1.1rem, 2.4vw, 1.7rem); font-weight: 900; word-break: break-all; } -.host-share { display: flex; gap: 6px; flex-wrap: wrap; margin: -4px 0 12px; } -.host-share .btn.copied, #host-copy-teacher.copied { background: var(--good-bg); border-color: var(--good); color: var(--good-ink); } -.host-code { margin: 0; font-size: clamp(3rem, 9vw, 5.5rem); font-weight: 900; letter-spacing: 0.15em; line-height: 1; color: var(--accent); } -.host-settings { margin: 0; color: var(--muted); font-weight: 700; } -.host-panel .btn-big { max-width: 420px; } -.host-dice { gap: 28px; margin: 0; } -.host-dice .die { width: 120px; height: 120px; padding: 16px; border-radius: 22px; box-shadow: 0 6px 0 var(--edge-shadow); } -.host-dice .pip { width: 20px; height: 20px; } -.host-dice .die-num { font-size: 3.4rem; } -.host-roll-text { margin: 0; font-size: 1.5rem; font-weight: 900; } -.host-progress { width: min(100%, 420px); display: flex; align-items: center; gap: 12px; font-weight: 800; white-space: nowrap; } -.host-progress .progress-bar { flex: 1; height: 16px; } -#host-done-fill { display: block; height: 100%; background: var(--good); transition: width 0.3s; } -.host-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } -.host-actions .btn { font-size: 1.1rem; padding: 0.7rem 1.3rem; } -.host-auto { display: flex; gap: 8px; align-items: center; font-weight: 700; color: var(--muted); cursor: pointer; } -.host-auto input { width: 18px; height: 18px; accent-color: var(--accent); } -.host-side .stats-heading { margin-top: 0; } -.host-roster { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: calc(100vh - 200px); overflow-y: auto; } -.host-roster li { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 10px; font-weight: 800; } -.host-roster li.offline { opacity: 0.55; } -.host-roster li.waiting { border-style: dashed; } -.host-waiting { font-size: 0.8rem; color: var(--muted); white-space: nowrap; } -.host-roster li.host-empty { display: block; border-style: dashed; color: var(--muted); font-weight: 700; } -.host-rank { min-width: 2.6em; color: var(--muted); font-variant-numeric: tabular-nums; } -.host-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.host-score { font-variant-numeric: tabular-nums; font-size: 1.1rem; } -.host-status { width: 1.4em; text-align: center; color: var(--good-ink); } -.host-remove { border: 0; background: none; font-size: 1.3rem; line-height: 1; color: var(--muted); cursor: pointer; padding: 0 4px; } -.host-remove:hover { color: var(--bad); } -.podium { list-style: none; margin: 0; padding: 0; display: flex; gap: 12px; justify-content: center; align-items: flex-end; flex-wrap: wrap; } -.podium li { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 140px; padding: 16px; border-radius: 14px; border: 2px solid var(--edge); background: var(--surface); font-weight: 900; } -.podium .podium-1 { order: 2; background: var(--gold-bg); border-color: var(--gold-line); padding-top: 28px; } -.podium .podium-2 { order: 1; } -.podium .podium-3 { order: 3; } -.podium-medal { font-size: 2.6rem; } -.podium-name { font-size: 1.3rem; } -.podium-score { color: var(--muted); } -#host-report { width: 100%; } -.host-hardest { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; } -.host-hardest li { display: flex; flex-direction: column; background: var(--bad-bg); border-radius: 10px; padding: 8px 12px; text-align: left; } -.host-hardest li.host-empty { background: var(--good-bg); grid-column: 1 / -1; font-weight: 800; } -.host-fact { font-size: 1.4rem; font-weight: 900; } -.host-missed { font-size: 0.85rem; font-weight: 700; color: var(--bad); } -/* Lobby: game settings */ -.host-game-settings { width: 100%; max-width: 760px; text-align: left; border-top: 2px solid var(--line); padding-top: 12px; } -.host-settings-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.host-settings-head .stats-heading { margin: 0; } -.host-game-settings > .setting-help { margin: 2px 0 10px; } -.host-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px 16px; } -.host-settings-grid .segmented button { padding: 0.45rem 0.3rem; font-size: 0.92rem; } -.host-settings-grid small { display: block; } - -/* Tournaments */ -.host-settings-grid .field.wide { grid-column: 1 / -1; } -.host-settings-grid [data-key="format"] { display: grid; grid-template-columns: repeat(5, 1fr); } -@media (max-width: 700px) { - .host-settings-grid [data-key="format"] { grid-template-columns: repeat(2, 1fr); } -} -.host-tournament { width: 100%; display: flex; flex-direction: column; gap: 14px; } -.bracket { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; align-items: stretch; } -.bracket-round { display: flex; flex-direction: column; justify-content: space-around; gap: 10px; min-width: 150px; } -.bracket-label { font-size: 0.8rem; font-weight: 900; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; text-align: center; } -.bracket-match { border: 2px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; } -.bracket-name { padding: 4px 10px; font-weight: 800; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.bracket-name + .bracket-name { border-top: 1px solid var(--line); } -.bracket-name.winner { background: var(--good-bg); color: var(--good-ink); } -.bracket-name.loser { color: var(--muted); } -.bracket-bye { font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; } -.standings { width: 100%; border-collapse: collapse; font-weight: 700; font-variant-numeric: tabular-nums; } -.standings th, .standings td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--line); } -.standings th { font-size: 0.8rem; color: var(--muted); } -.standings tr.out td { color: var(--muted); } -.koth-hills { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } -.koth-hill { border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; padding: 10px 12px; text-align: left; } -.koth-title { font-size: 0.8rem; font-weight: 900; color: var(--gold-ink); text-transform: uppercase; } -.koth-king { font-size: 1.4rem; font-weight: 900; } -.koth-streak { font-weight: 800; } -.koth-queue, .koth-count { font-size: 0.85rem; color: var(--muted); font-weight: 700; } -.host-roster li.knocked-out { opacity: 0.6; } -.result-tournament { margin: 0 0 14px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; text-align: center; } - -/* Pairs */ -.host-pairs { width: 100%; max-width: 760px; display: flex; flex-direction: column; gap: 12px; text-align: left; } -#host-odd small { display: block; } -.host-pairs .setting-help { margin: 0; } -.host-pair-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; } -.host-pair-list li { display: flex; align-items: center; gap: 6px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 8px; font-weight: 800; } -.pair-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } -button.pair-name { border: 2px dashed var(--line); border-radius: 8px; background: var(--paper); padding: 4px 6px; font-weight: 800; cursor: pointer; } -button.pair-name:hover { border-color: var(--accent); } -button.pair-name.picked { border-style: solid; border-color: var(--accent); background: var(--accent-soft); } -.pair-name.other { color: var(--muted); font-style: italic; } -.pair-vs { font-size: 0.75rem; color: var(--muted); } -.host-teacher-device { display: flex; gap: 14px; align-items: center; background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 12px; padding: 10px; } -.host-qr.small { width: 120px; } -.host-teacher-url { margin: 2px 0; font-weight: 800; word-break: break-all; font-size: 0.85rem; } -.host-teacher-status { margin: 0; font-weight: 800; color: var(--muted); } -.host-teacher-status.ok { color: var(--good-ink); } -.host-matches { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } -.host-match { background: var(--surface); border: 2px solid var(--edge); border-radius: 12px; padding: 8px 10px; text-align: left; } -.host-match.over { border-color: var(--line); } -.match-row { display: flex; align-items: center; gap: 6px; font-weight: 800; padding: 2px 0; } -.match-row.winner { color: var(--good-ink); } -.match-turn { width: 1.4em; text-align: center; } -.match-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.match-score { font-size: 1.2rem; font-weight: 900; font-variant-numeric: tabular-nums; } -.match-status { margin-top: 4px; font-size: 0.8rem; font-weight: 700; color: var(--muted); } -.host-mygame.your-turn { animation: pulse 1.2s ease-in-out infinite; } -@keyframes pulse { - 0%, 100% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 0 var(--accent-ring); } - 50% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 8px var(--accent-ring); } -} - -@media (max-width: 820px) { - .host-layout { grid-template-columns: 1fr; } - .host-roster { max-height: none; } -} -.settings-note { background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 8px 12px; font-size: 0.88rem; font-weight: 700; margin: 0 0 14px; } -.progress { width: min(100%, 190px); display: flex; flex-direction: column; gap: 4px; } -.progress-text { color: var(--muted); font-weight: 700; font-size: 0.85rem; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } -.progress-text .short { display: none; } -.progress-bar { - display: flex; - height: 10px; - border: 2px solid var(--edge); - border-radius: 6px; - background: var(--surface); - overflow: hidden; -} -.progress-bar span { height: 100%; transition: width 0.4s ease-out; } - -.game-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 300px; - gap: 16px; - max-width: 1100px; - margin: 0 auto; - align-items: start; -} - -.board-wrap { - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - padding: 12px; - display: flex; - justify-content: center; -} -#board { display: block; touch-action: none; cursor: crosshair; } - -.side { display: flex; flex-direction: column; gap: 16px; } - -.turn-panel { - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - padding: 16px; - text-align: center; - border-top: 8px solid var(--turn-color, var(--ink)); -} -.turn-label { font-weight: 900; font-size: 1.15rem; } -.turn-msg { min-height: 2.6em; color: var(--muted); font-size: 0.92rem; margin: 6px 0 10px; } -.turn-msg:empty { display: none; } -.turn-msg.oops { color: var(--bad); font-weight: 800; } -.turn-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } - -.dice { display: flex; gap: 14px; justify-content: center; margin-top: 12px; } -.die { - width: 58px; - height: 58px; - border: 2px solid var(--edge); - border-radius: 12px; - background: var(--surface); - display: grid; - grid-template: repeat(3, 1fr) / repeat(3, 1fr); - padding: 8px; - box-shadow: 0 3px 0 var(--edge-shadow); -} -.die.blank { background: repeating-linear-gradient(45deg, var(--surface), var(--surface) 6px, var(--soft) 6px, var(--soft) 12px); } -.die.rolling { animation: wobble 0.12s linear infinite; } -.pip { width: 10px; height: 10px; border-radius: 50%; background: var(--ink); place-self: center; } - -/* ---------- Wardrobe: dice skins (the same in light and dark themes) ---------- */ -.die.skin-golden { background: linear-gradient(135deg, #fff3b0 0%, #f6c945 38%, #d99a1c 70%, #f8dc6c 100%); border-color: #9a6a0c; box-shadow: 0 3px 0 #7a520a, inset 0 1px 2px rgba(255, 255, 255, 0.7); } -.die.skin-golden .pip { background: #4a3200; } -.die.skin-golden .die-num { color: #4a3200; } -.die.skin-golden.blank { background: repeating-linear-gradient(45deg, #f8dc6c, #f8dc6c 6px, #e8bb3a 6px, #e8bb3a 12px); } -.die.skin-golden.rolling { animation: wobble 0.12s linear infinite, golden-sparkle 0.5s linear infinite; } - -.die.skin-wood { background: repeating-linear-gradient(100deg, #c8914f 0 6px, #b98244 6px 9px, #d09b5a 9px 15px); border-color: #6b4420; box-shadow: 0 3px 0 #4a2e14; } -.die.skin-wood .pip { background: #3b240f; } -.die.skin-wood .die-num { color: #3b240f; } - -.die.skin-candy { background: repeating-linear-gradient(45deg, #fff 0 6px, #ffb3c7 6px 12px); border-color: #d6336c; box-shadow: 0 3px 0 #a61e4d; } -.die.skin-candy .pip { background: #a61e4d; } -.die.skin-candy .die-num { color: #a61e4d; } - -.die.skin-glass { background: rgba(170, 215, 255, 0.35); border-color: rgba(60, 130, 200, 0.7); box-shadow: 0 3px 0 rgba(40, 90, 150, 0.5), inset 0 0 10px rgba(255, 255, 255, 0.8); backdrop-filter: blur(2px); } -.die.skin-glass .pip { background: #1d5fa0; } -.die.skin-glass .die-num { color: #1d5fa0; } - -.die.skin-neon { background: #120f24; border-color: #3cf2ff; box-shadow: 0 3px 0 #0a0818, 0 0 10px #3cf2ff; } -.die.skin-neon .pip { background: #ff4fd8; box-shadow: 0 0 6px #ff4fd8; } -.die.skin-neon .die-num { color: #3cf2ff; text-shadow: 0 0 6px #3cf2ff; } - -.die.skin-galaxy { background: radial-gradient(circle at 30% 30%, #6a4bc4 0%, #2a1a5e 45%, #0d0b26 100%); border-color: #8f7cff; box-shadow: 0 3px 0 #070617; } -.die.skin-galaxy .pip { background: #fff; box-shadow: 0 0 5px #fff; } -.die.skin-galaxy .die-num { color: #fff; } - -.die.skin-pumpkin { background: radial-gradient(circle at 40% 35%, #ffb04a 0%, #f07b12 60%, #c85a00 100%); border-color: #7a3500; box-shadow: 0 3px 0 #5a2700; } -.die.skin-pumpkin .pip { background: #2b1200; } -.die.skin-pumpkin .die-num { color: #2b1200; } - -.die[class*="skin-"]:not(.skin-classic).blank { opacity: 0.8; } -/* picked real-dice buttons keep the player's colour whatever the skin */ -.die.mini.selected { background: var(--turn-color, var(--accent)) !important; border-color: var(--edge) !important; } -.die.mini.selected .pip { background: #fff !important; box-shadow: none !important; } -.die.mini.selected .die-num { color: #fff !important; } -/* pip styles: hearts, stars, paws, dinosaurs */ -.pip.pip-glyph { background: none !important; box-shadow: none !important; width: auto; height: auto; font-size: 11px; line-height: 1; } -.die.mini .pip.pip-glyph { font-size: 7px; } - -/* rolling styles */ -.die.rolling.roll-tumble { animation: tumble 0.28s linear infinite; } -.die.rolling.roll-bounce { animation: bounce 0.22s ease-in-out infinite alternate; } -.die.rolling.roll-sparkle { animation: spin 0.3s linear infinite, golden-sparkle 0.3s linear infinite; } -@keyframes tumble { - 0% { transform: perspective(200px) rotateX(0deg) rotateY(0deg); } - 100% { transform: perspective(200px) rotateX(360deg) rotateY(180deg); } -} -@keyframes bounce { - from { transform: translateY(0) scale(1, 1); } - to { transform: translateY(-12px) scale(0.95, 1.05); } -} -@keyframes spin { - to { transform: rotate(360deg); } -} -@keyframes golden-sparkle { - 0%, 100% { filter: brightness(1); } - 50% { filter: brightness(1.25) saturate(1.2); } -} -@keyframes wobble { - 0% { transform: rotate(-8deg); } - 50% { transform: rotate(8deg); } - 100% { transform: rotate(-8deg); } -} - -.btn-small { padding: 0.35rem 0.8rem; font-size: 0.9rem; } - -/* Real dice entry */ -.dice-entry { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 4px 0 12px; } -.die-pick-label { font-size: 0.8rem; font-weight: 800; color: var(--muted); } -.die-pick { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; } -.die.mini { - width: 36px; - height: 36px; - padding: 5px; - border-radius: 8px; - box-shadow: 0 2px 0 var(--edge-shadow); - cursor: pointer; -} -.die.mini .pip { width: 6px; height: 6px; } -.die.mini.selected { background: var(--turn-color, var(--accent)); } -.die.mini.selected .pip { background: #fff; } -.die.mini:active { transform: translateY(2px); box-shadow: none; } -#use-dice { margin-top: 6px; } - -/* Math panel (human answers) */ -.math { margin: 4px 0 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; } -.math-q { - font-size: 2rem; - font-weight: 900; - font-variant-numeric: tabular-nums; - display: flex; - align-items: center; - gap: 8px; -} -.answer-box { - min-width: 2.4em; - height: 1.5em; - padding: 0 6px; - border: 3px solid var(--edge); - border-radius: 10px; - background: var(--surface); - display: inline-flex; - align-items: center; - justify-content: center; -} -.answer-box:empty::after { content: '?'; color: var(--placeholder); } -.answer-box.right { border-color: var(--good); background: var(--good-bg); color: var(--good-ink); } -.answer-box.wrong { border-color: var(--bad); animation: shake 0.35s; } -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-6px); } - 75% { transform: translateX(6px); } -} -.math-feedback { min-height: 1.3em; font-weight: 800; } - -/* Answer timer */ -.timer { display: flex; align-items: center; gap: 8px; width: 100%; max-width: 240px; } -.timer-bar { flex: 1; height: 10px; border: 2px solid var(--edge); border-radius: 6px; background: var(--surface); overflow: hidden; } -.timer-bar span { display: block; height: 100%; width: 100%; background: var(--good); transition: width 0.1s linear; } -.timer-num { min-width: 2.6em; text-align: right; font-weight: 900; font-variant-numeric: tabular-nums; } -.timer.low .timer-bar span { background: var(--bad); } -.timer.low .timer-num { color: var(--bad); } -.answer-box.timeout { border-color: var(--bad); background: var(--bad-bg); color: var(--bad); } -.math-feedback.good { color: var(--good-ink); } -.math-feedback.try { color: var(--bad); } - -.count-trail { - font-weight: 800; - font-variant-numeric: tabular-nums; - color: var(--ink); - min-height: 0; -} -.count-trail:not(:empty) { - background: var(--gold-bg); - border: 2px dashed var(--gold-line); - border-radius: 10px; - padding: 4px 10px; -} - -.keypad { display: grid; grid-template-columns: repeat(3, 56px); gap: 6px; } -.keypad button { - height: 46px; - border: 2px solid var(--edge); - border-radius: 10px; - background: var(--surface); - font-size: 1.3rem; - font-weight: 900; - cursor: pointer; - box-shadow: 0 3px 0 var(--edge-shadow); -} -.keypad button:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--edge-shadow); } -.keypad button.check { background: var(--good); color: #fff; } -.keypad button.back { background: var(--danger); color: #fff; } -.keypad button:disabled { opacity: 0.45; cursor: not-allowed; } - -/* Computer's step-by-step working */ -.steps { - list-style: none; - margin: 18px 0 12px; - padding: 0; - text-align: left; - display: flex; - flex-direction: column; - gap: 6px; -} -.steps li { - background: var(--surface); - border: 2px solid var(--line); - border-radius: 10px; - padding: 6px 10px; - font-size: 0.95rem; - animation: step-in 0.3s ease-out; -} -.steps li.big { font-size: 1.3rem; font-weight: 900; text-align: center; border-color: var(--good); background: var(--good-bg); } -.steps .num { font-weight: 900; font-variant-numeric: tabular-nums; } -@keyframes step-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: none; } -} - -.scores { display: flex; flex-direction: column; gap: 10px; } -.score-card { - background: var(--paper); - border: 2px solid var(--line); - border-left: 8px solid var(--c); - border-radius: 12px; - padding: 10px 12px; - transition: border-color 0.2s; -} -.score-card.active { border-color: var(--edge); border-left-color: var(--c); } -.score-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } -.score-name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.score-pts { font-weight: 900; font-size: 1.4rem; font-variant-numeric: tabular-nums; } -.score-log { - margin: 6px 0 0; - padding: 0; - list-style: none; - display: flex; - flex-wrap: wrap; - gap: 4px; - font-size: 0.8rem; - font-variant-numeric: tabular-nums; -} -.score-log li { background: var(--soft); border-radius: 6px; padding: 1px 6px; } -.score-log li.pass { color: var(--muted); font-style: italic; } -.score-log li.star::before { content: '★ '; color: var(--star); } -.score-log li.timeout { color: var(--bad); } - -/* ---------- Game over ---------- */ -.overlay { - position: fixed; - inset: 0; - background: var(--overlay); - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - z-index: 10; -} -.dialog { - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - --pad-x: 28px; /* the bottom panel of buttons reaches out to the edges using these */ - --pad-y: 28px; - padding: var(--pad-y) var(--pad-x); - width: 100%; - max-width: 420px; - text-align: center; - animation: pop 0.25s ease-out; -} -.dialog h2 { margin: 0; font-size: 2rem; font-weight: 900; } -.result-sub { color: var(--muted); } -.result-list { list-style: none; padding: 0; margin: 0 0 20px; text-align: left; } -.result-list li { - display: grid; - grid-template-columns: 1fr auto; - gap: 6px 12px; - padding: 8px 12px; - border-radius: 8px; - border-left: 8px solid var(--c); - background: var(--surface); - margin-bottom: 6px; - font-weight: 800; -} -.result-pts { font-variant-numeric: tabular-nums; } -.result-bar { grid-column: 1 / -1; height: 14px; border-radius: 4px; background: var(--soft-2); overflow: hidden; } -.result-bar span { display: block; height: 100%; min-width: 4px; background: var(--c); border-radius: 4px; animation: bar-grow 0.8s ease-out; transform-origin: left; } -@keyframes bar-grow { - from { transform: scaleX(0); } - to { transform: scaleX(1); } -} -.dialog-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } -/* The shop can open on top of any other screen (e.g. the results) */ -#shop, #wardrobe, #stickers { z-index: 20; } -/* Quick unlock confirmation: small, above everything */ -#unlock { z-index: 25; } -#achievements { z-index: 22; } /* can open from a results screen */ - -/* Achievement banners: bottom centre, one at a time */ -.achievement-banner { - position: fixed; - left: 50%; - bottom: 24px; - z-index: 45; - display: flex; - align-items: center; - gap: 14px; - min-width: min(420px, calc(100vw - 32px)); - max-width: calc(100vw - 32px); - padding: 12px 22px 12px 14px; - border-radius: 16px; - border: 3px solid var(--gold-line); - background: linear-gradient(180deg, #3a2f1a, #221b10); - color: #fff6d6; - box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.35), 0 10px 30px rgba(0, 0, 0, 0.45), inset 0 0 18px rgba(240, 201, 106, 0.25); - pointer-events: none; /* never in the way of the buttons underneath */ - opacity: 0; - transform: translate(-50%, 40px) scale(0.92); - transition: opacity 0.3s ease-out, transform 0.35s cubic-bezier(0.2, 1.4, 0.4, 1); -} -.achievement-banner.show { opacity: 1; transform: translate(-50%, 0) scale(1); } -.achievement-banner.leaving { opacity: 0; transform: translate(-50%, 20px) scale(0.96); } -.banner-icon { - flex: none; - width: 58px; - height: 58px; - display: grid; - place-items: center; - font-size: 2rem; - border-radius: 50%; - border: 3px solid var(--gold-line); - background: radial-gradient(circle at 35% 30%, #fff3b0, #d99a1c); - box-shadow: 0 0 14px rgba(240, 201, 106, 0.6); -} -.banner-text { display: flex; flex-direction: column; min-width: 0; } -.banner-kicker { font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #f0c96a; } -.banner-name { font-size: 1.35rem; font-weight: 900; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; } -.banner-reward { font-size: 0.9rem; font-weight: 800; opacity: 0.85; } -.achievement-banner.minor { min-width: 0; padding: 8px 16px 8px 10px; gap: 10px; } -.achievement-banner.minor .banner-icon { width: 38px; height: 38px; font-size: 1.3rem; } -.achievement-banner.minor .banner-name { font-size: 1rem; } -@media (prefers-reduced-motion: reduce) { - .achievement-banner { transition: opacity 0.2s; transform: translate(-50%, 0); } - .achievement-banner.leaving { transform: translate(-50%, 0); } -} - -.unlock-dialog { max-width: 360px; } -.unlock-dialog h2 { font-size: 1.35rem; } -.unlock-price { font-weight: 900; color: var(--gold-ink); margin: 6px 0; } -#unlock-message:empty { display: none; } - -/* "Shop · 2 new unlocks", "Wardrobe · 1 new", "Stickers · 2 spares" on the results screens: own row, in gold */ -.nudges { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } -.nudges:not(:has(.nudge:not([hidden]))) { display: none; } -.nudge { - flex: 1 1 auto; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - background: var(--gold-bg); - border-color: var(--gold-line); - color: var(--gold-ink); - animation: pop 0.3s ease-out; -} -/* Stats tables by game type: bold title, muted details underneath */ -.stats-table.by-type tbody th { min-width: 7em; } -.stats-table.by-type .row-title { display: block; color: var(--ink); font-weight: 800; } -.stats-table.by-type .row-sub { display: block; font-size: 0.78rem; font-weight: 700; color: var(--muted); } -@keyframes pop { - from { transform: scale(0.9); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} - -/* ---------- End-of-game stats ---------- */ -.results-dialog { max-width: 600px; max-height: calc(100vh - 32px); overflow-y: auto; } -.stats { text-align: left; margin-bottom: 18px; } -.stats-summary { text-align: center; font-weight: 700; color: var(--muted); font-size: 0.9rem; margin: 0 0 10px; } -.stats-table-wrap { overflow-x: auto; } -.stats-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; font-variant-numeric: tabular-nums; } -.stats-table th, .stats-table td { padding: 5px 8px; border-bottom: 1px solid var(--line); } -.stats-table thead th { font-weight: 900; text-align: center; white-space: nowrap; } -.stats-table tbody th { font-weight: 700; text-align: left; color: var(--muted); } -.stats-table td { text-align: center; font-weight: 800; white-space: nowrap; } -.stats-table .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--c); margin-right: 5px; } -.stats-heading { font-size: 1rem; margin: 16px 0 8px; } -.practice { padding: 8px 10px; border-left: 6px solid var(--c); background: var(--surface); border-radius: 8px; margin-bottom: 6px; } -.practice-name { font-weight: 900; margin-bottom: 4px; } -.practice-none { color: var(--muted); font-size: 0.88rem; } -.facts { display: flex; flex-wrap: wrap; gap: 6px; } -.fact { - display: flex; - flex-direction: column; - background: var(--gold-bg); - border: 1px solid var(--gold-line); - border-radius: 8px; - padding: 3px 9px; - font-variant-numeric: tabular-nums; -} -.fact-eq { font-weight: 900; } -.fact-said { font-size: 0.78rem; font-weight: 700; color: var(--gold-ink); } -/* Results: Play again · Main menu · Show details, same size on one line */ -.results-actions > .btn { flex: 1 1 0; min-width: 0; white-space: nowrap; } -.results-actions > .btn[hidden] { display: none; } -@media (max-width: 480px) { - .results-actions { gap: 6px; } - .results-actions > .btn { white-space: normal; font-size: 0.85rem; padding: 0.5rem 0.25rem; line-height: 1.15; } - .results-actions .details-btn .icon { display: none; } -} -#stats:not([hidden]), #pd-body:not([hidden]) { margin-bottom: 16px; } - -/* ---------- Settings ---------- */ -.menu-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 1rem; } -.menu-actions .btn { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding-left: 0.4rem; padding-right: 0.4rem; white-space: nowrap; } -.howto-dialog { max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.howto-dialog h2 { text-align: center; margin-bottom: 0.5rem; } -.howto-dialog ul { padding-left: 1.2rem; line-height: 1.55; } - -/* ---------- All-time stats ---------- */ -.history-dialog { max-width: 600px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.history-dialog h2 { text-align: center; margin-bottom: 1rem; } -.history-empty { text-align: center; color: var(--muted); margin: 1rem 0 1.5rem; } -.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 6px; } -.tile { background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 8px; text-align: center; } -.tile-value { display: block; font-size: 1.35rem; font-weight: 900; font-variant-numeric: tabular-nums; } -.tile-label { display: block; font-size: 0.78rem; font-weight: 700; color: var(--muted); } -.recent { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } -.recent li { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; font-size: 0.88rem; } -.recent .meta { color: var(--muted); font-weight: 700; margin-right: 6px; } -.recent .winner { font-weight: 800; } -.history-dialog .dialog-actions { margin-top: 20px; } -.history-dialog .stats-table thead th { white-space: normal; font-size: 0.8rem; } -@media (max-width: 420px) { - .tile-value { font-size: 1.1rem; } - .history-dialog { --pad-y: 20px; --pad-x: 14px; } - .history-dialog .stats-table th, .history-dialog .stats-table td { padding: 5px 4px; } - .history-dialog .stats-table { font-size: 0.8rem; } - .history-dialog .stats-table thead th { font-size: 0.72rem; } -} -.settings-dialog { text-align: left; max-height: calc(100vh - 32px); overflow-y: auto; } -.settings-dialog h2 { text-align: center; margin-bottom: 1rem; } -.setting { margin-bottom: 1.1rem; } -.setting-label { font-weight: 800; font-size: 0.9rem; margin-bottom: 6px; } -.setting-help { margin: 6px 0 0; font-size: 0.82rem; color: var(--muted); } - -/* ---------- Wallet, shop, achievements, toasts ---------- */ -.wallet { - display: inline-block; - margin-bottom: 10px; - padding: 4px 12px; - border: 2px solid var(--gold-line); - border-radius: 999px; - background: var(--gold-bg); - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.locked { position: relative; color: var(--muted) !important; background: var(--soft) !important; } -.lock-badge { display: block; font-size: 0.72rem; font-weight: 800; color: var(--gold-ink); } -.mode-card.locked { opacity: 0.85; } -.mode-card.locked .mode-icon { filter: grayscale(1); opacity: 0.6; } -.mode-card .lock-badge { margin-top: 2px; } - -.shop-dialog, .achievements-dialog { max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.shop-dialog h2, .achievements-dialog h2 { text-align: center; margin-bottom: 0.5rem; } -.shop-item { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 2px 10px; - padding: 8px 10px; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); - margin-bottom: 6px; -} -.shop-item.highlight { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } -.shop-item.owned { background: var(--good-bg); border-color: var(--good-line); } -.shop-name { font-weight: 800; } -.shop-owned { font-weight: 800; color: var(--good-ink); } -.shop-why { grid-column: 1 / -1; font-size: 0.8rem; color: var(--muted); text-align: right; } -.shop-dialog .dialog-actions, .achievements-dialog .dialog-actions { margin-top: 16px; } - -.achievement-list { display: flex; flex-direction: column; gap: 6px; } -.achievement { - display: grid; - grid-template-columns: 2rem 1fr auto; - align-items: center; - gap: 10px; - padding: 8px 10px; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--soft-2); - color: var(--muted); -} -.achievement.earned { background: var(--surface); color: var(--ink); border-color: var(--gold-line); } -.achievement-icon { font-size: 1.4rem; text-align: center; } -.achievement-text { display: flex; flex-direction: column; font-size: 0.85rem; } -.achievement-text strong { font-size: 0.95rem; } -.achievement-reward { font-weight: 800; font-size: 0.85rem; white-space: nowrap; } - -.rewards { margin: 0 0 12px; display: flex; flex-direction: column; gap: 6px; } -.rewards:empty { display: none; } -.reward-points { font-weight: 900; color: var(--gold-ink); background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 6px 10px; } -.reward-achievement { display: flex; align-items: center; gap: 8px; font-weight: 800; background: var(--surface); border: 2px solid var(--gold-line); border-radius: 10px; padding: 4px 10px; text-align: left; } -.reward-achievement .achievement-reward { margin-left: auto; } - -/* Achievement confetti: above everything, never catches clicks */ -.confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 40; } - -/* Bottom-left, newest at the bottom */ -.toasts { position: fixed; bottom: 16px; left: 16px; right: 16px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; z-index: 30; pointer-events: none; } -.toast { - max-width: 340px; - background: var(--invert-bg); - color: var(--invert-ink); - border-radius: 12px; - padding: 10px 14px; - box-shadow: var(--shadow); - display: flex; - flex-direction: column; - animation: toast-in 0.25s ease-out; - transition: opacity 0.4s, transform 0.4s; -} -.toast span { font-size: 0.85rem; opacity: 0.85; } -.toast.leaving { opacity: 0; transform: translateY(8px); } -@keyframes toast-in { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: none; } -} - -/* Practice mode */ -.table-chips { display: flex; flex-wrap: wrap; gap: 6px; } -.table-chips button { - min-width: 3em; - padding: 0.4rem 0.5rem; - border: 2px solid var(--line); - border-radius: 999px; - background: var(--surface); - font-weight: 900; - font-variant-numeric: tabular-nums; - cursor: pointer; -} -.table-chips button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } -.practice-preview { margin: 0; font-size: 0.88rem; color: var(--muted); font-weight: 700; } -.practice-card { - max-width: 520px; - margin: 0 auto; - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - padding: 16px; - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; -} -.practice-top { width: 100%; display: flex; justify-content: space-between; font-weight: 800; color: var(--muted); } -.practice-bar { width: 100%; height: 8px; border-radius: 4px; background: var(--soft-2); overflow: hidden; } -.practice-bar span { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s; } -.pr-array { - display: grid; - gap: 2px; - justify-content: center; - align-items: center; - --cell: min(26px, calc((min(100vw, 520px) - 110px) / var(--cols))); - --totals: 2.6em; /* column for the running totals on the right */ - grid-template-columns: repeat(var(--cols), var(--cell)) var(--totals); - /* matching space on the left so the squares line up with the keypad's centre */ - padding-left: calc(var(--totals) + 2px); -} -.pr-array .cell { width: var(--cell); height: var(--cell); border-radius: 3px; background: var(--accent); opacity: 0.85; } -.pr-array.counting .cell { opacity: 0.3; } -.pr-array .cell.counted { opacity: 1; box-shadow: inset 0 0 0 2px var(--edge-shadow); } -.pr-array .row-total { font-weight: 900; font-size: 0.85rem; font-variant-numeric: tabular-nums; text-align: left; padding-left: 6px; visibility: hidden; } -.pr-array .row-total.shown { visibility: visible; } -.practice-change { display: flex; justify-content: space-between; gap: 10px; padding: 5px 10px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); margin-bottom: 5px; font-size: 0.9rem; } -.practice-change .up { color: var(--good-ink); font-weight: 800; } -.practice-change .down { color: var(--bad); font-weight: 800; } - -/* Invites */ -.invite-dialog { max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.invite-dialog h2 { text-align: center; margin-bottom: 1rem; } -.invite-dialog .field { margin-bottom: 10px; } -.invite-dialog .field > span { font-weight: 800; font-size: 0.9rem; } -.invite-dialog input[type="text"], .invite-dialog input[type="number"], .invite-dialog select, .invite-dialog textarea { - font: inherit; - width: 100%; - min-width: 0; - padding: 0.45rem 0.6rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.invite-dialog textarea { resize: vertical; } -.invite-dialog :is(input, select, textarea):focus { outline: none; border-color: var(--accent); } -.invite-row { display: flex; gap: 8px; } -.invite-row input { flex: 1; } -.invite-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; } -.invite-players { margin: 0; font-weight: 800; } -.invite-dialog > .setting-help { margin: 0 0 12px; } -.invite-error { color: var(--bad); font-weight: 800; font-size: 0.88rem; margin: 6px 0 0; } -.check-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; font-weight: 700; font-size: 0.9rem; } -.invite-result { margin-top: 14px; padding-top: 12px; border-top: 2px dashed var(--line); } -.invite-qr { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin: 12px 0; } -.invite-qr canvas { width: 240px; height: 240px; max-width: 100%; image-rendering: pixelated; border: 2px solid var(--line); border-radius: 10px; background: var(--surface); } -.invite-qr-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; flex: 1; min-width: 160px; } -.invite-qr-actions .setting-help { margin: 0; } -.invite-note { background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 10px 12px; font-weight: 800; font-size: 1.05rem; } -.invite-summary { padding-left: 1.2rem; line-height: 1.6; } -@media (max-width: 420px) { - .invite-grid { grid-template-columns: 1fr; } -} - -/* Numbered dice for 8 and 12 sides */ -.die.numbered { display: flex; align-items: center; justify-content: center; } -.die-num { font-size: 1.6rem; font-weight: 900; font-variant-numeric: tabular-nums; } -.die.mini .die-num { font-size: 0.95rem; } -.die.mini.selected .die-num { color: #fff; } - -/* Lifetime times-table grid (status: icon + color + legend) */ -.fact-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.8rem; color: var(--muted); margin-bottom: 8px; } -.fact-legend-item { display: inline-flex; align-items: center; gap: 5px; } -.fact-legend .fact-cell { width: 18px; height: 18px; flex: none; line-height: 1; } -.fact-grid-wrap { margin-bottom: 10px; } -.stats-heading.centered { text-align: center; } -/* practice summary and Stats: heading, legend and grid centred */ -#pd-body .fact-legend, #history-body .fact-legend { justify-content: center; } -#pd-body .fact-grid, #history-body .fact-grid { margin: 0 auto; } -#history-body .fact-grid-wrap > .practice-name { text-align: center; } -.fact-grid { display: grid; grid-template-columns: repeat(var(--n), minmax(0, 1fr)); gap: 2px; max-width: 420px; } -.fact-head { font-size: 0.7rem; font-weight: 800; color: var(--muted); text-align: center; align-self: center; } -.fact-cell { - display: inline-flex; - align-items: center; - justify-content: center; - aspect-ratio: 1; - border-radius: 4px; - font-size: 0.7rem; - font-weight: 900; - cursor: default; -} -.fact-cell.unseen { background: var(--soft-2); } -.fact-cell.mastered { background: var(--good); color: #fff; } -.fact-cell.timed { font-size: min(0.62rem, 2.5vw); letter-spacing: -0.02em; } -.setting-sub { font-size: 0.82rem; font-weight: 700; color: var(--muted); margin: 8px 0 4px; } -.fact-cell.learning { background: var(--learning); color: var(--learning-ink); } -/* Fact speed tiers (Achievements): metallic bronze, silver and gold */ -.fact-cell.speed-10 { background: linear-gradient(135deg, #f0b98a 0%, #cd7f32 45%, #8c5320 100%); color: #fff; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.45); box-shadow: inset 0 0 0 1px #7a4618; } -.fact-cell.speed-5 { background: linear-gradient(135deg, #ffffff 0%, #c0c7cf 45%, #8a939c 100%); color: #2a3038; box-shadow: inset 0 0 0 1px #7d868f; } -.fact-cell.speed-1 { background: linear-gradient(135deg, #fff3b0 0%, #f6c945 40%, #c9950c 100%); color: #4a3200; box-shadow: inset 0 0 0 1px #9a6a0c; } -.fact-cell.practice { background: var(--danger); color: #fff; } - -/* ---------- Small screens ---------- */ -@media (max-width: 820px) { - .game-layout { grid-template-columns: 1fr; } - /* Header: ☰ logo ⚙️ on one row, the progress meter (if shown) on its own row below */ - .header-right { display: contents; } - .header-right .icon-btn { grid-column: 3; grid-row: 1; justify-self: end; } - .game-header .progress { grid-column: 1 / -1; grid-row: 2; width: 100%; margin-top: 8px; } - .game-header .progress-text { text-align: left; } - .progress-text .long { display: none; } - .progress-text .short { display: inline; } - .board-wrap { padding: 6px; } - .side { display: grid; grid-template-columns: 1fr; } - .die { width: 48px; height: 48px; padding: 6px; } - .pip { width: 8px; height: 8px; } -} - -/* ---------- Wardrobe: keypad skins ---------- */ -.keypad.skin-candy button, .key-preview.skin-candy { background: linear-gradient(#ffd6e4, #ffb3c7); border-color: #d6336c; color: #7a1340; box-shadow: 0 3px 0 #a61e4d; border-radius: 16px; } -.keypad.skin-candy button.check { background: linear-gradient(#b9f0cf, #7fd8a3); color: #0f4d2a; } -.keypad.skin-candy button.back { background: linear-gradient(#ffc9a8, #ff9c6e); color: #6b2400; } -.keypad.skin-retro button, .key-preview.skin-retro { background: linear-gradient(#e7e3d6, #cfc9b6); border-color: #5c5747; color: #2a271f; box-shadow: 0 3px 0 #3d3a2f; border-radius: 6px; font-family: ui-monospace, "Courier New", monospace; } -.keypad.skin-retro button.check { background: linear-gradient(#f0a64a, #d9822b); color: #2a1500; } -.keypad.skin-retro button.back { background: linear-gradient(#7d7866, #5c5747); color: #fff; } -.keypad.skin-neon button, .key-preview.skin-neon { background: #120f24; border-color: #3cf2ff; color: #3cf2ff; box-shadow: 0 0 8px rgba(60, 242, 255, 0.6); text-shadow: 0 0 6px #3cf2ff; } -.keypad.skin-neon button.check { border-color: #7dff9a; color: #7dff9a; box-shadow: 0 0 8px rgba(125, 255, 154, 0.6); background: #0e1d14; } -.keypad.skin-neon button.back { border-color: #ff4fd8; color: #ff4fd8; box-shadow: 0 0 8px rgba(255, 79, 216, 0.6); background: #22101d; } - -/* ---------- Wardrobe dialog ---------- */ -.wardrobe-dialog, .stickers-dialog { max-width: 620px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.wardrobe-dialog h2, .stickers-dialog h2 { text-align: center; margin-bottom: 0.25rem; } -.wardrobe-row { display: flex; flex-wrap: wrap; gap: 8px; } -.wardrobe-chip { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - min-width: 84px; - padding: 8px 6px; - border: 2px solid var(--line); - border-radius: 12px; - background: var(--surface); - cursor: pointer; - font-weight: 800; - font-size: 0.8rem; -} -.wardrobe-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } -.wardrobe-chip.locked { background: var(--soft); color: var(--muted); } -.wardrobe-chip:disabled { cursor: not-allowed; } -.wardrobe-chip.title-chip { min-width: 0; padding: 8px 12px; } -.chip-need { font-size: 0.7rem; font-weight: 700; color: var(--muted); max-width: 140px; text-align: center; } -.chip-preview { display: flex; align-items: center; justify-content: center; gap: 2px; min-height: 36px; } -.chip-preview .die.mini { cursor: inherit; } -.chip-preview .swatch { width: 14px; height: 26px; border-radius: 4px; } -.emoji-preview { font-size: 1.6rem; line-height: 1; } -.key-preview { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 30px; border: 2px solid var(--edge); border-radius: 8px; background: var(--surface); font-weight: 900; box-shadow: 0 2px 0 var(--edge-shadow); } -.motion-preview { font-size: 1.4rem; display: inline-block; } -.wardrobe-chip:hover .motion-preview.roll-tumble { animation: tumble 0.6s linear infinite; } -.wardrobe-chip:hover .motion-preview.roll-bounce { animation: bounce 0.3s ease-in-out infinite alternate; } -.wardrobe-chip:hover .motion-preview.roll-sparkle { animation: spin 0.6s linear infinite; } -.wardrobe-chip:hover .motion-preview.roll-wobble { animation: wobble 0.3s linear infinite; } -.wardrobe-chip:hover .motion-preview.place-pop { animation: pop 0.5s ease-out infinite; } -.tile-preview { width: 40px; height: 30px; border-radius: 6px; border: 2px solid var(--edge); background: var(--accent); } -.tile-preview.pattern-stripes { background: repeating-linear-gradient(45deg, var(--accent) 0 5px, #f59a7b 5px 8px); } -.tile-preview.pattern-dots { background: radial-gradient(circle, #f9b7a2 2px, transparent 2.5px) 0 0 / 8px 8px, var(--accent); } -.tile-preview.pattern-bricks { background: linear-gradient(#f59a7b 1px, transparent 1px) 0 0 / 100% 8px, var(--accent); } -.tile-preview.pattern-checker { background: repeating-conic-gradient(var(--accent) 0 25%, #f59a7b 0 50%) 0 0 / 12px 12px; } -.tile-preview.pattern-wood { background: repeating-linear-gradient(0deg, var(--accent) 0 5px, #f08560 5px 6px); } -.tile-preview[class*="board-"] { background-size: 8px 8px; } -.tile-preview.board-graph { background: linear-gradient(#d9d2c3 1px, transparent 1px) 0 0 / 8px 8px, linear-gradient(90deg, #d9d2c3 1px, #fff 1px) 0 0 / 8px 8px; } -.tile-preview.board-notebook { background: linear-gradient(90deg, transparent 6px, #e98a8a 6px 7px, transparent 7px), linear-gradient(#b9d3ea 1px, #fdfdf6 1px) 0 0 / 8px 8px; } -.tile-preview.board-chalkboard { background: linear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px) 0 0 / 8px 8px, #2f4f3a; } -.tile-preview.board-beach { background: radial-gradient(circle, rgba(160, 120, 60, 0.4) 1px, transparent 1.5px) 0 0 / 5px 5px, #f4e1b0; } -.tile-preview.board-space { background: radial-gradient(circle, #fff 1px, transparent 1.5px) 0 0 / 9px 11px, #10143a; } -.tile-preview.board-snow { background: radial-gradient(circle, #a9c8ea 1.5px, transparent 2px) 0 0 / 9px 9px, #eef6ff; } - -/* "Showing only … · Show everything" in Shop / Wardrobe / Stickers opened from the results */ -/* Shop / Wardrobe footer: stays at the bottom of the scrolling dialog */ -.wallet-bar { - position: sticky; - bottom: calc(-1 * var(--pad-y)); - margin: 16px calc(-1 * var(--pad-x)) calc(-1 * var(--pad-y)); - padding: 12px var(--pad-x) 16px; - background: var(--paper); - border-top: 2px solid var(--line); - border-radius: 0 0 var(--radius) var(--radius); - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - z-index: 1; -} -.wallet-line { flex: 1 1 auto; min-width: 0; font-weight: 800; font-size: 1.1rem; font-variant-numeric: tabular-nums; white-space: nowrap; } -.wallet-after { color: var(--muted); } -.wallet-cost { color: var(--accent); } -.wallet-left { color: var(--ink); } -.wallet-after.short { color: var(--bad); font-size: 0.95rem; } -.view-toggle button { padding: 0.45rem 0.8rem; } -.wallet-bar .btn { flex: none; } -@media (max-width: 480px) { - .wallet-line { flex-basis: 100%; } - .wallet-bar .view-toggle { flex: 1; } -} -.filter-note { text-align: center; font-size: 0.85rem; font-weight: 700; color: var(--muted); margin: 0 0 8px; } -.link-btn { border: 0; background: none; padding: 0; color: var(--accent); font-weight: 800; text-decoration: underline; cursor: pointer; } - -/* ---------- Sticker album ---------- */ -.sticker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 8px; } -.sticker { position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: 14px; border: 2px solid var(--line); background: var(--surface); } -.sticker.rare { border-color: #5b9bd5; } -.sticker.epic { border-color: #b36bff; box-shadow: 0 0 10px rgba(179, 107, 255, 0.45); } -.sticker.missing { background: var(--soft); border-style: dashed; } -.sticker-emoji { font-size: 2rem; } -.sticker.missing .sticker-emoji { font-size: 1.2rem; color: var(--muted); font-weight: 900; } -.sticker-count { position: absolute; right: 4px; bottom: 2px; font-size: 0.72rem; font-weight: 900; color: var(--muted); } - -/* ---------- Players: title under the name; streak counter ---------- */ -.player-title { display: block; font-size: 0.72rem; font-weight: 800; color: var(--gold-ink); } -.streak-badge { min-height: 0; font-weight: 900; color: var(--muted); } -.streak-badge:empty { display: none; } -.flames { display: inline-block; color: #e25822; animation: flicker 0.6s ease-in-out infinite alternate; transform-origin: center bottom; } -.flames[data-level="1"] { font-size: 1.1em; } -.flames[data-level="2"] { font-size: 1.35em; text-shadow: 0 0 8px rgba(255, 140, 0, 0.6); } -.flames[data-level="3"] { font-size: 1.6em; text-shadow: 0 0 14px rgba(255, 90, 0, 0.8); } -@keyframes flicker { - from { transform: scale(1) rotate(-1deg); } - to { transform: scale(1.06) rotate(1deg); } -} - -/* Mastery badge on a times-table line */ -.fact-head.line-mastered { color: var(--star); font-size: 0.72rem; } - -/* iPhone-style notification badge on main-menu buttons */ -.badge { - position: absolute; - top: -9px; - right: -9px; - min-width: 22px; - height: 22px; - padding: 0 6px; - border-radius: 11px; - background: #ff3b30; - color: #fff; - border: 2px solid var(--paper); - font-size: 0.72rem; - font-weight: 900; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - font-variant-numeric: tabular-nums; - animation: pop 0.25s ease-out; - pointer-events: none; -} - -/* Locked steps further down a skill tree: a blurred placeholder with just a lock */ -.skeleton { position: relative; color: transparent !important; text-shadow: none !important; overflow: hidden; } -.skeleton > :not(.lock-badge) { visibility: hidden; } -.skeleton::before { - content: ''; - position: absolute; - inset: 22% 18%; - border-radius: 6px; - background: linear-gradient(90deg, var(--soft-2), var(--line), var(--soft-2)); - background-size: 200% 100%; - filter: blur(2px); - animation: skeleton-shine 1.6s linear infinite; - opacity: 0.8; -} -.skeleton .lock-badge { - position: absolute; - inset: 0; - display: grid; - place-items: center; - margin: 0; - color: var(--muted); - visibility: visible; -} -.skeleton .lock-badge .icon { width: 1.1em; height: 1.1em; } -@keyframes skeleton-shine { - to { background-position: -200% 0; } -} -@media (prefers-reduced-motion: reduce) { - .skeleton::before { animation: none; } -} -.shop-perk { display: block; font-size: 0.78rem; font-weight: 700; color: var(--gold-ink); margin-top: 2px; } -.skeleton-row .shop-name { color: transparent; background: linear-gradient(90deg, var(--soft-2), var(--line), var(--soft-2)); border-radius: 6px; filter: blur(1.5px); user-select: none; } -/* Wardrobe placeholders: all the same size, so a hidden look's name length doesn't show */ -.wardrobe-chip.skeleton { width: 84px; min-width: 84px; max-width: 84px; } -.wardrobe-chip.skeleton > :not(.lock-badge) { display: none; } -.wardrobe-chip.skeleton { min-height: 92px; } - -/* Every dialog's buttons sit in a bottom panel, like the Shop's: full width, a - line above, room around the buttons, and pinned to the bottom while the - dialog scrolls. */ -.dialog .dialog-actions { - position: sticky; - bottom: calc(-1 * var(--pad-y)); - margin: 20px calc(-1 * var(--pad-x)) calc(-1 * var(--pad-y)); - padding: 14px var(--pad-x) 16px; - background: var(--paper); - border-top: 2px solid var(--line); - border-radius: 0 0 var(--radius) var(--radius); - z-index: 1; -} diff --git a/deploy/blockout/.env.1password b/deploy/blockout/.env.1password new file mode 100644 index 0000000..42fa170 --- /dev/null +++ b/deploy/blockout/.env.1password @@ -0,0 +1,17 @@ +# What only you have, as 1Password references (op:////): no +# secrets here. Use: op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh +# The names and fields are examples: point them at your own items. +# (Sign-in's secrets aren't here: the workspace cluster's Keycloak is shared, +# and rts-terraform's own .env.1password has them. With the chart's own +# Keycloak, add BLOCKOUT_GOOGLE_CLIENT_SECRET and, for mail, BLOCKOUT_SMTP_*.) +BLOCKOUT_GHCR_USER=op://Blockout/GHCR pull/username +BLOCKOUT_GHCR_TOKEN=op://Blockout/GHCR pull/token +# Backups (optional; any RCLONE_CONFIG_* turns them on) +# RCLONE_CONFIG_STORE_TYPE=s3 +# RCLONE_CONFIG_STORE_PROVIDER=Other +# RCLONE_CONFIG_STORE_ENDPOINT=op://Blockout/Backup storage/endpoint +# RCLONE_CONFIG_STORE_ACCESS_KEY_ID=op://Blockout/Backup storage/access-key +# RCLONE_CONFIG_STORE_SECRET_ACCESS_KEY=op://Blockout/Backup storage/secret-key +# RCLONE_CONFIG_CRYPT_TYPE=crypt +# RCLONE_CONFIG_CRYPT_REMOTE=op://Blockout/Backup storage/crypt-remote +# RCLONE_CONFIG_CRYPT_PASSWORD=op://Blockout/Backup storage/crypt-password-obscured diff --git a/deploy/blockout/.helmignore b/deploy/blockout/.helmignore new file mode 100644 index 0000000..6898d0c --- /dev/null +++ b/deploy/blockout/.helmignore @@ -0,0 +1,7 @@ +.DS_Store +*.swp +*.tmp +# scripts that use the chart, not part of it +install.sh +forward.sh +.env.1password diff --git a/deploy/blockout/Chart.lock b/deploy/blockout/Chart.lock new file mode 100644 index 0000000..c4dfa6f --- /dev/null +++ b/deploy/blockout/Chart.lock @@ -0,0 +1,6 @@ +dependencies: +- name: keycloakx + repository: https://codecentric.github.io/helm-charts + version: 7.3.2 +digest: sha256:62096a975fbe20dddc08fc8f7b939226ea43d2c1e4d63671556a908b4c9cdc83 +generated: "2026-10-16T21:45:23.660715612Z" diff --git a/deploy/blockout/Chart.yaml b/deploy/blockout/Chart.yaml new file mode 100644 index 0000000..f9361fc --- /dev/null +++ b/deploy/blockout/Chart.yaml @@ -0,0 +1,17 @@ +apiVersion: v2 +name: blockout +description: Blockout, multiplication as area for 3rd graders. The games, live classes and accounts, with everything they run on - MongoDB, Valkey (Redis) and Keycloak with its Postgres - and their backups. +type: application +# The chart's own version; release.yml sets both to the release's version. +version: 0.11.0 +appVersion: "0.11.0" +kubeVersion: ">=1.29.0-0" +home: https://github.com/DeepSpaceCartel/blockout +sources: + - https://github.com/DeepSpaceCartel/blockout +dependencies: + # Keycloak itself; the chart adds its Postgres, realm, address and settings + - name: keycloakx + version: 7.3.2 + repository: https://codecentric.github.io/helm-charts + condition: keycloak.enabled diff --git a/deploy/blockout/files/realm-blockout.json b/deploy/blockout/files/realm-blockout.json new file mode 100644 index 0000000..4595f39 --- /dev/null +++ b/deploy/blockout/files/realm-blockout.json @@ -0,0 +1,77 @@ +{ + "realm": "blockout", + "displayName": "Blockout", + "enabled": true, + "registrationAllowed": false, + "loginWithEmailAllowed": true, + "accessTokenLifespan": 900, + "ssoSessionIdleTimeout": 28800, + "ssoSessionMaxLifespan": 36000, + "roles": { + "realm": [ + { + "name": "admin", + "description": "Blockout admin: accounts and roles, teacher codes, schools. (Blockout keeps everyone else's role itself.)" + } + ] + }, + "clients": [ + { + "clientId": "blockout", + "name": "Blockout", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://localhost:8080/*", + "http://localhost:5173/*" + ], + "webOrigins": [ + "http://localhost:8080", + "http://localhost:5173" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:8080/*##http://localhost:5173/*" + }, + "protocolMappers": [ + { + "name": "identity provider", + "protocol": "openid-connect", + "protocolMapper": "oidc-usersessionmodel-note-mapper", + "consentRequired": false, + "config": { + "user.session.note": "identity_provider", + "claim.name": "identity_provider", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "false", + "userinfo.token.claim": "false", + "introspection.token.claim": "false" + } + } + ] + } + ], + "identityProviders": [ + { + "alias": "google", + "providerId": "google", + "displayName": "Google", + "enabled": true, + "trustEmail": true, + "storeToken": false, + "firstBrokerLoginFlowAlias": "first broker login", + "config": { + "clientId": "336840010192-o7fu9qa4d2iuaa9q7t6crbtuc3i9t0sa.apps.googleusercontent.com", + "clientSecret": "${vault.google-secret}", + "defaultScope": "openid profile email", + "syncMode": "IMPORT", + "prompt": "select_account" + } + } + ] +} diff --git a/deploy/blockout/forward.sh b/deploy/blockout/forward.sh new file mode 100755 index 0000000..8fb2b42 --- /dev/null +++ b/deploy/blockout/forward.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Development: forwards MongoDB (localhost:27017) and Valkey (localhost:6379) +# from the cluster and prints the URLs to start Blockout with. The services are +# the chart's development release (values-dev.yaml): release blockout-dev in +# namespace blockout-dev, or RELEASE=… NAMESPACE=…. Stops with Ctrl+C. +# +# npm run services +set -euo pipefail +RELEASE="${RELEASE:-blockout-dev}" +NS="${NAMESPACE:-$RELEASE}" +secret() { kubectl -n "$NS" get secret "$RELEASE-$1" -o jsonpath="{.data.$2}" | base64 -d; } +mongo_pw="$(secret mongo root-password)" +redis_pw="$(secret redis password)" +echo "Forwarding MongoDB and Valkey from namespace $NS. Start Blockout with:" +echo " BLOCKOUT_MONGO_URL='mongodb://root:$mongo_pw@localhost:27017/blockout?authSource=admin&directConnection=true' \\" +echo " BLOCKOUT_REDIS_URL='redis://:$redis_pw@localhost:6379' npm start" +trap 'kill 0' EXIT +kubectl -n "$NS" port-forward "pod/$RELEASE-mongo-0" 27017:27017 >/dev/null & +kubectl -n "$NS" port-forward "svc/$RELEASE-valkey" 6379:6379 >/dev/null & +wait diff --git a/deploy/blockout/install.sh b/deploy/blockout/install.sh new file mode 100755 index 0000000..0da0eb8 --- /dev/null +++ b/deploy/blockout/install.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Installs or updates Blockout and everything it runs on (MongoDB, Valkey, +# backups, and Keycloak with its Postgres where the cluster has no shared one) +# from this checkout's chart, then waits +# until every part is ready; a failed update is rolled back by itself. The same +# as installing the released chart (docs/guides/deploy.md), with a local one. +# +# op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh 0.11.0 +# deploy/blockout/install.sh 0.11.0 -f secrets.yaml # or the secrets in a file +# IMAGE=registry.example.org/blockout:test deploy/blockout/install.sh +# VALUES=values-production.yaml deploy/blockout/install.sh 1.0.0 +# +# What only you have comes from the environment (op run fills it from +# 1Password; .env.1password holds only references), or from a values file kept +# out of git (secrets.yaml, see values.yaml): +# BLOCKOUT_GHCR_USER, BLOCKOUT_GHCR_TOKEN imageCredentials (a token that can read packages) +# BLOCKOUT_GOOGLE_CLIENT_SECRET keycloak.google.clientSecret (the chart's own Keycloak only) +# BLOCKOUT_SMTP_HOST, _PORT, _FROM, _USER, _PASSWORD keycloak.smtp (the chart's own Keycloak, optional) +# RCLONE_CONFIG_* backup.rclone; any of them turns backups on +# BLOCKOUT_OPENAI_API_KEY openai.apiKey (optional) +# They reach helm on its standard input, never on its command line (where ps +# would show them). Any other helm options can follow. Needs helm and jq with +# access to the cluster. +set -euo pipefail + +NS="${NAMESPACE:-blockout}" +VALUES="${VALUES:-values-workspace.yaml}" +cd "$(dirname "$0")" + +image_args=() +if [ -n "${IMAGE:-}" ]; then + image_args=(--set "image.repository=${IMAGE%:*}" --set "image.tag=${IMAGE##*:}") +elif [ -n "${1:-}" ] && [[ "$1" != -* ]]; then + image_args=(--set "image.tag=${1#v}"); shift +else + echo "Which version? deploy/blockout/install.sh 0.4.1 -f secrets.yaml (or IMAGE=repository:tag)" >&2 + exit 1 +fi + +# The secrets from the environment, as values (unset ones are left out) +secrets() { + jq -n '{ + keycloak: { + google: { clientSecret: env.BLOCKOUT_GOOGLE_CLIENT_SECRET }, + smtp: { host: env.BLOCKOUT_SMTP_HOST, port: env.BLOCKOUT_SMTP_PORT, from: env.BLOCKOUT_SMTP_FROM, + user: env.BLOCKOUT_SMTP_USER, password: env.BLOCKOUT_SMTP_PASSWORD } + }, + imageCredentials: { username: env.BLOCKOUT_GHCR_USER, password: env.BLOCKOUT_GHCR_TOKEN }, + openai: { apiKey: env.BLOCKOUT_OPENAI_API_KEY }, + backup: ([env | to_entries[] | select(.key | startswith("RCLONE_CONFIG_"))] + | if length > 0 then { enabled: true, rclone: from_entries } else null end) + } | walk(if type == "object" then with_entries(select(.value != null and .value != "" and .value != {})) else . end)' +} + +helm repo add codecentric https://codecentric.github.io/helm-charts --force-update >/dev/null +helm dependency build . >/dev/null +secrets | helm upgrade --install blockout . -n "$NS" --create-namespace -f "$VALUES" -f - "${image_args[@]}" "$@" \ + --atomic --wait --timeout 15m diff --git a/deploy/blockout/templates/NOTES.txt b/deploy/blockout/templates/NOTES.txt new file mode 100644 index 0000000..f6f69f3 --- /dev/null +++ b/deploy/blockout/templates/NOTES.txt @@ -0,0 +1,22 @@ +{{- if .Values.server.enabled }} +Blockout {{ .Chart.AppVersion }} is on its way: {{ .Values.publicUrl }} + + kubectl -n {{ .Release.Namespace }} rollout status deployment/{{ include "blockout.fullname" . }} + kubectl -n {{ .Release.Namespace }} logs deployment/{{ include "blockout.fullname" . }} | head +{{- end }} +{{- if .Values.keycloak.enabled }} + +Keycloak: {{ .Values.keycloak.url }} +{{- if .Values.keycloak.httpRoute.adminConsole }} +Its admin console: {{ .Values.keycloak.url | trimSuffix "/" }}/admin, user admin, password: +{{- else }} (sign-in only; set keycloak.httpRoute.adminConsole for the admin console) +Its admin console's password (user admin): +{{- end }} + kubectl -n {{ .Release.Namespace }} get secret {{ .Release.Name }}-keycloak-admin -o jsonpath='{.data.password}' | base64 -d + +The first admin: give your account the realm role "admin" (docs/guides/set-up-keycloak.md). +{{- end }} +{{- if not .Values.backup.enabled }} + +No backups: set backup.enabled and backup.rclone (docs/guides/operate.md). +{{- end }} diff --git a/deploy/blockout/templates/_helpers.tpl b/deploy/blockout/templates/_helpers.tpl new file mode 100644 index 0000000..533ff1e --- /dev/null +++ b/deploy/blockout/templates/_helpers.tpl @@ -0,0 +1,117 @@ +{{/* Names and labels (app.kubernetes.io/*) shared by every object */}} +{{- define "blockout.name" -}} +{{- .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{- define "blockout.fullname" -}} +{{- if contains .Chart.Name .Release.Name -}} +{{- .Release.Name | trunc 63 | trimSuffix "-" -}} +{{- else -}} +{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}} +{{- end -}} +{{- end -}} + +{{- define "blockout.selectorLabels" -}} +app.kubernetes.io/name: {{ include "blockout.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end -}} + +{{- define "blockout.labels" -}} +{{ include "blockout.selectorLabels" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }} +{{- end -}} + +{{/* repository:tag, or repository@digest when a digest is given */}} +{{- define "blockout.image" -}} +{{- if .Values.image.digest -}} +{{ .Values.image.repository }}@{{ .Values.image.digest }} +{{- else -}} +{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }} +{{- end -}} +{{- end -}} + +{{/* +The chart's other parts (MongoDB, Valkey, Keycloak's Postgres): their own +app.kubernetes.io/name, so the server's selectors never pick them up. +Called with (list $ "mongo"). +*/}} +{{- define "blockout.part.selectorLabels" -}} +app.kubernetes.io/name: {{ index . 1 }} +app.kubernetes.io/instance: {{ (index . 0).Release.Name }} +{{- end -}} + +{{- define "blockout.part.labels" -}} +{{- $ := index . 0 -}} +{{ include "blockout.part.selectorLabels" . }} +app.kubernetes.io/part-of: blockout +app.kubernetes.io/managed-by: {{ $.Release.Service }} +helm.sh/chart: {{ printf "%s-%s" $.Chart.Name $.Chart.Version | replace "+" "_" }} +{{- end -}} + +{{/* +A password made once: the one already in the Secret if there is one, else a new +random one. Called with (list $ "" "" ). (helm template +can't look Secrets up, so it always shows new ones.) +*/}} +{{- define "blockout.password" -}} +{{- $ := index . 0 -}} +{{- $old := lookup "v1" "Secret" $.Release.Namespace (index . 1) -}} +{{- if and $old $old.data (hasKey $old.data (index . 2)) -}} +{{- index $old.data (index . 2) | b64dec -}} +{{- else -}} +{{- randAlphaNum (index . 3) -}} +{{- end -}} +{{- end -}} + +{{/* Where the server finds MongoDB's and Valkey's URLs: the chart's Secrets, or the external ones */}} +{{- define "blockout.mongoSecret" -}} +{{- if .Values.mongo.enabled }}{{ .Release.Name }}-mongo{{ else }}{{ required "mongo.external.secretName: the Secret with MongoDB's URL (or mongo.enabled)" .Values.mongo.external.secretName }}{{ end -}} +{{- end -}} +{{- define "blockout.mongoKey" -}} +{{- if .Values.mongo.enabled }}url{{ else }}{{ .Values.mongo.external.key }}{{ end -}} +{{- end -}} +{{- define "blockout.redisSecret" -}} +{{- if .Values.valkey.enabled }}{{ .Release.Name }}-redis{{ else }}{{ required "valkey.external.secretName: the Secret with the Redis URL (or valkey.enabled)" .Values.valkey.external.secretName }}{{ end -}} +{{- end -}} +{{- define "blockout.redisKey" -}} +{{- if .Values.valkey.enabled }}url{{ else }}{{ .Values.valkey.external.key }}{{ end -}} +{{- end -}} + +{{/* The tokens' issuer: the bundled Keycloak's realm, or keycloak.issuer */}} +{{- define "blockout.issuer" -}} +{{- if .Values.keycloak.issuer -}} +{{ .Values.keycloak.issuer }} +{{- else if .Values.keycloak.enabled -}} +{{ required "keycloak.url: Keycloak's public address (https://…)" .Values.keycloak.url | trimSuffix "/" }}/realms/blockout +{{- else -}} +{{ fail "keycloak.issuer is required when keycloak.enabled is false (https://…/realms/blockout)" }} +{{- end -}} +{{- end -}} + +{{/* The server's pull Secrets: the one made from imageCredentials, and any named */}} +{{- define "blockout.imagePullSecrets" -}} +{{- $list := .Values.imagePullSecrets -}} +{{- if .Values.imageCredentials.password -}} +{{- $list = append $list (dict "name" (printf "%s-pull" (include "blockout.fullname" .))) -}} +{{- end -}} +{{- with $list }} +imagePullSecrets: {{- toYaml . | nindent 2 }} +{{- end }} +{{- end -}} + +{{/* Backup and restore pods: called with (dict "uid" ) */}} +{{- define "blockout.backup.pod" -}} +restartPolicy: Never +automountServiceAccountToken: false +securityContext: + runAsNonRoot: true + runAsUser: {{ .uid }} + runAsGroup: {{ .uid }} + fsGroup: {{ .uid }} + seccompProfile: { type: RuntimeDefault } +{{- end }} +{{- define "blockout.backup.restricted" -}} +securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } +{{- end }} diff --git a/deploy/blockout/templates/backup.yaml b/deploy/blockout/templates/backup.yaml new file mode 100644 index 0000000..cc800cc --- /dev/null +++ b/deploy/blockout/templates/backup.yaml @@ -0,0 +1,188 @@ +{{- if .Values.backup.enabled }} +{{- $secret := .Values.backup.existingSecret | default (printf "%s-backup" .Release.Name) }} +{{- $rclone := .Values.backup.rcloneImage }} +# MongoDB, nightly: mongodump to one compressed archive, then rclone copies it +# through the crypt remote (encrypted before it leaves the cluster) +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ .Release.Name }}-backup-mongo + labels: {{- include "blockout.part.labels" (list $ "backup") | nindent 4 }} +spec: + schedule: {{ .Values.backup.schedule.mongo | quote }} + timeZone: Etc/UTC + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 2 + activeDeadlineSeconds: 3600 + template: + metadata: + labels: {{- include "blockout.part.selectorLabels" (list $ "backup") | nindent 12 }} + spec: + {{- include "blockout.backup.pod" (dict "uid" 999) | nindent 10 }} + initContainers: + - name: dump + image: {{ .Values.mongo.image }} + command: [sh, -c, 'mongodump --uri "$MONGO_URL" --gzip --archive="/backup/blockout-$(date -u +%Y-%m-%d).archive.gz" && ls -l /backup'] + env: + - name: MONGO_URL + valueFrom: { secretKeyRef: { name: {{ include "blockout.mongoSecret" . }}, key: {{ include "blockout.mongoKey" . }} } } + {{- include "blockout.backup.restricted" . | nindent 14 }} + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { memory: 512Mi } + volumeMounts: [{ name: backup, mountPath: /backup }] + containers: + - name: upload + image: {{ $rclone }} + command: + - sh + - -c + - | + set -e + rclone mkdir crypt:daily && rclone mkdir crypt:monthly # (deleting old copies needs both) + rclone copy /backup crypt:daily + if [ "$(date -u +%d)" = 01 ]; then rclone copy /backup crypt:monthly; fi + rclone delete --min-age 31d crypt:daily + rclone delete --min-age 366d crypt:monthly + rclone lsl crypt: | tail -5 + envFrom: [{ secretRef: { name: {{ $secret }} } }] + env: + - { name: RCLONE_CONFIG, value: /tmp/rclone.conf } # (settings come from the environment; nothing to read) + {{- include "blockout.backup.restricted" . | nindent 14 }} + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 256Mi } + volumeMounts: + - { name: backup, mountPath: /backup, readOnly: true } + - { name: tmp, mountPath: /tmp } + volumes: + - { name: backup, emptyDir: { sizeLimit: 2Gi } } + - { name: tmp, emptyDir: {} } +--- +# The restore check, monthly: fetches the newest MongoDB backup, restores it into +# a scratch database (restore_check) next to the real one, counts what's in it, +# and drops it. A backup nobody has restored is only a hope. +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ .Release.Name }}-restore-check + labels: {{- include "blockout.part.labels" (list $ "backup") | nindent 4 }} +spec: + schedule: {{ .Values.backup.schedule.restoreCheck | quote }} + timeZone: Etc/UTC + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 0 + activeDeadlineSeconds: 1800 + template: + metadata: + labels: {{- include "blockout.part.selectorLabels" (list $ "backup") | nindent 12 }} + spec: + {{- include "blockout.backup.pod" (dict "uid" 999) | nindent 10 }} + initContainers: + - name: fetch + image: {{ $rclone }} + command: [sh, -c, 'latest=$(rclone lsf crypt:daily | sort | tail -1) && echo "newest: $latest" && rclone copyto "crypt:daily/$latest" /restore/backup.archive.gz'] + envFrom: [{ secretRef: { name: {{ $secret }} } }] + env: + - { name: RCLONE_CONFIG, value: /tmp/rclone.conf } + {{- include "blockout.backup.restricted" . | nindent 14 }} + volumeMounts: + - { name: restore, mountPath: /restore } + - { name: tmp, mountPath: /tmp } + containers: + - name: restore + image: {{ .Values.mongo.image }} + command: + - sh + - -c + - | + set -e + mongorestore --uri "$MONGO_URL" --gzip --archive=/restore/backup.archive.gz --nsFrom 'blockout.*' --nsTo 'restore_check.*' --drop --quiet + mongosh "$MONGO_URL" --quiet --eval ' + const r = db.getSiblingDB("restore_check"); + for (const c of r.getCollectionNames().sort()) print(c.padEnd(14), r.getCollection(c).countDocuments()); + r.dropDatabase(); + print("restored, counted and dropped");' + env: + - name: MONGO_URL + valueFrom: { secretKeyRef: { name: {{ include "blockout.mongoSecret" . }}, key: {{ include "blockout.mongoKey" . }} } } + - { name: HOME, value: /tmp } + {{- include "blockout.backup.restricted" . | nindent 14 }} + volumeMounts: + - { name: restore, mountPath: /restore, readOnly: true } + - { name: tmp, mountPath: /tmp } + volumes: + - { name: restore, emptyDir: { sizeLimit: 2Gi } } + - { name: tmp, emptyDir: {} } +{{- if .Values.keycloak.enabled }} +--- +# Keycloak's Postgres, nightly: pg_dump, then the same crypt remote, under keycloak/ +apiVersion: batch/v1 +kind: CronJob +metadata: + name: {{ .Release.Name }}-backup-keycloak + labels: {{- include "blockout.part.labels" (list $ "backup") | nindent 4 }} +spec: + schedule: {{ .Values.backup.schedule.keycloak | quote }} + timeZone: Etc/UTC + concurrencyPolicy: Forbid + successfulJobsHistoryLimit: 3 + failedJobsHistoryLimit: 3 + jobTemplate: + spec: + backoffLimit: 2 + activeDeadlineSeconds: 3600 + template: + metadata: + labels: {{- include "blockout.part.selectorLabels" (list $ "backup") | nindent 12 }} + spec: + {{- include "blockout.backup.pod" (dict "uid" 70) | nindent 10 }} + initContainers: + - name: dump + image: {{ .Values.keycloak.postgres.image }} + command: [sh, -c, 'pg_dump -h {{ .Release.Name }}-keycloak-db -U keycloak -d keycloak --format=custom --file="/backup/keycloak-$(date -u +%Y-%m-%d).dump" && ls -l /backup'] + env: + - name: PGPASSWORD + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-keycloak-db, key: password } } + {{- include "blockout.backup.restricted" . | nindent 14 }} + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 256Mi } + volumeMounts: [{ name: backup, mountPath: /backup }] + containers: + - name: upload + image: {{ $rclone }} + command: + - sh + - -c + - | + set -e + rclone mkdir crypt:keycloak/daily && rclone mkdir crypt:keycloak/monthly + rclone copy /backup crypt:keycloak/daily + if [ "$(date -u +%d)" = 01 ]; then rclone copy /backup crypt:keycloak/monthly; fi + rclone delete --min-age 31d crypt:keycloak/daily + rclone delete --min-age 366d crypt:keycloak/monthly + rclone lsl crypt:keycloak | tail -3 + envFrom: [{ secretRef: { name: {{ $secret }} } }] + env: + - { name: RCLONE_CONFIG, value: /tmp/rclone.conf } + {{- include "blockout.backup.restricted" . | nindent 14 }} + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 256Mi } + volumeMounts: + - { name: backup, mountPath: /backup, readOnly: true } + - { name: tmp, mountPath: /tmp } + volumes: + - { name: backup, emptyDir: { sizeLimit: 1Gi } } + - { name: tmp, emptyDir: {} } +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/configmap.yaml b/deploy/blockout/templates/configmap.yaml new file mode 100644 index 0000000..dc2f34f --- /dev/null +++ b/deploy/blockout/templates/configmap.yaml @@ -0,0 +1,20 @@ +{{- if .Values.server.enabled }} +# The server's settings that aren't secret (reference: docs/reference/CONFIGURATION.md) +{{- $publicUrl := required "publicUrl is required (https://…)" .Values.publicUrl }} +{{- if not (hasPrefix "https://" $publicUrl) }}{{ fail "publicUrl must start with https://" }}{{ end }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +data: + PUBLIC_URL: {{ $publicUrl | quote }} + BLOCKOUT_AUTH: keycloak + KEYCLOAK_ISSUER: {{ include "blockout.issuer" . | quote }} + KEYCLOAK_CLIENT_ID: {{ .Values.keycloak.clientId | quote }} + TRUST_PROXY: {{ .Values.trustProxy | quote }} + LOG_LEVEL: {{ .Values.log.level | quote }} + LOG_FORMAT: {{ .Values.log.format | quote }} + BLOCKOUT_REDIS_PREFIX: {{ .Values.redisPrefix | quote }} + METRICS_PORT: {{ .Values.metrics.port | quote }} +{{- end }} diff --git a/deploy/blockout/templates/deployment.yaml b/deploy/blockout/templates/deployment.yaml new file mode 100644 index 0000000..a9171a9 --- /dev/null +++ b/deploy/blockout/templates/deployment.yaml @@ -0,0 +1,94 @@ +{{- if .Values.server.enabled }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + {{- if not .Values.autoscaling.enabled }} + replicas: {{ .Values.replicaCount }} + {{- end }} + selector: + matchLabels: {{- include "blockout.selectorLabels" . | nindent 6 }} + # One new server at a time, and never fewer than asked for: classes move over as servers stop + strategy: + type: RollingUpdate + rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } + template: + metadata: + labels: {{- include "blockout.selectorLabels" . | nindent 8 }} + annotations: + # a settings change restarts the servers + checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + serviceAccountName: {{ include "blockout.fullname" . }} + automountServiceAccountToken: false + {{- include "blockout.imagePullSecrets" . | nindent 6 }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + securityContext: {{- toYaml .Values.podSecurityContext | nindent 8 }} + # spread over nodes, so one node going away doesn't take every server + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: kubernetes.io/hostname + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: {{- include "blockout.selectorLabels" . | nindent 14 }} + containers: + - name: blockout + image: {{ include "blockout.image" . }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + securityContext: {{- toYaml .Values.securityContext | nindent 12 }} + ports: + - { name: http, containerPort: 8080 } + - { name: metrics, containerPort: {{ .Values.metrics.port }} } + envFrom: + - configMapRef: { name: {{ include "blockout.fullname" . }} } + env: + - name: BLOCKOUT_MONGO_URL + valueFrom: { secretKeyRef: { name: {{ include "blockout.mongoSecret" . }}, key: {{ include "blockout.mongoKey" . }} } } + - name: BLOCKOUT_REDIS_URL + valueFrom: { secretKeyRef: { name: {{ include "blockout.redisSecret" . }}, key: {{ include "blockout.redisKey" . }} } } + {{- if or .Values.openai.apiKey .Values.openai.existingSecret }} + - name: OPENAI_API_KEY + valueFrom: { secretKeyRef: { name: {{ .Values.openai.existingSecret | default (printf "%s-openai" (include "blockout.fullname" .)) }}, key: api-key } } + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} + # startup: migrations done and databases connected; live: the event + # loop turns (never the databases, so their outage restarts nothing); + # ready: can serve (not while shutting down) + startupProbe: + httpGet: { path: /health/startup, port: http } + periodSeconds: 2 + failureThreshold: 60 + livenessProbe: + httpGet: { path: /health/live, port: http } + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: { path: /health/ready, port: http } + periodSeconds: 5 + failureThreshold: 2 + lifecycle: + # let the gateway stop sending new requests before the server starts finishing + preStop: + exec: { command: [sleep, "{{ .Values.preStopSleepSeconds }}"] } + resources: {{- toYaml .Values.resources | nindent 12 }} + volumeMounts: + - { name: tmp, mountPath: /tmp } + volumes: + - { name: tmp, emptyDir: { sizeLimit: 64Mi } } + {{- with .Values.nodeSelector }} + nodeSelector: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/deploy/blockout/templates/hpa.yaml b/deploy/blockout/templates/hpa.yaml new file mode 100644 index 0000000..2bca13f --- /dev/null +++ b/deploy/blockout/templates/hpa.yaml @@ -0,0 +1,18 @@ +{{- if .Values.server.enabled }} +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + scaleTargetRef: { apiVersion: apps/v1, kind: Deployment, name: {{ include "blockout.fullname" . }} } + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: { type: Utilization, averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} } +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/httproute.yaml b/deploy/blockout/templates/httproute.yaml new file mode 100644 index 0000000..a7736b5 --- /dev/null +++ b/deploy/blockout/templates/httproute.yaml @@ -0,0 +1,19 @@ +{{- if .Values.server.enabled }} +{{- if .Values.httpRoute.enabled }} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + parentRefs: {{- required "httpRoute.parentRefs: the Gateway (and listener) to attach to" .Values.httpRoute.parentRefs | toYaml | nindent 4 }} + hostnames: {{- required "httpRoute.hostnames: e.g. blockout.example.org" .Values.httpRoute.hostnames | toYaml | nindent 4 }} + rules: + - backendRefs: + - name: {{ include "blockout.fullname" . }} + port: {{ .Values.service.port }} + {{- with .Values.httpRoute.timeouts }} + timeouts: {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/ingress.yaml b/deploy/blockout/templates/ingress.yaml new file mode 100644 index 0000000..940b56d --- /dev/null +++ b/deploy/blockout/templates/ingress.yaml @@ -0,0 +1,31 @@ +{{- if .Values.server.enabled }} +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} + {{- with .Values.ingress.annotations }} + annotations: {{- toYaml . | nindent 4 }} + {{- end }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "blockout.fullname" $ }} + port: { number: {{ $.Values.service.port }} } + {{- end }} +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/keycloak-realm-job.yaml b/deploy/blockout/templates/keycloak-realm-job.yaml new file mode 100644 index 0000000..4b6a330 --- /dev/null +++ b/deploy/blockout/templates/keycloak-realm-job.yaml @@ -0,0 +1,139 @@ +{{- if .Values.keycloak.enabled }} +{{- $smtp := .Values.keycloak.smtp.existingSecret | default (ternary (printf "%s-keycloak-smtp" .Release.Name) "" (ne .Values.keycloak.smtp.host "")) }} +{{- $urls := prepend .Values.keycloak.extraRedirectUrls .Values.publicUrl | compact | uniq }} +# Brings the blockout realm in line after every install and upgrade: the realm +# file is only imported into an empty Keycloak, so changes to an existing realm +# go through Keycloak's admin CLI. Safe to run any number of times. +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-keycloak-realm-job + labels: {{- include "blockout.part.labels" (list $ "keycloak") | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-weight: "-1" + helm.sh/hook-delete-policy: before-hook-creation +data: + realm.sh: | + set -euo pipefail + set -f # (the addresses hold * wildcards: no file name expansion) + REALM=blockout + kc() { /opt/keycloak/bin/kcadm.sh "$@" --config /tmp/kcadm.config; } + + for i in $(seq 1 60); do + kc config credentials --server "$KEYCLOAK" --realm master --user "$ADMIN_USER" --password "$ADMIN_PASSWORD" >/dev/null 2>&1 && break + [ "$i" = 60 ] && { echo "Keycloak didn't answer at $KEYCLOAK" >&2; exit 1; } + sleep 5 + done + + # The client: Blockout's addresses only (no wildcards beyond each address's own paths) + redirects=$(printf '"%s/*",' $URLS); origins=$(printf '"%s",' $URLS) + logout=$(printf '%s/*##' $URLS) + id=$(kc get clients -r "$REALM" -q clientId={{ .Values.keycloak.clientId }} --fields id --format csv --noquotes) + kc update "clients/$id" -r "$REALM" \ + -s "redirectUris=[${redirects%,}]" -s "webOrigins=[${origins%,}]" \ + -s "attributes.\"post.logout.redirect.uris\"=${logout%##}" + echo "client {{ .Values.keycloak.clientId }}: $URLS" + + # Keycloak's own admin signs in on the master realm: guessing is slowed down there too + kc update realms/master -s bruteForceProtected=true -s failureFactor=5 -s waitIncrementSeconds=60 -s maxFailureWaitSeconds=3600 + echo "master realm: brute-force protection" + + # The phone app's client: public, with PKCE; its tokens name "blockout" as + # their audience, so the server takes them like the web game's + if [ -n "$MOBILE_URLS" ]; then + mid=$(kc get clients -r "$REALM" -q clientId=blockout-mobile --fields id --format csv --noquotes) + [ -n "$mid" ] || kc create clients -r "$REALM" -f /job/mobile-client.json + mid=$(kc get clients -r "$REALM" -q clientId=blockout-mobile --fields id --format csv --noquotes) + mredirects=$(printf '"%s",' $MOBILE_URLS) + kc update "clients/$mid" -r "$REALM" -s "redirectUris=[${mredirects%,}]" -s 'webOrigins=["+"]' + for mapper in audience identity-provider; do + name=$(sed -n 's/.*"name": *"\([^"]*\)".*/\1/p' "/job/mapper-$mapper.json" | head -1) + kc get "clients/$mid/protocol-mappers/models" -r "$REALM" --fields name --format csv --noquotes | grep -qx "$name" || + kc create "clients/$mid/protocol-mappers/models" -r "$REALM" -f "/job/mapper-$mapper.json" + done + echo "client blockout-mobile: $MOBILE_URLS" + fi + + # Admins are the only role Keycloak keeps for Blockout (ADR-0008) + kc get "roles/admin" -r "$REALM" >/dev/null 2>&1 || kc create roles -r "$REALM" -s name=admin -s 'description=Blockout admin: accounts and roles, teacher codes, schools.' + echo "realm role admin" + + # Guessing passwords is slowed down; passwords are at least 10 characters + kc update "realms/$REALM" -s bruteForceProtected=true -s failureFactor=10 -s waitIncrementSeconds=60 -s maxFailureWaitSeconds=900 -s 'passwordPolicy=length(10)' + echo "brute-force protection, passwords of 10 or more" + + # Mail: check addresses and reset passwords only when Keycloak can send mail + if [ -n "${SMTP_HOST:-}" ]; then + kc update "realms/$REALM" -s verifyEmail=true -s resetPasswordAllowed=true \ + -s "smtpServer.host=$SMTP_HOST" -s "smtpServer.port=$SMTP_PORT" -s "smtpServer.from=$SMTP_FROM" \ + -s "smtpServer.auth=true" -s "smtpServer.user=$SMTP_USER" -s "smtpServer.password=$SMTP_PASSWORD" \ + -s "smtpServer.starttls=true" -s "smtpServer.fromDisplayName=Blockout" + echo "mail through $SMTP_HOST: addresses are checked, passwords can be reset" + else + kc update "realms/$REALM" -s verifyEmail=false -s resetPasswordAllowed=false + echo "no mail: no address checks or password resets (nothing could send the mail)" + fi + mobile-client.json: | + { "clientId": "blockout-mobile", "name": "Blockout app", "enabled": true, "publicClient": true, "protocol": "openid-connect", + "standardFlowEnabled": true, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, + "attributes": { "pkce.code.challenge.method": "S256" } } + mapper-audience.json: | + { "name": "blockout audience", "protocol": "openid-connect", "protocolMapper": "oidc-audience-mapper", + "config": { "included.client.audience": "blockout", "access.token.claim": "true", "id.token.claim": "false", "introspection.token.claim": "true" } } + mapper-identity-provider.json: | + { "name": "identity provider", "protocol": "openid-connect", "protocolMapper": "oidc-usersessionmodel-note-mapper", + "config": { "user.session.note": "identity_provider", "claim.name": "identity_provider", "jsonType.label": "String", + "id.token.claim": "true", "access.token.claim": "false", "userinfo.token.claim": "false", "introspection.token.claim": "false" } } +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ .Release.Name }}-keycloak-realm + labels: {{- include "blockout.part.labels" (list $ "keycloak") | nindent 4 }} + annotations: + helm.sh/hook: post-install,post-upgrade + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: 2 + activeDeadlineSeconds: 900 + template: + metadata: + labels: {{- include "blockout.part.selectorLabels" (list $ "keycloak-realm-job") | nindent 8 }} + spec: + restartPolicy: Never + automountServiceAccountToken: false + securityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: { type: RuntimeDefault } + containers: + - name: realm + image: {{ .Values.keycloakx.image.repository | default "quay.io/keycloak/keycloak" }}:{{ .Values.keycloakx.image.tag }} + command: [bash, /job/realm.sh] + env: + - { name: KEYCLOAK, value: "http://{{ .Release.Name }}-keycloakx-http" } + - { name: URLS, value: {{ join " " $urls | quote }} } + - { name: MOBILE_URLS, value: {{ join " " .Values.keycloak.mobile.redirectUrls | quote }} } + - name: ADMIN_USER + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-keycloak-admin, key: username } } + - name: ADMIN_PASSWORD + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-keycloak-admin, key: password } } + {{- if $smtp }} + {{- range $key := list "host" "port" "from" "user" "password" }} + - name: SMTP_{{ upper $key }} + valueFrom: { secretKeyRef: { name: {{ $smtp }}, key: {{ $key }} } } + {{- end }} + {{- end }} + - { name: JAVA_OPTS_APPEND, value: "-XX:MaxRAMPercentage=70 -Djava.io.tmpdir=/tmp" } + securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } + resources: {{- toYaml .Values.keycloak.realmJob.resources | nindent 12 }} + volumeMounts: + - { name: job, mountPath: /job, readOnly: true } + - { name: tmp, mountPath: /tmp } + volumes: + - name: job + configMap: { name: {{ .Release.Name }}-keycloak-realm-job } + - { name: tmp, emptyDir: {} } +{{- end }} diff --git a/deploy/blockout/templates/keycloak.yaml b/deploy/blockout/templates/keycloak.yaml new file mode 100644 index 0000000..5278d23 --- /dev/null +++ b/deploy/blockout/templates/keycloak.yaml @@ -0,0 +1,113 @@ +{{- if .Values.keycloak.enabled }} +{{- $db := printf "%s-keycloak-db" .Release.Name }} +{{- $dbSel := include "blockout.part.selectorLabels" (list $ "keycloak-db") }} +# Keycloak's settings that come from this chart (the keycloakx chart reads them +# through keycloakx.extraEnvFrom) +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-keycloak + labels: {{- include "blockout.part.labels" (list $ "keycloak") | nindent 4 }} +data: + # Every URL Keycloak hands out, and the tokens' issuer, use this address + KC_HOSTNAME: {{ required "keycloak.url: Keycloak's public address (https://…)" .Values.keycloak.url | trimSuffix "/" | quote }} + KC_DB: postgres + KC_DB_URL_HOST: {{ $db }} + KC_DB_URL_DATABASE: keycloak + KC_DB_USERNAME: keycloak + KC_VAULT: file + KC_VAULT_DIR: /opt/keycloak/vault +--- +# The blockout realm, imported when Keycloak first starts (an existing realm is +# left alone: the realm job below keeps it in line) +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ .Release.Name }}-keycloak-realm + labels: {{- include "blockout.part.labels" (list $ "keycloak") | nindent 4 }} +data: + realm-blockout.json: {{ .Files.Get "files/realm-blockout.json" | quote }} +--- +# Postgres for Keycloak: the realm's users, their Google links and settings +apiVersion: v1 +kind: Service +metadata: + name: {{ $db }} + labels: {{- include "blockout.part.labels" (list $ "keycloak-db") | nindent 4 }} +spec: + selector: {{- $dbSel | nindent 4 }} + ports: [{ name: postgres, port: 5432 }] +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $db }} + labels: {{- include "blockout.part.labels" (list $ "keycloak-db") | nindent 4 }} +spec: + serviceName: {{ $db }} + replicas: 1 + selector: + matchLabels: {{- $dbSel | nindent 6 }} + template: + metadata: + labels: {{- $dbSel | nindent 8 }} + spec: + automountServiceAccountToken: false + # The alpine image's postgres user; fsGroup lets it own the volume + securityContext: + runAsUser: 70 + runAsGroup: 70 + fsGroup: 70 + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: postgres + image: {{ .Values.keycloak.postgres.image }} + securityContext: + allowPrivilegeEscalation: false + capabilities: { drop: [ALL] } + env: + - { name: POSTGRES_DB, value: keycloak } + - { name: POSTGRES_USER, value: keycloak } + - name: POSTGRES_PASSWORD + valueFrom: { secretKeyRef: { name: {{ $db }}, key: password } } + - { name: PGDATA, value: /var/lib/postgresql/data/pgdata } + ports: [{ name: postgres, containerPort: 5432 }] + readinessProbe: + exec: { command: [pg_isready, -U, keycloak, -d, keycloak] } + periodSeconds: 5 + resources: {{- toYaml .Values.keycloak.postgres.resources | nindent 12 }} + volumeMounts: [{ name: data, mountPath: /var/lib/postgresql/data }] + volumeClaimTemplates: + - metadata: { name: data } + spec: + accessModes: [ReadWriteOnce] + {{- with .Values.keycloak.postgres.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: { requests: { storage: {{ .Values.keycloak.postgres.storage }} } } +{{- if .Values.keycloak.httpRoute.enabled }} +--- +# Keycloak's public address: sign-in, and with adminConsole the admin console +# (/admin, and the master realm it signs in with) +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ .Release.Name }}-keycloak + labels: {{- include "blockout.part.labels" (list $ "keycloak") | nindent 4 }} +spec: + parentRefs: {{- required "keycloak.httpRoute.parentRefs: the Gateway (and listener) for Keycloak's address" .Values.keycloak.httpRoute.parentRefs | toYaml | nindent 4 }} + hostnames: [{{ urlParse .Values.keycloak.url | dig "host" "" | quote }}] + rules: + - matches: + - path: { type: PathPrefix, value: /realms/blockout } + - path: { type: PathPrefix, value: /resources } + {{- if .Values.keycloak.httpRoute.adminConsole }} + - path: { type: PathPrefix, value: /admin } + - path: { type: PathPrefix, value: /realms/master } + {{- end }} + backendRefs: + - name: {{ .Release.Name }}-keycloakx-http + port: 80 +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/mongo.yaml b/deploy/blockout/templates/mongo.yaml new file mode 100644 index 0000000..52d01b0 --- /dev/null +++ b/deploy/blockout/templates/mongo.yaml @@ -0,0 +1,106 @@ +{{- if .Values.mongo.enabled }} +{{- $name := printf "%s-mongo" .Release.Name }} +{{- $sel := include "blockout.part.selectorLabels" (list $ "mongo") }} +# MongoDB for accounts: one member of the replica set rs0 +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: {{- include "blockout.part.labels" (list $ "mongo") | nindent 4 }} +spec: + clusterIP: None # each member by name: -mongo-0.-mongo..svc + # Names resolve before the member is ready: the replica set is started (and + # the servers find it) by that name + publishNotReadyAddresses: true + selector: {{- $sel | nindent 4 }} + ports: [{ name: mongo, port: 27017 }] +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + labels: {{- include "blockout.part.labels" (list $ "mongo") | nindent 4 }} +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: {{- $sel | nindent 6 }} + template: + metadata: + labels: {{- $sel | nindent 8 }} + spec: + automountServiceAccountToken: false + # The image's mongodb user; fsGroup lets it own the volume + securityContext: + runAsUser: 999 + runAsGroup: 999 + fsGroup: 999 + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + initContainers: + # mongod wants the replica set key readable by its owner only + - name: keyfile + image: {{ .Values.mongo.image }} + command: [sh, -c, 'cp /secret/keyfile /key/keyfile && chmod 400 /key/keyfile'] + securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: [ALL] } } + volumeMounts: + - { name: secret, mountPath: /secret, readOnly: true } + - { name: key, mountPath: /key } + containers: + - name: mongo + image: {{ .Values.mongo.image }} + args: [--replSet, rs0, --bind_ip_all, --keyFile, /key/keyfile] + securityContext: { allowPrivilegeEscalation: false, capabilities: { drop: [ALL] } } + env: + - { name: MONGO_INITDB_ROOT_USERNAME, value: root } + - name: MONGO_INITDB_ROOT_PASSWORD + valueFrom: { secretKeyRef: { name: {{ $name }}, key: root-password } } + ports: [{ name: mongo, containerPort: 27017 }] + readinessProbe: + exec: { command: [mongosh, --quiet, --eval, 'db.adminCommand("ping").ok'] } + periodSeconds: 10 + timeoutSeconds: 5 + resources: {{- toYaml .Values.mongo.resources | nindent 12 }} + volumeMounts: + - { name: data, mountPath: /data/db } + - { name: key, mountPath: /key, readOnly: true } + # Starts the replica set of one, once: until then MongoDB has no + # primary and nothing can write. (On a first start the image restarts + # mongod after making the root user, so it tries until that's done.) + - name: replica-set + image: {{ .Values.mongo.image }} + command: + - sh + - -c + - | + host="$(hostname).{{ $name }}.{{ .Release.Namespace }}.svc.cluster.local:27017" + until mongosh --quiet -u root -p "$MONGO_PASSWORD" --authenticationDatabase admin --eval " + try { rs.status().ok } catch (e) { rs.initiate({ _id: 'rs0', members: [{ _id: 0, host: '$host' }] }).ok } + for (let i = 0; i < 30 && !db.hello().isWritablePrimary; i++) sleep(1000); + if (!db.hello().isWritablePrimary) quit(1)" >/dev/null 2>&1; do sleep 5; done + echo "replica set rs0 is ready" + exec sleep infinity + securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } + env: + - name: MONGO_PASSWORD + valueFrom: { secretKeyRef: { name: {{ $name }}, key: root-password } } + # mongosh keeps its history and logs in the home folder + - { name: HOME, value: /tmp } + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { memory: 256Mi } + volumeMounts: [{ name: tmp, mountPath: /tmp }] + volumes: + - name: secret + secret: { secretName: {{ $name }}, items: [{ key: keyfile, path: keyfile }] } + - { name: key, emptyDir: { medium: Memory } } + - { name: tmp, emptyDir: {} } + volumeClaimTemplates: + - metadata: { name: data } + spec: + accessModes: [ReadWriteOnce] + {{- with .Values.mongo.storageClass }} + storageClassName: {{ . }} + {{- end }} + resources: { requests: { storage: {{ .Values.mongo.storage }} } } +{{- end }} diff --git a/deploy/blockout/templates/networkpolicy.yaml b/deploy/blockout/templates/networkpolicy.yaml new file mode 100644 index 0000000..19db3e6 --- /dev/null +++ b/deploy/blockout/templates/networkpolicy.yaml @@ -0,0 +1,58 @@ +{{- if .Values.server.enabled }} +{{- if .Values.networkPolicy.enabled }} +{{- if .Values.networkPolicy.cilium }} +# In: only from the gateway. Out: DNS, MongoDB and Valkey, and HTTPS to the +# world (Keycloak's public keys at its public address, OpenAI's moderation). +apiVersion: cilium.io/v2 +kind: CiliumNetworkPolicy +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + endpointSelector: + matchLabels: {{- include "blockout.selectorLabels" . | nindent 6 }} + ingress: + - fromEntities: [ingress] + toPorts: [{ ports: [{ port: "8080", protocol: TCP }] }] + - fromEndpoints: + - matchLabels: { k8s:io.kubernetes.pod.namespace: {{ .Values.metrics.namespace }} } + toPorts: [{ ports: [{ port: "{{ .Values.metrics.port }}", protocol: TCP }] }] + # (the kubelet's health probes come from the node) + - fromEntities: [host] + egress: + - toEndpoints: + - matchLabels: { k8s:io.kubernetes.pod.namespace: kube-system, k8s-app: kube-dns } + toPorts: [{ ports: [{ port: "53", protocol: ANY }] }] + - toEndpoints: + - matchLabels: { k8s:io.kubernetes.pod.namespace: {{ .Values.networkPolicy.dataNamespace | default .Release.Namespace }} } + toPorts: [{ ports: [{ port: "27017", protocol: TCP }, { port: "6379", protocol: TCP }] }] + - toEntities: [world] + toPorts: [{ ports: [{ port: "443", protocol: TCP }] }] +{{- else }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: {{- include "blockout.selectorLabels" . | nindent 6 }} + policyTypes: [Ingress, Egress] + ingress: + - from: + {{- range .Values.networkPolicy.ingressNamespaces }} + - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: {{ . }} } } + {{- end }} + ports: [{ port: 8080, protocol: TCP }] + - from: + - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: {{ .Values.metrics.namespace }} } } + ports: [{ port: {{ .Values.metrics.port }}, protocol: TCP }] + egress: + - ports: [{ port: 53, protocol: UDP }, { port: 53, protocol: TCP }] + - to: + - namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: {{ .Values.networkPolicy.dataNamespace | default .Release.Namespace }} } } + ports: [{ port: 27017, protocol: TCP }, { port: 6379, protocol: TCP }] + - ports: [{ port: 443, protocol: TCP }] +{{- end }} +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/pdb.yaml b/deploy/blockout/templates/pdb.yaml new file mode 100644 index 0000000..e0526db --- /dev/null +++ b/deploy/blockout/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if .Values.server.enabled }} +{{- if .Values.podDisruptionBudget.enabled }} +# Node maintenance takes servers one at a time, so classes always have one to move to +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + selector: + matchLabels: {{- include "blockout.selectorLabels" . | nindent 6 }} +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/prometheusrule.yaml b/deploy/blockout/templates/prometheusrule.yaml new file mode 100644 index 0000000..b5f9e8c --- /dev/null +++ b/deploy/blockout/templates/prometheusrule.yaml @@ -0,0 +1,61 @@ +{{- if .Values.server.enabled }} +{{- if .Values.metrics.rules.enabled }} +{{- $deploy := include "blockout.fullname" . }} +{{- $ns := .Release.Namespace }} +# What needs a person: no servers, errors, slowness, and backups that stopped +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: {{ $deploy }} + labels: + {{- include "blockout.labels" . | nindent 4 }} + {{- with .Values.metrics.rules.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + groups: + - name: blockout + rules: + - alert: BlockoutDown + expr: kube_deployment_status_replicas_available{namespace="{{ $ns }}", deployment="{{ $deploy }}"} < 1 + for: 2m + labels: { severity: critical } + annotations: + summary: No Blockout server is ready + description: Nobody can play or sign in. kubectl -n {{ $ns }} describe pods; logs in Loki. + - alert: BlockoutServersMissing + expr: kube_deployment_status_replicas_available{namespace="{{ $ns }}", deployment="{{ $deploy }}"} < kube_deployment_spec_replicas{namespace="{{ $ns }}", deployment="{{ $deploy }}"} + for: 10m + labels: { severity: warning } + annotations: + summary: Fewer Blockout servers are ready than asked for + - alert: BlockoutServerErrors + expr: | + sum(rate(blockout_http_requests_total{namespace="{{ $ns }}", status=~"5.."}[5m])) + / clamp_min(sum(rate(blockout_http_requests_total{namespace="{{ $ns }}"}[5m])), 1e-9) > 0.01 + for: 10m + labels: { severity: warning } + annotations: + summary: More than 1% of Blockout requests fail + description: Look for http.error records in the logs. + - alert: BlockoutSlow + # (event streams and long polls are slow on purpose) + expr: | + histogram_quantile(0.95, sum by (le) (rate(blockout_http_request_duration_seconds_bucket{namespace="{{ $ns }}", route!~".*/(events|poll)"}[5m]))) > 1 + for: 10m + labels: { severity: warning } + annotations: + summary: One Blockout request in 20 takes more than a second + {{- if .Values.backup.enabled }}{{ with printf "%s-backup-mongo" .Release.Name }} + - alert: BlockoutBackupMissing + expr: | + time() - max(kube_cronjob_status_last_successful_time{namespace="{{ $ns }}", cronjob="{{ . }}"}) > 26 * 3600 + or absent(kube_cronjob_status_last_successful_time{namespace="{{ $ns }}", cronjob="{{ . }}"}) + for: 30m + labels: { severity: warning } + annotations: + summary: No Blockout database backup for over a day + description: kubectl -n {{ $ns }} get jobs; docs/guides/operate.md + {{- end }}{{ end }} +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/service.yaml b/deploy/blockout/templates/service.yaml new file mode 100644 index 0000000..19bcc2e --- /dev/null +++ b/deploy/blockout/templates/service.yaml @@ -0,0 +1,13 @@ +{{- if .Values.server.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +spec: + selector: {{- include "blockout.selectorLabels" . | nindent 4 }} + ports: + - { name: http, port: {{ .Values.service.port }}, targetPort: http } + # (not on the HTTPRoute: only Prometheus, inside the cluster) + - { name: metrics, port: {{ .Values.metrics.port }}, targetPort: metrics } +{{- end }} diff --git a/deploy/blockout/templates/serviceaccount.yaml b/deploy/blockout/templates/serviceaccount.yaml new file mode 100644 index 0000000..c072c9f --- /dev/null +++ b/deploy/blockout/templates/serviceaccount.yaml @@ -0,0 +1,9 @@ +{{- if .Values.server.enabled }} +# Its own service account, with no token mounted: Blockout never talks to the Kubernetes API +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "blockout.fullname" . }} + labels: {{- include "blockout.labels" . | nindent 4 }} +automountServiceAccountToken: false +{{- end }} diff --git a/deploy/blockout/templates/servicemonitor.yaml b/deploy/blockout/templates/servicemonitor.yaml new file mode 100644 index 0000000..2756de2 --- /dev/null +++ b/deploy/blockout/templates/servicemonitor.yaml @@ -0,0 +1,20 @@ +{{- if .Values.server.enabled }} +{{- if .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "blockout.fullname" . }} + labels: + {{- include "blockout.labels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + selector: + matchLabels: {{- include "blockout.selectorLabels" . | nindent 6 }} + endpoints: + - port: metrics + path: /metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} +{{- end }} +{{- end }} diff --git a/deploy/blockout/templates/valkey.yaml b/deploy/blockout/templates/valkey.yaml new file mode 100644 index 0000000..e71e031 --- /dev/null +++ b/deploy/blockout/templates/valkey.yaml @@ -0,0 +1,50 @@ +{{- if .Values.valkey.enabled }} +{{- $name := printf "%s-valkey" .Release.Name }} +{{- $sel := include "blockout.part.selectorLabels" (list $ "valkey") }} +# Valkey for live classes: no volume (a restart only ends the classes running then) +apiVersion: v1 +kind: Service +metadata: + name: {{ $name }} + labels: {{- include "blockout.part.labels" (list $ "valkey") | nindent 4 }} +spec: + selector: {{- $sel | nindent 4 }} + ports: [{ name: redis, port: 6379 }] +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: {{ $name }} + labels: {{- include "blockout.part.labels" (list $ "valkey") | nindent 4 }} +spec: + serviceName: {{ $name }} + replicas: 1 + selector: + matchLabels: {{- $sel | nindent 6 }} + template: + metadata: + labels: {{- $sel | nindent 8 }} + spec: + automountServiceAccountToken: false + securityContext: + runAsUser: 999 + runAsGroup: 999 + runAsNonRoot: true + seccompProfile: { type: RuntimeDefault } + containers: + - name: valkey + image: {{ .Values.valkey.image }} + # The password from the environment, not the command line (it would show in ps) + command: [sh, -c, 'exec valkey-server --save "" --appendonly no --maxmemory {{ .Values.valkey.maxmemory }} --maxmemory-policy noeviction --requirepass "$VALKEY_PASSWORD"'] + securityContext: { allowPrivilegeEscalation: false, readOnlyRootFilesystem: true, capabilities: { drop: [ALL] } } + env: + - name: VALKEY_PASSWORD + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-redis, key: password } } + ports: [{ name: redis, containerPort: 6379 }] + readinessProbe: + exec: { command: [sh, -c, 'REDISCLI_AUTH="$VALKEY_PASSWORD" valkey-cli ping | grep -q PONG'] } + periodSeconds: 5 + resources: {{- toYaml .Values.valkey.resources | nindent 12 }} + volumeMounts: [{ name: tmp, mountPath: /data }] + volumes: [{ name: tmp, emptyDir: {} }] +{{- end }} diff --git a/deploy/blockout/values-dev.yaml b/deploy/blockout/values-dev.yaml new file mode 100644 index 0000000..e3f53fe --- /dev/null +++ b/deploy/blockout/values-dev.yaml @@ -0,0 +1,7 @@ +# Development: only MongoDB and Valkey, for servers run from a checkout +# (npm run services forwards them). They sign in with the workspace's Keycloak. +# helm upgrade --install blockout-dev deploy/blockout -n blockout-dev --create-namespace -f deploy/blockout/values-dev.yaml +server: + enabled: false +keycloak: + enabled: false diff --git a/deploy/blockout/values-production.yaml b/deploy/blockout/values-production.yaml new file mode 100644 index 0000000..ddfcbe5 --- /dev/null +++ b/deploy/blockout/values-production.yaml @@ -0,0 +1,35 @@ +# The production cluster (M8 in the production-readiness plan): more servers, +# autoscaling, an image pinned by digest. Hostnames and the gateway are +# placeholders until that cluster exists. +replicaCount: 3 +image: + # digest: sha256:… (set by the deploy, so a tag can't move under it) + pullPolicy: IfNotPresent +publicUrl: https://blockout.example.org +httpRoute: + hostnames: [blockout.example.org] + parentRefs: + - name: gateway + namespace: gateway +keycloak: + url: https://id.example.org + httpRoute: + parentRefs: + - name: gateway + namespace: gateway +mongo: + storage: 20Gi +backup: + enabled: true + # backup.rclone (or existingSecret) at install time +resources: + requests: { cpu: 250m, memory: 256Mi } + limits: { memory: 768Mi } +autoscaling: + enabled: true + minReplicas: 3 + maxReplicas: 12 +podDisruptionBudget: + minAvailable: 2 +networkPolicy: + enabled: true diff --git a/deploy/blockout/values-workspace.yaml b/deploy/blockout/values-workspace.yaml new file mode 100644 index 0000000..996f3ea --- /dev/null +++ b/deploy/blockout/values-workspace.yaml @@ -0,0 +1,26 @@ +# The workspace cluster: the shared Cilium gateway (gateway/rts-gateway) with a +# listener and certificate per hostname (rts-terraform, platform/blockout.tf). +# Secrets are passed at install time (docs/guides/deploy.md), never kept here. +publicUrl: https://blockout.deepspacecartel.com +httpRoute: + hostnames: [blockout.deepspacecartel.com] + parentRefs: + - name: rts-gateway + namespace: gateway + sectionName: blockout-deepspacecartel-com +# Sign-in goes through the cluster's shared Keycloak (rts-terraform, +# platform/keycloak.tf), not one of this chart's own (ADR-0016). Its clients +# come from .devcontainer/keycloak-clients.json, which lists this address. +keycloak: + enabled: false + issuer: https://id.deepspacecartel.com/realms/dev +networkPolicy: + enabled: true + cilium: true +metrics: + serviceMonitor: + enabled: true + labels: { release: kube-prometheus-stack } + rules: + enabled: true + labels: { release: kube-prometheus-stack } diff --git a/deploy/blockout/values.schema.json b/deploy/blockout/values.schema.json new file mode 100644 index 0000000..02af0e8 --- /dev/null +++ b/deploy/blockout/values.schema.json @@ -0,0 +1,416 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Blockout chart values", + "type": "object", + "required": [ + "image", + "publicUrl", + "keycloak", + "mongo", + "valkey" + ], + "properties": { + "image": { + "type": "object", + "required": [ + "repository" + ], + "properties": { + "repository": { + "type": "string", + "minLength": 1 + }, + "tag": { + "type": "string" + }, + "digest": { + "type": "string", + "pattern": "^(sha256:[0-9a-f]{64})?$" + }, + "pullPolicy": { + "enum": [ + "Always", + "IfNotPresent", + "Never" + ] + } + } + }, + "imagePullSecrets": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + } + } + } + }, + "replicaCount": { + "type": "integer", + "minimum": 1 + }, + "publicUrl": { + "type": "string", + "pattern": "^(https://.+)?$", + "description": "https only; required to render" + }, + "keycloak": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "url": { + "type": "string", + "pattern": "^(https?://[^/]+/?)?$", + "description": "Keycloak's public address (bundled Keycloak)" + }, + "issuer": { + "type": "string", + "pattern": "^(https?://.+/realms/[^/]+)?$" + }, + "clientId": { + "type": "string", + "minLength": 1 + }, + "extraRedirectUrls": { + "type": "array", + "items": { + "type": "string", + "pattern": "^https?://[^*]+$" + } + }, + "google": { + "type": "object", + "properties": { + "clientSecret": { + "type": "string" + } + } + }, + "smtp": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": [ + "integer", + "string" + ] + }, + "from": { + "type": "string" + }, + "user": { + "type": "string" + }, + "password": { + "type": "string" + }, + "existingSecret": { + "type": "string" + } + } + }, + "httpRoute": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "parentRefs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ] + } + }, + "adminConsole": { + "type": "boolean" + } + } + }, + "postgres": { + "$ref": "#/$defs/store" + }, + "realmJob": { + "type": "object" + }, + "mobile": { + "type": "object", + "properties": { + "redirectUrls": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + } + } + } + }, + "trustProxy": { + "type": "integer", + "minimum": 0 + }, + "log": { + "type": "object", + "properties": { + "level": { + "enum": [ + "debug", + "info", + "warn", + "error", + "silent" + ] + }, + "format": { + "enum": [ + "json", + "text" + ] + } + } + }, + "redisPrefix": { + "type": "string" + }, + "extraEnv": { + "type": "array" + }, + "terminationGracePeriodSeconds": { + "type": "integer", + "minimum": 10 + }, + "preStopSleepSeconds": { + "type": "integer", + "minimum": 0 + }, + "httpRoute": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "hostnames": { + "type": "array", + "items": { + "type": "string" + } + }, + "parentRefs": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ] + } + } + } + }, + "autoscaling": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "minReplicas": { + "type": "integer", + "minimum": 1 + }, + "maxReplicas": { + "type": "integer", + "minimum": 1 + } + } + }, + "networkPolicy": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "cilium": { + "type": "boolean" + }, + "dataNamespace": { + "type": "string" + }, + "ingressNamespaces": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "server": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "imageCredentials": { + "type": "object", + "properties": { + "registry": { + "type": "string", + "minLength": 1 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + } + } + }, + "openai": { + "type": "object", + "properties": { + "apiKey": { + "type": "string" + }, + "existingSecret": { + "type": "string" + } + } + }, + "mongo": { + "allOf": [ + { + "$ref": "#/$defs/store" + } + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "external": { + "type": "object", + "properties": { + "secretName": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "valkey": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "image": { + "type": "string", + "minLength": 1 + }, + "maxmemory": { + "type": "string", + "pattern": "^[0-9]+[kmgKMG]?[bB]?$" + }, + "resources": { + "type": "object" + }, + "external": { + "type": "object", + "properties": { + "secretName": { + "type": "string" + }, + "key": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "backup": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "rclone": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "pattern": "^RCLONE_" + } + }, + "existingSecret": { + "type": "string" + }, + "rcloneImage": { + "type": "string", + "minLength": 1 + }, + "schedule": { + "type": "object", + "properties": { + "mongo": { + "type": "string", + "minLength": 1 + }, + "keycloak": { + "type": "string", + "minLength": 1 + }, + "restoreCheck": { + "type": "string", + "minLength": 1 + } + } + } + } + }, + "keycloakx": { + "type": "object", + "description": "The keycloakx chart's values" + } + }, + "$defs": { + "store": { + "type": "object", + "properties": { + "image": { + "type": "string", + "minLength": 1 + }, + "storage": { + "type": "string", + "pattern": "^[0-9]+(Mi|Gi|Ti)$" + }, + "storageClass": { + "type": "string" + }, + "resources": { + "type": "object" + } + } + } + } +} diff --git a/deploy/blockout/values.yaml b/deploy/blockout/values.yaml new file mode 100644 index 0000000..2770c7a --- /dev/null +++ b/deploy/blockout/values.yaml @@ -0,0 +1,308 @@ +# Blockout and everything it runs on, in one release: the server, MongoDB +# (accounts), Valkey (live classes), Keycloak (sign-in) with its Postgres, and +# their backups. Each of MongoDB, Valkey and Keycloak can be switched off to use +# one run elsewhere. +# +# Required: publicUrl; keycloak.url and keycloak.google.clientSecret (or +# keycloak.enabled: false and keycloak.issuer). Passwords the chart needs are +# made once and kept in its Secrets; the ones only you have (the Google client +# secret, image credentials, mail, backup storage) are values below, or the +# names of Secrets you made. Pass them at install time, never commit them: +# helm upgrade --install … -f values-workspace.yaml -f secrets.yaml + +# The Blockout server. enabled: false runs only the services (development). +server: + enabled: true + +image: + repository: ghcr.io/deepspacecartel/blockout + # Defaults to the chart's appVersion. A digest (sha256:…) pins the exact image. + tag: "" + digest: "" + pullPolicy: IfNotPresent + +# A private image: the chart makes a pull Secret from these (a token that can +# read packages), or name Secrets you made in imagePullSecrets +imageCredentials: + registry: ghcr.io + username: "" + password: "" +imagePullSecrets: [] + +replicaCount: 2 + +# The address people use, https only, e.g. https://blockout.example.org +publicUrl: "" + +# How many proxies sit in front of the server (the gateway): their +# X-Forwarded-For is trusted for rate limits +trustProxy: 1 + +log: + level: info + format: json + +# A prefix for every Redis key (to keep several Blockouts apart in one Redis) +redisPrefix: "" + +# Optional: OpenAI's free moderation for guests' names +openai: + apiKey: "" + # or a Secret you made, with the key api-key + existingSecret: "" + +# More environment variables, as in a container spec +extraEnv: [] + +resources: + requests: { cpu: 100m, memory: 160Mi } + limits: { memory: 512Mi } + +podSecurityContext: + runAsNonRoot: true + runAsUser: 1000 + runAsGroup: 1000 + seccompProfile: { type: RuntimeDefault } + +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: { drop: [ALL] } + +# Time to finish: streams are ended so browsers reconnect elsewhere, and +# classes are handed over (the server needs a few seconds; the gateway a few more +# to stop sending new requests) +terminationGracePeriodSeconds: 30 +preStopSleepSeconds: 5 + +podAnnotations: {} +nodeSelector: {} +tolerations: [] +affinity: {} + +service: + port: 80 + +# Gateway API: an HTTPRoute on a shared Gateway (the cluster's ingress) +httpRoute: + enabled: true + hostnames: [] + parentRefs: [] + # - name: rts-gateway + # namespace: gateway + # sectionName: blockout-example-org + # Live classes hold event streams open for hours: no request timeout + timeouts: + request: "0s" + +# Or a classic Ingress +ingress: + enabled: false + className: "" + annotations: {} + hosts: [] + # - host: blockout.example.org + tls: [] + +podDisruptionBudget: + enabled: true + minAvailable: 1 + +autoscaling: + enabled: false + minReplicas: 2 + maxReplicas: 6 + targetCPUUtilizationPercentage: 70 + +# Prometheus (the Prometheus Operator's ServiceMonitor and PrometheusRule). +# The metrics port is never on the public route. +metrics: + port: 9464 + serviceMonitor: + enabled: false + # labels the cluster's Prometheus selects monitors and rules by + labels: {} + interval: 30s + # Alert rules (seen in Prometheus and Grafana; an Alertmanager sends them on), + # with one for backups when backup.enabled + rules: + enabled: false + labels: {} + # the namespace Prometheus runs in (allowed through the network policy) + namespace: observability + +# Who may talk to the server, and whom it may talk to (Cilium: traffic from the +# gateway is the "ingress" entity, which a plain NetworkPolicy can't name) +networkPolicy: + enabled: false + # CiliumNetworkPolicy (true) or a Kubernetes NetworkPolicy (false) + cilium: true + # where MongoDB and Valkey run when they're not in this release (empty: here) + dataNamespace: "" + # Plain NetworkPolicy only: namespaces whose pods may connect (the ingress controller's) + ingressNamespaces: [] + +# MongoDB for accounts: one member of the replica set rs0 (transactions need a +# replica set, even of one). The root password and the members' key are made once. +mongo: + enabled: true + image: mongo:8.0.34 + # Hetzner volumes start at 10 GB + storage: 10Gi + storageClass: "" + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 1Gi } + # enabled: false: another MongoDB, its URL in this Secret + external: + secretName: "" + key: url + +# Valkey (Redis-compatible, BSD licensed) for live classes. Rooms are small and +# last hours, so no volume: a restart only ends the classes running at the time. +valkey: + enabled: true + image: valkey/valkey:8.1.10-alpine + maxmemory: 256mb + resources: + requests: { cpu: 50m, memory: 64Mi } + limits: { memory: 384Mi } + # enabled: false: another Redis, its URL in this Secret + external: + secretName: "" + key: url + +# Sign-in. enabled: Keycloak runs in this release (the keycloakx chart, set up +# under keycloakx below) with its own Postgres and the blockout realm. +keycloak: + enabled: true + # Keycloak's public address, e.g. https://id.example.org (enabled only) + url: "" + # The tokens' issuer. Defaults to /realms/blockout; set it to use a + # Keycloak run elsewhere (enabled: false). + issuer: "" + clientId: blockout + # More addresses sign-in may send people back to (e.g. http://localhost:8080 + # for development); publicUrl is always one + extraRedirectUrls: [] + # The phone app (apps/mobile): its own public client, blockout-mobile, whose + # tokens name "blockout" as their audience. The addresses it may come back + # to, e.g. blockout://* (empty: no app client) + mobile: + redirectUrls: [] + # The Google OAuth client's secret (its id is in files/realm-blockout.json) + google: + clientSecret: "" + # Mail, for checking addresses and resetting passwords. Without it both are off. + smtp: + host: "" + port: 587 + from: "" + user: "" + password: "" + # or a Secret you made, with the keys host, port, from, user and password + existingSecret: "" + # Its public address on the gateway: sign-in, and the admin console only with + # adminConsole (its login is the master realm's, with brute-force protection; + # without it, Keycloak still sends the console to the public address, so it + # can't be reached at all) + httpRoute: + enabled: true + adminConsole: false + parentRefs: [] + postgres: + image: postgres:17.11-alpine + storage: 10Gi + storageClass: "" + resources: + requests: { cpu: 50m, memory: 128Mi } + limits: { memory: 512Mi } + # The job that brings the realm in line after each install and upgrade + # (redirect addresses, the admin role, brute-force protection, mail); the + # realm file itself is only imported into an empty Keycloak + realmJob: + resources: + requests: { cpu: 100m, memory: 256Mi } + limits: { memory: 512Mi } + +# The keycloakx chart's own values (https://github.com/codecentric/helm-charts). +# The wiring to this chart's Secrets and ConfigMaps is here; Keycloak's address, +# database and settings come from keycloak above. +keycloakx: + image: + tag: "26.7.4" + # Production mode on plain HTTP behind the gateway; the blockout realm is + # imported on first start (an existing realm is left alone) + args: ["start", "--import-realm"] + # The client's address and https come from X-Forwarded-* (only the gateway + # and port-forwards reach the pod) + proxy: + enabled: true + mode: xforwarded + http: + relativePath: "/" + managementRelativePath: "/" + extraEnvFrom: | + - configMapRef: { name: {{ .Release.Name }}-keycloak } + extraEnv: | + - name: KC_DB_PASSWORD + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-keycloak-db, key: password } } + - name: KC_BOOTSTRAP_ADMIN_USERNAME + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-keycloak-admin, key: username } } + - name: KC_BOOTSTRAP_ADMIN_PASSWORD + valueFrom: { secretKeyRef: { name: {{ .Release.Name }}-keycloak-admin, key: password } } + - name: JAVA_OPTS_APPEND + value: -XX:MaxRAMPercentage=70 + # Secrets the realm refers to as ${vault.} are files named _: + # the Google client secret is blockout_google-secret + extraVolumes: | + - name: realm + configMap: { name: {{ .Release.Name }}-keycloak-realm } + - name: vault + secret: + secretName: {{ .Release.Name }}-keycloak-google + items: [{ key: secret, path: blockout_google-secret }] + extraVolumeMounts: | + - name: realm + mountPath: /opt/keycloak/data/import + readOnly: true + - name: vault + mountPath: /opt/keycloak/vault + readOnly: true + resources: + requests: { cpu: 250m, memory: 768Mi } + limits: { memory: 1Gi } + # The restricted Pod Security profile (Keycloak writes under /opt/keycloak/data, + # so the root filesystem stays writable) + podSecurityContext: + fsGroup: 1000 + seccompProfile: { type: RuntimeDefault } + securityContext: + runAsUser: 1000 + runAsNonRoot: true + allowPrivilegeEscalation: false + capabilities: { drop: [ALL] } + +# Nightly backups of MongoDB and Keycloak's Postgres: dumped, encrypted by +# rclone's crypt remote before they leave the cluster, and copied to object +# storage: daily/ (kept 31 days) and, on the 1st, monthly/ (kept a year). A +# monthly restore check restores the newest MongoDB backup into a scratch +# database, counts what's in it, and drops it. See docs/guides/operate.md. +backup: + enabled: false + # rclone's settings as environment variables (a remote "store" and a "crypt" + # remote over it), e.g. + # RCLONE_CONFIG_STORE_TYPE: s3 + # RCLONE_CONFIG_STORE_PROVIDER: … RCLONE_CONFIG_STORE_ENDPOINT: … + # RCLONE_CONFIG_STORE_ACCESS_KEY_ID: … RCLONE_CONFIG_STORE_SECRET_ACCESS_KEY: … + # RCLONE_CONFIG_CRYPT_TYPE: crypt RCLONE_CONFIG_CRYPT_REMOTE: store:/blockout + # RCLONE_CONFIG_CRYPT_PASSWORD: (keep the plain password: restores need it) + rclone: {} + # or a Secret you made with those keys + existingSecret: "" + rcloneImage: rclone/rclone:1.75.2@sha256:2687085f718d3c628f7fdfb77c52a1d344332aed543110bb88088f2c70d43eb5 + schedule: + mongo: "17 3 * * *" + keycloak: "47 3 * * *" + # 04:37 on the 2nd of each month (after the 1st's monthly copy) + restoreCheck: "37 4 2 * *" diff --git a/docs/assets/screenshots/admin-accounts.png b/docs/assets/screenshots/admin-accounts.png new file mode 100644 index 0000000..659b5ea Binary files /dev/null and b/docs/assets/screenshots/admin-accounts.png differ diff --git a/docs/assets/screenshots/classroom-host-setup.png b/docs/assets/screenshots/classroom-host-setup.png new file mode 100644 index 0000000..265a4dd Binary files /dev/null and b/docs/assets/screenshots/classroom-host-setup.png differ diff --git a/docs/assets/screenshots/classroom-join.png b/docs/assets/screenshots/classroom-join.png new file mode 100644 index 0000000..3a84492 Binary files /dev/null and b/docs/assets/screenshots/classroom-join.png differ diff --git a/docs/assets/screenshots/classroom-lobby.png b/docs/assets/screenshots/classroom-lobby.png new file mode 100644 index 0000000..a3c5fdf Binary files /dev/null and b/docs/assets/screenshots/classroom-lobby.png differ diff --git a/docs/assets/screenshots/classroom-pairs-live.png b/docs/assets/screenshots/classroom-pairs-live.png new file mode 100644 index 0000000..f05db69 Binary files /dev/null and b/docs/assets/screenshots/classroom-pairs-live.png differ diff --git a/docs/assets/screenshots/classroom-student.png b/docs/assets/screenshots/classroom-student.png new file mode 100644 index 0000000..fdfc045 Binary files /dev/null and b/docs/assets/screenshots/classroom-student.png differ diff --git a/docs/assets/screenshots/classroom-teacher.png b/docs/assets/screenshots/classroom-teacher.png new file mode 100644 index 0000000..94b24f6 Binary files /dev/null and b/docs/assets/screenshots/classroom-teacher.png differ diff --git a/docs/assets/screenshots/demos-all.png b/docs/assets/screenshots/demos-all.png new file mode 100644 index 0000000..b4b59f1 Binary files /dev/null and b/docs/assets/screenshots/demos-all.png differ diff --git a/docs/assets/screenshots/demos-hub.png b/docs/assets/screenshots/demos-hub.png new file mode 100644 index 0000000..28e7e8e Binary files /dev/null and b/docs/assets/screenshots/demos-hub.png differ diff --git a/docs/assets/screenshots/fraction-game.png b/docs/assets/screenshots/fraction-game.png new file mode 100644 index 0000000..f32e632 Binary files /dev/null and b/docs/assets/screenshots/fraction-game.png differ diff --git a/docs/assets/screenshots/main-menu.png b/docs/assets/screenshots/main-menu.png new file mode 100644 index 0000000..80328fa Binary files /dev/null and b/docs/assets/screenshots/main-menu.png differ diff --git a/docs/assets/screenshots/more-games.png b/docs/assets/screenshots/more-games.png new file mode 100644 index 0000000..049982b Binary files /dev/null and b/docs/assets/screenshots/more-games.png differ diff --git a/docs/assets/screenshots/number-snake-add.png b/docs/assets/screenshots/number-snake-add.png new file mode 100644 index 0000000..3ca9b45 Binary files /dev/null and b/docs/assets/screenshots/number-snake-add.png differ diff --git a/docs/assets/screenshots/number-snake.png b/docs/assets/screenshots/number-snake.png new file mode 100644 index 0000000..aa2e362 Binary files /dev/null and b/docs/assets/screenshots/number-snake.png differ diff --git a/docs/assets/screenshots/other-game-menu.png b/docs/assets/screenshots/other-game-menu.png new file mode 100644 index 0000000..e9487be Binary files /dev/null and b/docs/assets/screenshots/other-game-menu.png differ diff --git a/docs/assets/screenshots/other-game-shop.png b/docs/assets/screenshots/other-game-shop.png new file mode 100644 index 0000000..bc2346b Binary files /dev/null and b/docs/assets/screenshots/other-game-shop.png differ diff --git a/docs/assets/screenshots/other-game-stats.png b/docs/assets/screenshots/other-game-stats.png new file mode 100644 index 0000000..b1e158b Binary files /dev/null and b/docs/assets/screenshots/other-game-stats.png differ diff --git a/docs/assets/screenshots/other-game-win.png b/docs/assets/screenshots/other-game-win.png new file mode 100644 index 0000000..f315504 Binary files /dev/null and b/docs/assets/screenshots/other-game-win.png differ diff --git a/docs/assets/screenshots/parent-my-kids.png b/docs/assets/screenshots/parent-my-kids.png new file mode 100644 index 0000000..1a5e0fb Binary files /dev/null and b/docs/assets/screenshots/parent-my-kids.png differ diff --git a/docs/assets/screenshots/practice-menu.png b/docs/assets/screenshots/practice-menu.png new file mode 100644 index 0000000..d7b1c14 Binary files /dev/null and b/docs/assets/screenshots/practice-menu.png differ diff --git a/docs/assets/screenshots/practice.png b/docs/assets/screenshots/practice.png new file mode 100644 index 0000000..84c9509 Binary files /dev/null and b/docs/assets/screenshots/practice.png differ diff --git a/docs/assets/screenshots/results.png b/docs/assets/screenshots/results.png new file mode 100644 index 0000000..910c04d Binary files /dev/null and b/docs/assets/screenshots/results.png differ diff --git a/docs/assets/screenshots/shop.png b/docs/assets/screenshots/shop.png new file mode 100644 index 0000000..c4af7b4 Binary files /dev/null and b/docs/assets/screenshots/shop.png differ diff --git a/docs/assets/screenshots/sign-in.png b/docs/assets/screenshots/sign-in.png new file mode 100644 index 0000000..92bf6c5 Binary files /dev/null and b/docs/assets/screenshots/sign-in.png differ diff --git a/docs/assets/screenshots/single-player.png b/docs/assets/screenshots/single-player.png new file mode 100644 index 0000000..73fedec Binary files /dev/null and b/docs/assets/screenshots/single-player.png differ diff --git a/docs/assets/screenshots/stats.png b/docs/assets/screenshots/stats.png new file mode 100644 index 0000000..7cbd13d Binary files /dev/null and b/docs/assets/screenshots/stats.png differ diff --git a/docs/assets/screenshots/student-homework.png b/docs/assets/screenshots/student-homework.png new file mode 100644 index 0000000..d44f926 Binary files /dev/null and b/docs/assets/screenshots/student-homework.png differ diff --git a/docs/assets/screenshots/student-menu.png b/docs/assets/screenshots/student-menu.png new file mode 100644 index 0000000..cfd9ce1 Binary files /dev/null and b/docs/assets/screenshots/student-menu.png differ diff --git a/docs/assets/screenshots/teacher-grown-ups.png b/docs/assets/screenshots/teacher-grown-ups.png new file mode 100644 index 0000000..900977f Binary files /dev/null and b/docs/assets/screenshots/teacher-grown-ups.png differ diff --git a/docs/assets/screenshots/teacher-my-classes.png b/docs/assets/screenshots/teacher-my-classes.png new file mode 100644 index 0000000..1cc7faf Binary files /dev/null and b/docs/assets/screenshots/teacher-my-classes.png differ diff --git a/docs/assets/screenshots/teacher-student.png b/docs/assets/screenshots/teacher-student.png new file mode 100644 index 0000000..13559c0 Binary files /dev/null and b/docs/assets/screenshots/teacher-student.png differ diff --git a/docs/assets/screenshots/wardrobe.png b/docs/assets/screenshots/wardrobe.png new file mode 100644 index 0000000..bed8909 Binary files /dev/null and b/docs/assets/screenshots/wardrobe.png differ diff --git a/docs/concepts/architecture.md b/docs/concepts/architecture.md new file mode 100644 index 0000000..6eb50f8 --- /dev/null +++ b/docs/concepts/architecture.md @@ -0,0 +1,56 @@ +Architecture + +# Architecture + +Blockout is a [Turborepo](https://turbo.build/) of plain-JavaScript ES modules: one Vite app with every web page, the phone app, shared packages, and one Node server. Why it's built this way: [ADR-0001](../decisions/0001-turborepo-monorepo.md) and [ADR-0015](../decisions/0015-one-web-app.md). + +```mermaid +flowchart LR + subgraph Browser["apps/web"] + M["the main game (/)"] + G["addition … fraction-divide"] + H["more-games"] + end + subgraph Packages + E["@blockout/engine"] + P["@blockout/progress"] + U["@blockout/ui"] + K["@blockout/game-kit"] + C["@blockout/classroom"] + A["@blockout/auth"] + end + S["apps/server"] + DB[("data/blockout.db")] + M --> E & P & U & C & A + G --> K --> E & U + H --> E + C -- "JSON, SSE / long poll" --> S + A -- "bearer token" --> S + S --> E & P + S --> DB +``` + +## The pieces + +| Piece | Job | +|---|---| +| `apps/web` | Every web page, one Vite app: `index.html` and `/index.html`, each page's code in `src//`. | +| `apps/web/src/multiplication` | The main game. `game/*.js` is the UI, one module per area (board and turns, practice, shop, Wardrobe, classroom screens…); `accounts.js` is the account screens. | +| `apps/web//` | The other games: pages that `src/operation.js` starts with `startGame()` from `@blockout/game-kit`, for the game in ``. | +| `apps/server` | Serves the built pages (`apps/web/dist`: multiplication at `/`, the others at `//`), runs live classes, and the accounts API. Node built-ins plus the MongoDB and Redis clients. | +| `@blockout/engine` | Pure game rules: board, placement, dice (including the weighted "pair dice" of Tricky, Master and Legend) and the CPU; the other operations' rules. | +| `@blockout/progress` | Pure rules for everything earned: points and bonuses, the shop's unlock chains, achievements, fact stats, homework goals, cheats and save migrations. The server uses it too. | +| `@blockout/ui` | The shared look (CSS, icons) and a Vite plugin that adds the theme and icons to every page. | +| `@blockout/classroom`, `@blockout/auth` | Browser clients for live classes and for signing in. | + +## Live classes + +A class is kept in Redis (Valkey) while it runs, so several servers can share it and a restart doesn't end it ([ADR-0007](../decisions/0007-live-classes-in-redis.md)): every change is a read-change-write that tries again if another server wrote first, one server runs each class's timers, and the others take over if it goes away (`live.js`). Every browser gets its own view of it over Server-Sent Events from whichever server it reached; where a proxy holds event streams back (Cloudflare quick tunnels, some school filters), the page switches to long polling after a few seconds. Room rules (`rooms.js`, `tournament.js`) are pure functions, tested without a server. + +## Accounts + +Signed-in players' data is in MongoDB ([ADR-0006](../decisions/0006-mongodb-and-redis.md)); sign-in goes through one provider interface, dev sign-in now and Keycloak later ([ADR-0002](../decisions/0002-swappable-sign-in.md)). A student's progress is the same save the game keeps in the browser, synced to the server: the server works out class stats and homework from it with `@blockout/progress`, so the rules are the same on both sides. + +## The game modules + +The multiplication game was one 6,300-line file. It's now split into modules that only declare things when imported and do their load-time work in `run()`, called in a fixed order ([ADR-0005](../decisions/0005-game-modules-run-phase.md)). diff --git a/docs/concepts/progression.md b/docs/concepts/progression.md new file mode 100644 index 0000000..8c0743f --- /dev/null +++ b/docs/concepts/progression.md @@ -0,0 +1,114 @@ +Progression + +# Progression: the Journeys + +An 8-year-old shouldn't have to plan a route through a skill tree. The game picks the next step instead: one **Journey** for single player and one for **Practice**. Each moves in small steps. + +## The game's Journey + +From 4×4 on, each board has three stages, and each stage asks a little more than the one before. The first boards, 1×1 to 3×3, are a game or two each, so they have one stage ([ADR-0014](../decisions/0014-first-boards-one-stage.md)): + +| Stage | To move up | +|---|---| +| 1 | Beat the CPU with **4 out of 5 answers right first try** | +| 2 | …without "Help me count" | +| 3 | Beat the CPU with **every answer right first try** | + +The boards, in order: + +| Levels | Board | Dice | +|---|---|---| +| 1–3 | 1×1, 2×2, 3×3, one stage each | 1-, 2-, 3-sided | +| 4–30 | *n*×*n* from 4×4 to 12×12, three stages each (6×6 is 10–12, 12×12 is 28–30) | *n*-sided (4 to 12) | +| 31 | 16×16 | 12-sided | +| 32 | 12×12 | Tricky | +| 33 | 20×20 | 12-sided | +| 34 | 12×12 | Master | +| 35 | 16×16 | Legend | +| 36 | 24×24 | 12-sided | + +How it plays out: +- **Warm-ups first.** The first boards are short: 1×1 is a single square. +- **The board grows with the times tables.** The 6×6 board (Level 10) is where everyone used to start, and 12×12 (Level 28) is the classic game. +- **Rewards.** Each stage passed earns 10 points × the board's size. Reaching 6×6, 12×12 and the end each earn an achievement. +- **The results say what's missing:** the win, the answers, "Help me count", or a slip on stage 3. + +## What a board unlocks + +Reaching a board's first stage unlocks the board and difficulty it uses. On the first boards it also unlocks and switches on the speed-ups. A child first learns the mechanics: rolling, drawing, watching the CPU explain. Later the game gets out of the way, so the time goes on the math. The player can switch each one back in Settings. + +| Board | Unlocks | +|---|---| +| 4×4 | Fast CPU explanations (switched on) | +| 5×5 | Auto roll (switched on) | +| 6×6 | Auto placing (switched on) | +| 7×7 | Medium; Instant CPU turns (switched on); "Press a button after CPU turns" | +| 8×8 | 8×8 | +| 9×9 | Hard | +| 10×10 | 10×10 | +| 12×12 | 12×12 | +| Levels 31–36 | 16×16, Tricky, 20×20, Master, Legend and 24×24, in that order | + +- **Custom boards** unlock after Level 36. +- **Everything that depends on a difficulty follows automatically:** its Expert games and timers, its practice, and homework. +- **Free play** opens on 6×6. It's the old menu with difficulty and board, offering only what the Journey has reached. + +## Practice's Journey + +Practice is free, and goes one times table at a time, each in five rounds: + +| Round | Questions | Mode | +|---|---|---| +| 1 | 5 | Learn | +| 2 | 10 | Learn | +| 3 | 20 | Learn | +| 4 | 10 | Expert: no picture, no "Help me count" | +| 5 | 10 | Expert with a timer | + +That makes 60 levels: ×1 is Levels 1–5, ×6 is 26–30, ×12 is 56–60. + +**The timer** gets shorter along each difficulty's tables: + +| Difficulty | Tables and timers | +|---|---| +| Easy | ×1–×2: 30 s · ×3–×4: 10 s · ×5–×6: 5 s | +| Medium | ×7: 10 s · ×8: 5 s | +| Hard | ×9: 30 s · ×10: 10 s · ×11–×12: 5 s | + +- **Moving up:** each round needs 4 out of 5 right first try; a timeout counts as missed. Each round passed earns 5 points × the table. +- **Facts go up to the table's dice,** as Practice always did: 6 for ×1–×6 (6 × 1 to 6 × 6), 8 for ×7 and ×8, 12 from ×9. +- **Review:** about one question in five reviews the tables before, with the same limit. +- **On the Journey, the round decides everything:** its length, Learn or Expert, and the timer. +- **Unlocks for Auto** come with the first round that brings them, for its difficulty: + - 10 and 20 questions, and Expert, on a difficulty's first table: ×1 for Easy, ×7 for Medium, ×9 for Hard, ×12 for the ones after Hard. + - Each timer on the first table that uses it. +- **Auto** opens on ×2: the game picks the questions from every table you've reached, in this order until the round is full: + 1. Your *facts to practice*: missed before, and not mastered since (the list Stats shows). + 2. Facts that *need practice* on your Times Table. + 3. *Learning* and *not seen yet* facts, mixed. + + Short of those, it asks them again rather than mastered facts: a mastered fact only comes up once everything you've reached is mastered. The round asks them shuffled. You pick the length, Learn or Expert and the timer from what you've unlocked; there's no table or difficulty to pick. +- **Separate from the game:** the two Journeys move on their own. + +## What's still bought + +Points buy what changes *how* you play, not *what* you play next: +- Multiplayer +- Single player's Expert and its timers (for the difficulty the level uses) +- A few settings (the board progress bar, the times-table grid's best times, Home multiplayer's answer timers), and the Wardrobe's looks + +## Older saves + +Nobody loses anything: +- **From before the Journey:** a save starts on the highest board its boards, difficulties and wins reach, and on 6×6 at least once it has played. In Practice it starts on the highest table it owns. +- **From 0.6–0.7:** a save starts on the first stage of the board or table it was on. +- **From 0.8–0.9:** a save keeps its board and stage, and its table and round. + +The decisions are [ADR-0010](../decisions/0010-journey-progression.md), [ADR-0011](../decisions/0011-practice-journey.md), [ADR-0012](../decisions/0012-journey-stages.md), [ADR-0013](../decisions/0013-practice-expert-rounds.md) and [ADR-0014](../decisions/0014-first-boards-one-stage.md). + +The rules are in `@blockout/progress`: +- `levels.js`: the ladders +- `recordLevelGame` and `recordPracticeLevel` in `progress.js` +- `journey.js`: the words and what each level plays + +The web game and the phone app both use them. diff --git a/docs/concepts/security.md b/docs/concepts/security.md new file mode 100644 index 0000000..bdce3ff --- /dev/null +++ b/docs/concepts/security.md @@ -0,0 +1,29 @@ +Security + +# Security + +What protects Blockout, and what doesn't yet, organised by the chapters of the [OWASP ASVS 5.0](https://owasp.org/www-project-application-security-verification-standard/). The target is the first level (L1): a school game with children's first names and progress in it, not a bank. + +## In place + +| Area (ASVS chapter) | How | +|---|---| +| Authentication | Sign-in is delegated to a provider ([ADR-0002](../decisions/0002-swappable-sign-in.md)). With Keycloak, passwords and Google sign-in never touch Blockout. | +| Tokens | Every API request's bearer token is verified: an HMAC signature (dev) or RS256 against the realm's keys, plus issuer, client and expiry (Keycloak). Tampered, expired or foreign tokens get `401`. | +| Authorization | Checked on the server for every request: roles (only teachers make classes or host; only students join rosters), ownership (a teacher sees only their classes; homework is removed only by whoever set it), and relationship (a student's page is visible only to linked parents and teachers of their classes). School classes only take students from that school's email domain. | +| Validation | Names, codes, goals, dates and settings are checked and cleaned on the server; request bodies have size limits. | +| Data protection | Only first name, last initial, role and email domain are stored ([ADR-0004](../decisions/0004-store-minimum-child-data.md), [Privacy](../privacy.md)). No third-party analytics or ads. | +| Errors | Errors are RFC 9457 problem details with a message for the player, never a stack trace or internal detail. | +| Browser | Responses carry `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`, a `Permissions-Policy` (no camera, microphone or location) and, over HTTPS, `Strict-Transport-Security`. A Content Security Policy allows only Blockout's own scripts, styles and images and no inline scripts or style attributes, and lets pages talk only to Blockout and the sign-in provider. | +| Live classes | The teacher's controls need the room's secret teacher key; a student's actions need their own player key. | +| Rate limits | Per address and per account ([HTTP API](../reference/HTTP-API.md)). Code guessing is limited by counting wrong codes, not tries, because a whole school often shares one address: 30 wrong codes a minute against 330,000 room codes and nearly 900 million class codes. Live-update streams give the same answer for a wrong key as for no class, so they can't be used to find codes. | +| Logging | Structured JSON logs: one record per request with the route's pattern (never the path, which can hold a code), status and time; and an audit record for every account change (`class.created`, `class.joined`, `class.student_removed`, `parent.linked`, `homework.created`, `schedule.deleted`, …) with the ids and role of who did it. Names, codes, keys and tokens are never logged. | + +## Known gaps + +| Gap | Why it matters | Plan | +|---|---|---| +| Dev sign-in lets anyone pick any role | Must never be the sign-in on a public server | Keycloak before the first real deployment ([guide](../guides/set-up-keycloak.md)) | +| Tokens are kept in `localStorage` | A cross-site scripting bug could read them | Keep the pages free of injected HTML (the game builds text with `textContent`); the Content Security Policy blocks injected scripts | +| No token revocation | A stolen token works until it expires: 15 minutes with Keycloak (renewed from a refresh token while the Keycloak session lasts), 12 hours with dev sign-in | Ending the Keycloak session (sign out) stops renewals; nothing revokes an access token early | +| Plain HTTP by default | Tokens could be read on the network | Serve over HTTPS (a tunnel already does) | diff --git a/docs/decisions/0001-turborepo-monorepo.md b/docs/decisions/0001-turborepo-monorepo.md new file mode 100644 index 0000000..ecbe5a8 --- /dev/null +++ b/docs/decisions/0001-turborepo-monorepo.md @@ -0,0 +1,27 @@ +ADR-0001 + +# ADR-0001: A Turborepo of plain-JavaScript Vite apps and shared packages + +Status: accepted +Date: 2026-09-23 + +## Context + +Blockout started as one page with no build step: `index.html`, one stylesheet and a 6,300-line `js/game.js`, with seven more games as prototypes that loaded the same stylesheet. Those games are meant to get the main game's features (wallet, shop, achievements, classroom), and code shared between pages travelled through `window.BlockoutX` globals and a fixed script order. Every new game would have copied more of `game.js`. + +## Decision + +We will organise the code as an npm-workspaces monorepo run by Turborepo: +- **Apps:** one app per game, a More games hub, and the server. +- **Packages:** shared ones for the engine, progress, UI, game kit, classroom client and sign-in client. +- **Language and build:** the code stays plain JavaScript ES modules with no framework. Each app is built with Vite; the server serves the built apps (multiplication at `/`, the others at `//`). + +### Alternatives considered +- **Keep the single page and add more script tags:** no way to share code without globals and load order, and nothing to stop the prototypes diverging. +- **TypeScript or a UI framework:** a much bigger rewrite than moving files, for code that's working and tested. It can still come later, one package at a time. + +## Consequences + +- **There's now a build step.** Opening `index.html` from disk no longer works; `npm start` builds and serves. +- **Shared code is imported explicitly** and each package has its own tests; `npm test`, `npm run lint` and `npm run build` run everything through Turborepo. +- **More moving parts:** a lockfile, Vite and Turborepo versions to keep up to date, and URLs that changed from `prototypes/*.html` to `//`. diff --git a/docs/decisions/0002-swappable-sign-in.md b/docs/decisions/0002-swappable-sign-in.md new file mode 100644 index 0000000..7f048f0 --- /dev/null +++ b/docs/decisions/0002-swappable-sign-in.md @@ -0,0 +1,26 @@ +ADR-0002 + +# ADR-0002: Sign-in behind one provider interface — dev sign-in now, Keycloak later + +Status: accepted +Date: 2026-09-23 + +## Context + +Teachers, parents and students need accounts (class rosters, stats, homework), but real registration is planned with Keycloak, which will also bring in school Google accounts (`@iusd.org` for Cadence Park). The account features need to be built and tried before Keycloak exists, and guests must always be able to play. + +## Decision + +We will check every request's bearer token through one provider interface on the server (`apps/server/src/auth.js`): +- **Both providers return the same claims:** `sub`, `given_name`, `family_name`, `email`, `roles`. `identity()` turns those claims into what we store. +- **The dev provider** issues and checks HMAC-SHA256 JWTs from a "pick a name and role" form. Its secret is kept in the database. +- **The Keycloak provider** checks RS256 JWTs against the realm's published keys (JWKS), and checks the issuer and client. The browser (`@blockout/auth`) signs in with OpenID Connect and PKCE. +- **Roles** come from the token's realm roles. With no role, a school-domain account is a student and anyone else a parent. + +## Consequences + +- **Switching to Keycloak is configuration:** `BLOCKOUT_AUTH=keycloak`, `KEYCLOAK_ISSUER` and `KEYCLOAK_CLIENT_ID`. The rest of the server never sees which provider signed a token. +- **Dev sign-in must never be enabled on a public production server,** because anyone can pick any role there (see [Security](../concepts/security.md)). +- **Not built yet:** + - Keycloak's token refresh: when a token expires, the player signs in again. + - Testing against a real Keycloak: the RS256 path has only been checked with a generated key in the unit tests. diff --git a/docs/decisions/0003-sqlite-with-node-builtin.md b/docs/decisions/0003-sqlite-with-node-builtin.md new file mode 100644 index 0000000..fc7fb65 --- /dev/null +++ b/docs/decisions/0003-sqlite-with-node-builtin.md @@ -0,0 +1,25 @@ +ADR-0003 + +# ADR-0003: Store accounts in sqlite through Node's built-in `node:sqlite` + +Status: accepted +Date: 2026-09-23 + +## Context + +Until now the server kept everything in memory, because classes only lived as long as a lesson. Accounts, rosters, synced progress, homework, parent links and scheduled tournaments have to survive restarts. The server had no npm dependencies, and it runs on one machine (a teacher's computer or a small host). + +## Decision + +We will keep account data in a single sqlite file (`data/blockout.db`, or the path in `BLOCKOUT_DB`) through Node's built-in `node:sqlite`. Everything goes through one module, `apps/server/src/store.js`, so the rest of the server never writes SQL; tests use `:memory:`. + +### Alternatives considered +- **A JSON file:** no transactions or queries, and it grows badly as progress saves pile up. +- **Postgres or another server database:** more to install and run for a single-school deployment. The store module keeps that change contained if it's ever needed. + +## Consequences + +- **No new dependencies, and backups are one file.** +- **Node 22.12 or newer** is required. +- **One server process writes the database,** so running several servers behind a load balancer would need a different store. +- **Schema changes need care:** the schema is created with `CREATE TABLE IF NOT EXISTS`. The first change to an existing table needs a proper migration step, not just an edit to `SCHEMA`. diff --git a/docs/decisions/0004-store-minimum-child-data.md b/docs/decisions/0004-store-minimum-child-data.md new file mode 100644 index 0000000..2ffad52 --- /dev/null +++ b/docs/decisions/0004-store-minimum-child-data.md @@ -0,0 +1,25 @@ +ADR-0004 + +# ADR-0004: Keep only a first name, last initial, role and email domain for each person + +Status: accepted +Date: 2026-09-23 + +## Context + +Most players are under 13 and in California schools, which brings in FERPA, COPPA and SOPIPA. Keycloak and Google sign-in will hand us full names and email addresses. The features only need to tell children apart within a class and know which school they belong to. + +## Decision + +We will store, for each person: +- the first name and last initial ("Maya K."), and the role +- the email **domain** only (to match a school) +- the sign-in provider's opaque subject ID + +Nothing else about identity: no full names, email addresses or birthdays. Signed-in students' names are not sent to OpenAI's name check. `identity()` in `apps/server/src/auth.js` is where this is enforced, and a unit test checks that the full name and address never reach the database. + +## Consequences + +- **Less to protect and less to leak.** [Privacy](../privacy.md) can promise it plainly. +- **We can't email anyone,** or tell apart two classmates with the same first name and last initial (they'd need to be told apart some other way). +- **Adding any personal field later needs a new ADR** and a privacy review. diff --git a/docs/decisions/0005-game-modules-run-phase.md b/docs/decisions/0005-game-modules-run-phase.md new file mode 100644 index 0000000..b9c9f2f --- /dev/null +++ b/docs/decisions/0005-game-modules-run-phase.md @@ -0,0 +1,26 @@ +ADR-0005 + +# ADR-0005: Game modules declare at import and do their load-time work in `run()` + +Status: accepted +Date: 2026-09-23 + +## Context + +The multiplication game was one immediately-invoked function of about 6,300 lines. Its sections shared top-level variables, and a lot of code ran at load in a particular order: element lookups, event listeners, the first render. Splitting it into ES modules could change that order, because a module's body runs when it's first imported, and the sections import each other in cycles. + +## Decision + +We will split it into one module per section (`apps/multiplication/src/game/*.js`): +- **At import, only declarations:** functions, and constants whose initialisers run nothing. +- **Load-time work goes in `export function run()`,** kept in its original order within the section. +- **`game/index.js` calls every module's `run()`** in the original section order. +- **Variables written from another module get a setter** in their home module (`setGame`, `setGameId`, `setPractice`, `setWardrobeFilter`). + +The split was done by a one-off script using a JavaScript parser and scope analysis, so every cross-module reference became an import or export mechanically. It was checked with the same Playwright pass before and after. + +## Consequences + +- **Behaviour is unchanged,** and circular imports between game modules are safe, because nothing is used before `run()`. +- **A new rule to follow:** no work at import in `src/game/*.js`. It's written down in CLAUDE.md. +- **The modules are still large,** and some are tightly coupled (`play.js` is about 1,100 lines). Splitting them further, and moving shared UI such as toasts, banners and dialogs into `@blockout/ui`, is follow-up work. diff --git a/docs/decisions/0006-mongodb-and-redis.md b/docs/decisions/0006-mongodb-and-redis.md new file mode 100644 index 0000000..54735dd --- /dev/null +++ b/docs/decisions/0006-mongodb-and-redis.md @@ -0,0 +1,39 @@ +ADR-0006 + +# ADR-0006: Keep accounts in MongoDB and live classes in Redis (Valkey), so several servers can run + +Status: accepted +Date: 2026-10-15 +Supersedes: [ADR-0003](0003-sqlite-with-node-builtin.md) + +## Context + +Blockout is moving from one server on a teacher's computer to a public service in Kubernetes. That needs: + +- **More than one server process at a time,** for rolling updates without dropping classes, and to grow with more schools. +- **Live classes that survive a restart or a deploy.** + +The sqlite file of ADR-0003 has one writer, and live classes lived in the server's memory. Neither works with two servers. + +## Decision + +- **Accounts** (users, classes, rosters, progress, homework, parent links, schedules) go in **MongoDB**, through the official `mongodb` driver. The store sits behind one async interface (`apps/server/src/store/`) with: + - a MongoDB implementation for servers + - an in-memory one for tests and quick runs + - the old sqlite store, kept to read old databases + + Every store passes the same contract tests (`apps/server/test/store.contract.js`). Schema steps are numbered migrations that one server runs while the others wait (`store/migrations.js`). +- **Live classes** and the messages between servers go in a **Redis-compatible server**, through the official `redis` client. We run **Valkey** (BSD licensed) rather than Redis 8 (AGPL/SSPL). +- **Production refuses to start without MongoDB.** + +### Alternatives considered +- **Postgres with the `pg` package:** a good fit for the data, and the cluster already runs one for Keycloak. Not chosen: the project owner chose MongoDB and Redis. +- **Postgres alone, with LISTEN/NOTIFY for live classes:** one service fewer, but live classes write several times a second, which suits an in-memory store better. +- **Sticky sessions to one server per class:** keeps classes in memory, but a deploy or a crash still ends them, and the accounts would still need a shared database. + +## Consequences + +- **The server has runtime dependencies** (`mongodb`, `redis`) for the first time. They're official clients with no native code. +- **Two more services to run and back up:** MongoDB (accounts: back it up) and Valkey (live classes, kept for hours: no backup needed). `deploy/data/` installs both in the cluster. +- **Development needs no database:** without `BLOCKOUT_MONGO_URL`, the server uses the sqlite file (or `BLOCKOUT_STORE=memory`). `npm run services` forwards the cluster's development MongoDB and Valkey. +- **Tests run without services,** and against real MongoDB and Valkey when their URLs are set (CI starts both). diff --git a/docs/decisions/0007-live-classes-in-redis.md b/docs/decisions/0007-live-classes-in-redis.md new file mode 100644 index 0000000..6971138 --- /dev/null +++ b/docs/decisions/0007-live-classes-in-redis.md @@ -0,0 +1,30 @@ +ADR-0007 + +# ADR-0007: Keep live classes in Redis (Valkey) for up to 4 hours, with one server running each class's timers + +Status: accepted +Date: 2026-10-15 + +## Context + +A live class used to live in one server's memory for the length of a lesson. [ADR-0006](0006-mongodb-and-redis.md) puts it in Redis so several servers can run and a restart doesn't end the class. That means: + +- **A guest's typed first name now leaves the server's memory.** It sits in Redis for as long as the class runs. +- **Some things a class does on its own** (the CPU's moves in pairs, "roll again when everyone's done", the next tournament round) must still happen exactly once. +- **Changes from different servers can arrive at once.** + +## Decision + +- **Every room is one JSON document in Redis**, with a version. A change reads it, changes it, and writes it back only if the version hasn't moved; otherwise it tries again. A message on the class's channel tells every server, and each sends its own streams and polls their new view. +- **One server owns each class** through a lease it renews every 5 seconds. Only the owner runs the class's timers. Each timer re-checks the class when it fires, so a timer that fires twice changes nothing the second time. When the owner stops renewing (a crash) or lets go (a shutdown), another server takes the class over within seconds. +- **Who's connected is kept beside the room,** per server and with an expiry, never inside it, so a crashed server's connections drop off by themselves. +- **A pair's move in progress** (dragging a rectangle) goes straight to the two players without a write. +- **A class is deleted 4 hours after its last change,** or as soon as the teacher closes it. Its code then stays unusable for 30 minutes. +- **Code lives in `apps/server/src/live.js`,** over a backend interface implemented for Redis (`roombackend/redis.js`, Lua scripts for the changes that must happen together) and for memory (`roombackend/memory.js`: one server, and tests where several runtimes share it). + +## Consequences + +- **Privacy:** a guest's first name, and the class's results, are in Redis (in the same cluster, with a password and no disk copy) for at most 4 hours after the class's last change. [Privacy](../privacy.md) says so. +- **A class survives a server restart, a crash and a rolling update.** Browsers reconnect to another server. +- **Timers can be up to ~5 seconds late** after a takeover, and start again from their full length. +- **Redis is needed in production.** Without `BLOCKOUT_REDIS_URL` a server keeps classes in its own memory, which only works for one server. diff --git a/docs/decisions/0008-roles-in-blockout.md b/docs/decisions/0008-roles-in-blockout.md new file mode 100644 index 0000000..b325e48 --- /dev/null +++ b/docs/decisions/0008-roles-in-blockout.md @@ -0,0 +1,38 @@ +ADR-0008 + +# ADR-0008: Blockout keeps each account's role; one-time invites change it, and only `admin` comes from Keycloak + +Status: accepted +Date: 2026-10-15 +Supersedes: the roles part of [ADR-0002](0002-swappable-sign-in.md) + +## Context + +[ADR-0002](0002-swappable-sign-in.md) took roles from the token: realm roles in Keycloak, and with none, a school-domain account was a student and anyone else a parent. With open sign-in (Google, or registering in Keycloak) that doesn't hold: + +- **Anyone could be a parent.** Every non-school account was one, and a permanent parent code was enough to follow a child. +- **Every teacher had to be set up by hand** in the Keycloak console. +- **The role changed with the token** on every request. + +The project owner wants anyone to be able to sign in as a student; admins and teachers to turn people into teachers; and teachers (and children) to invite grown-ups. + +## Decision + +- **Blockout's database keeps the role.** A new account is a student (dev sign-in can pick another, for practice). Signing in never changes it. +- **Admins come from the token:** the Keycloak realm role `admin`, checked on every request and never stored. +- **One-time, expiring invites change roles:** + - **Teacher codes** come from admins, and from teachers bringing in a colleague (10 waiting, 20 a day). They last 7 days. + - **Grown-up codes** come from the child, their teacher or an admin. They last 14 days, are tied to the child, and link the grown-up on use. + - **Codes are 8 characters,** shown once and stored as SHA-256 hashes. Using one is atomic, so a code works once even when two people try at the same moment. + - **An account in use keeps its role:** a student in a class, or anyone already linked, can't take a teacher's or a grown-up's code. +- **Admins can also change a role directly,** except to take the role off a teacher who still has classes or tournaments. +- **Permanent per-student parent codes are retired** (a MongoDB migration removes them). +- **Deleting an account removes everything that belongs to it.** Admins can delete any account, a grown-up their child's, and teachers (without classes) and grown-ups their own. + +## Consequences + +- **Self-registration is safe to turn on.** A new account can't do more than a student until someone with the right to invite says so. +- **Every promotion is traceable:** `invitedBy` on the account, and audit records for codes made, codes used, role changes and deletions. +- **Keycloak's `student`, `teacher` and `parent` realm roles mean nothing any more.** The realm file drops them; existing assignments are ignored. +- **A teacher's school still comes from their email domain.** A teacher code made for a school is a note for admins, not a setting. +- **A parent who is also a teacher needs two accounts.** That's simpler than one account with two roles. diff --git a/docs/decisions/0009-one-chart.md b/docs/decisions/0009-one-chart.md new file mode 100644 index 0000000..061d5d6 --- /dev/null +++ b/docs/decisions/0009-one-chart.md @@ -0,0 +1,28 @@ +ADR-0009 + +# ADR-0009: One Helm chart deploys Blockout with its databases and Keycloak + +Status: accepted +Date: 2026-10-16 + +## Context + +Until 0.4.1, a deployment took three steps, each run by hand: `deploy/data/install.sh` (MongoDB, Valkey and their backup, with `kubectl apply` and `kubectl exec`), `deploy/keycloak/install.sh` (Postgres, Keycloak, the realm through `kcadm.sh`, the HTTPRoute through `sed`), and the `blockout` chart, which only ran the servers and read Secrets that the scripts had made. Secrets were made with `kubectl create secret` and copied between namespaces. The cluster held objects that no release owned, so nobody could tell from Helm what was running, and a cluster rebuild needed every step again in order. + +The project owner wants nothing created by hand: one `helm install` should bring up the whole production setup, and secrets should be values. + +## Decision + +- **One chart, `blockout`, in one namespace,** installs the servers, MongoDB, Valkey, Keycloak with its Postgres, the backup CronJobs and the monthly restore check. +- **Keycloak comes from the upstream `codecentric/keycloakx` chart** as a dependency, pinned in `Chart.lock`. MongoDB, Valkey and Postgres are the chart's own small StatefulSets: there's no maintained upstream chart for them without Bitnami's paid images, and we don't want an operator for one member each. +- **What the scripts did by hand runs in the cluster:** the MongoDB replica set is started by a container beside `mongod`; the realm is brought in line by a post-install and post-upgrade hook Job. +- **Passwords are made by the chart once** (`lookup`, else random) and kept, also through `helm uninstall` (`helm.sh/resource-policy: keep`), because the data volumes outlive the release too. **Secrets only a person has** (the Google client secret, image credentials, mail, backup storage) **are values,** passed at install time from a file outside git, or the name of an existing Secret where the chart allows it. +- **Each of MongoDB, Valkey and Keycloak can be switched off** to use one run elsewhere; with all of them off and `server.enabled: false`, the same chart gives development its databases. + +## Consequences + +- **`helm upgrade --install` is the whole deploy and `helm rollback` the whole rollback;** a cluster rebuild is one command again. +- **Tools that render without a cluster** (`helm template`, Argo CD) can't look up the kept passwords and would make new ones. A GitOps setup must provide those Secrets itself. +- **The chart is bigger** (the Keycloak subchart travels with it) and its namespace holds the data, so deleting the namespace deletes the data volumes too. Removing the release alone doesn't. +- **Keycloak's settings from this chart** (its address) are read when it starts: changing `keycloak.url` or the Google secret needs a restart of `blockout-keycloakx`. +- **Production may later move to operators** (managed MongoDB, CloudNativePG) through the `external` settings, without changing the servers' side. diff --git a/docs/decisions/0010-journey-progression.md b/docs/decisions/0010-journey-progression.md new file mode 100644 index 0000000..57676ef --- /dev/null +++ b/docs/decisions/0010-journey-progression.md @@ -0,0 +1,34 @@ +ADR-0010 + +# ADR-0010: Boards and difficulties are Journey levels, not Shop items + +Status: accepted +Date: 2026-10-17 + +## Context + +Boards (8×8 up to 24×24, Custom) and difficulties (Medium up to Legend) were Shop chains. Each board also needed a win on the one before (`winOn`), and each difficulty needed most of the Times Table mastered (`masterUpTo`, `mastery`). Every player started on 6×6 with 6-sided dice. + +Children had to decide on their own where to go next. The mastery gates were invisible until met, and the menu showed every chain at once: difficulty, board, level and timer, plus locks and skeletons. + +## Decision + +Single player follows one path, the **Journey** (`packages/progress/src/levels.js`): + +- **Levels 1–12.** Level *n* is an *n*×*n* board with an *n*-sided die, so a new player starts on 1×1 with a 1-sided die. +- **Levels 13–18.** Challenges: 16×16, Tricky, 20×20, Master, Legend, 24×24. +- **Moving up.** Beat the CPU on your current level with at least 80 % of your answers right first try (`recordLevelGame`). + +Reaching a level grants the board and difficulty ids it stands for (`board8`, `difficulty.medium`, …). These ids stay in `SHOP` with a `journey` level and are never sold. Everything keyed on them keeps working: Free play, per-difficulty Expert and practice chains, homework and the classroom. + +**Free play** opens at Level 6 and offers only what the Journey has reached. + +Old saves get the `journeyV1` migration. It places a save on the highest level its unlocks or wins reach, and on Level 6 at least once it has played. It never takes anything away. + +## Consequences + +- **Menu:** single player is one card and one button ("Play Level 7"). The board and difficulty pickers appear only in Free play, and nothing locked is shown there. +- **Shop:** it sells modes, Expert, timers, practice, settings and looks. Points no longer buy boards or difficulties. +- **Removed gates:** `winOn` and `masterUpTo`/`mastery` are gone. `tableReady` stays for Master's dice and stats. +- **Engine:** it plays any die from 1 to 12 sides (`createMatch({ sides })`). Dice of 4 sides or fewer roll every face evenly. +- **Going back would need another migration:** saves now hold `journey.level`, and the granted ids are in `unlocks`. diff --git a/docs/decisions/0011-practice-journey.md b/docs/decisions/0011-practice-journey.md new file mode 100644 index 0000000..c4ea370 --- /dev/null +++ b/docs/decisions/0011-practice-journey.md @@ -0,0 +1,32 @@ +ADR-0011 + +# ADR-0011: Practice is free, and its times tables are Practice Journey levels + +Status: accepted +Date: 2026-10-17 + +## Context + +[ADR-0010](0010-journey-progression.md) put single player's boards and difficulties on a Journey. Practice kept its own shop path: +- Practice mode cost 100 points. +- Each times table (×1 to ×12) was bought one at a time. +- Picking tables, a difficulty and a length was up to the child. + +Children still had to plan what to practice next, and a child without points couldn't practice at all. + +## Decision + +Practice has its own Journey (`PRACTICE_LEVELS` in `packages/progress/src/levels.js`): +- **Levels:** Practice level *n* is the ×*n* table (*n*×1 to *n*×12, both ways round). About one question in five reviews the tables before it (`journeyQueue`). +- **Moving up:** a round with at least 80 % of its questions right first try moves you up (`recordPracticeLevel`) and unlocks the next table. +- **Free practice:** picking tables and a difficulty, as before, opens at Practice level 2 and offers only the tables reached. +- **Practice is free.** `practice` and `table1` come with every save, and `practice` and `table1`–`table12` are Practice Journey items in `SHOP` (`practiceJourney`), never sold. +- **Expert, timers and lengths:** Expert, its timers and 10 or 20 questions are still bought, per difficulty. On the Journey they follow the table's difficulty (Easy up to ×6, Medium for ×7 and ×8, Hard from ×9) once the game's Journey has opened it, and Easy until then. + +Old saves get the `practiceJourneyV1` migration: they start on the highest table they own, and nothing is taken away. + +## Consequences + +- **Menu:** Practice is a card and one button ("Practice ×7"), the same as single player. +- **Everyone can practice:** homework and today's practice can always offer a practice task. The daily game task now comes only once every table is mastered. +- **Two separate paths:** Practice and the game's Journey move independently. A child can be on ×9 in practice and Level 5 in the game. diff --git a/docs/decisions/0012-journey-stages.md b/docs/decisions/0012-journey-stages.md new file mode 100644 index 0000000..09f3bc8 --- /dev/null +++ b/docs/decisions/0012-journey-stages.md @@ -0,0 +1,34 @@ +ADR-0012 + +# ADR-0012: Each Journey level has three stages; Practice's longer rounds are stages too + +Status: accepted +Date: 2026-10-17 +Supersedes: the level shapes in [ADR-0010](0010-journey-progression.md) and [ADR-0011](0011-practice-journey.md) + +## Context + +With one level per board (18) and one per table (12), each step up was a big jump. A child could pass 7×7 on one lucky game and be on 8×8 next. Practice's 10- and 20-question rounds were still Shop items, one more choice on the menu. + +ADR-0011 also had Practice levels ask every table up to × 12, so ×6 asked 3 × 12. Practice had always gone up to its difficulty's dice. + +## Decision + +**The game's Journey: 42 levels.** Each board from 1×1 to 12×12 has three stages, and each stage asks a little more than the one before: +1. Beat the CPU with 4 out of 5 answers right first try. +2. The same, without "Help me count". +3. The same, and claim more than half the board. + +The six challenges (16×16, Tricky, 20×20, Master, Legend, 24×24) are one level each. A board's unlocks come with its first stage, and each stage passed pays 10 points × the board's size. + +**Practice's Journey: 36 levels.** Each table ×1 to ×12 has three rounds: 5, then 10, then 20 questions. Each needs 4 out of 5 right first try. +- **Facts:** they go up to the table's dice: 6 for ×1–×6, 8 for ×7–×8, 12 from ×9. Review questions follow the same limit. +- **Longer rounds:** reaching a table's second and third rounds unlocks 10- and 20-question rounds for Free practice. Easy's come on ×1, Medium's on ×7, Hard's on ×9, and the later difficulties' on ×12. These items leave the Shop. + +**Migrations.** Saves from 0.6–0.7 move to the first stage of the board or table they were on (`journeyV2`, `practiceJourneyV2`), and nothing is taken away. The goals live in `levelResult` and the shapes in `levels.js` (`STEPS`, `LEVELS`, `PRACTICE_LEVELS`). + +## Consequences + +- **Smaller steps:** more of them, each with a goal a child can see ("now without Help me count"). +- **The early boards are short:** 1×1 and 2×2 have three stages too, a few seconds each. +- **Shop:** it no longer sells round lengths, only Expert and its timers among the Practice items. diff --git a/docs/decisions/0013-practice-expert-rounds.md b/docs/decisions/0013-practice-expert-rounds.md new file mode 100644 index 0000000..6cf6e39 --- /dev/null +++ b/docs/decisions/0013-practice-expert-rounds.md @@ -0,0 +1,48 @@ +ADR-0013 + +# ADR-0013: Practice's Journey has Expert rounds, and Expert practice and its timers are reached, not bought + +Status: accepted +Date: 2026-10-17 +Supersedes: the Practice rounds in [ADR-0012](0012-journey-stages.md) + +## Context + +[ADR-0012](0012-journey-stages.md) gave each times table three rounds of 5, 10 and 20 questions, all in Learn. Expert practice (no picture, no "Help me count", double bonus points) and its answer timers were still Shop items. A child who never bought them never answered without help or against the clock. + +## Decision + +Each table has five rounds: + +| Round | Questions | Mode | +|---|---|---| +| 1 | 5 | Learn | +| 2 | 10 | Learn | +| 3 | 20 | Learn | +| 4 | 10 | Expert | +| 5 | 10 | Expert with a timer | + +That makes 60 Practice levels. Each round needs 4 out of 5 right first try, and a timeout counts as missed. + +**The timer** gets shorter along each difficulty's tables (`TIMERS` in `levels.js`): + +| Difficulty | Tables and timers | +|---|---| +| Easy | ×1–×2: 30 s · ×3–×4: 10 s · ×5–×6: 5 s | +| Medium | ×7: 10 s · ×8: 5 s | +| Hard | ×9: 30 s · ×10: 10 s · ×11–×12: 5 s | + +**On the Journey,** the round decides Learn or Expert and the timer, so the menu hides those choices. + +**For Free practice,** each item is unlocked by the first round that brings it, for its difficulty: +- **Expert** comes on round 4 of a difficulty's first table: ×1 for Easy, ×7 for Medium, ×9 for Hard, ×12 for the difficulties after Hard. +- **Each timer** comes on round 5 of the first table that uses it, together with the longer timers if they haven't come yet. The difficulties after Hard get all three on ×12. +- **None of these are sold** any more. + +Saves from 0.8 keep their table and round (`practiceJourneyV3`). Anything already bought is kept. + +## Consequences + +- **Everyone practices without help and against the clock,** a little at a time, as their tables grow. +- **Practice's Journey is longer:** 60 levels. +- **The Shop is smaller:** it sells nothing for Practice. Left are multiplayer, single player's Expert and timers, settings and looks. diff --git a/docs/decisions/0014-first-boards-one-stage.md b/docs/decisions/0014-first-boards-one-stage.md new file mode 100644 index 0000000..a4a142c --- /dev/null +++ b/docs/decisions/0014-first-boards-one-stage.md @@ -0,0 +1,27 @@ +ADR-0014 + +# ADR-0014: The first boards have one stage, and stage 3 asks for every answer right first try + +Status: accepted +Date: 2026-10-17 +Supersedes: the stages in [ADR-0012](0012-journey-stages.md): those of 1×1 to 3×3, and stage 3's goal + +## Context + +[ADR-0012](0012-journey-stages.md) gave every board from 1×1 to 12×12 three stages, the third being "claim more than half the board". In play that didn't work: +- **1×1** is one square, played three times over. +- **More than half the board was mostly luck.** One CPU rectangle can be half of 2×2. On bigger boards, the squares you end up with depend on the dice and on whether the board fills. +- **A win wasn't enough.** Points include bonuses, so players won and still fell short. They got stuck on "So close! Now claim more than half the board" (reported on 4×4, Level 12 in 0.9.0). + +## Decision + +- **First boards:** 1×1, 2×2 and 3×3 have one stage each: beat the CPU with 4 out of 5 answers right first try. The three stages start on 4×4 (`STAGES_FROM` in `levels.js`), so the game's Journey has 36 levels. +- **Stage 3:** beat the CPU with **every answer right first try**. That's skill, not the dice. A helped answer isn't first try, so it also covers stage 2's rule. + +Saves from 0.8–0.9 keep their board and stage (`journeyV3`). On 1×1 to 3×3, any stage becomes that board's one stage. + +## Consequences + +- **A quicker start:** a new player reaches 4×4 in three games. +- **Level numbers move:** 6×6 now starts at Level 10, and 12×12 at Level 28. +- **Stage 3 is strict:** one slip means playing again. On big boards that's many answers, and the stage is meant to be the hard one. diff --git a/docs/decisions/0015-one-web-app.md b/docs/decisions/0015-one-web-app.md new file mode 100644 index 0000000..172ee88 --- /dev/null +++ b/docs/decisions/0015-one-web-app.md @@ -0,0 +1,35 @@ +ADR-0015 + +# ADR-0015: Every browser page is one Vite app + +Status: accepted +Date: 2026-10-22 +Supersedes: "one app per game" in [ADR-0001](0001-turborepo-monorepo.md) + +## Context + +[ADR-0001](0001-turborepo-monorepo.md) made one app per game. A month later there were 15 apps, and 12 of them were web pages: +- **Seven were shells.** Each operation game was four files (`package.json`, `vite.config.js`, `index.html` and a three-line `main.js` calling `startGame('')`). Their code had already moved into `@blockout/game-kit`. +- **Thirteen dev servers.** `npm run dev` started a Vite server per page plus the server. Turborepo refuses more persistent tasks than its concurrency (10 by default), so `npm run dev` stopped working. Raising the limit would start 12 Vite servers in a 4 GiB workspace. +- **The server looked for apps at run time,** `apps//dist` for each first path segment, and the image copied every `dist` folder. + +## Decision + +We will have three apps, one per way the code runs: +- **`apps/web`:** every page a browser loads, as one Vite multi-page app. `index.html` is the multiplication game at `/`; each other page is `/index.html`, served at `//` (the seven operations, More games, Snake, Demos and Admin). Each page's code is under `src//`; the operation pages share `src/operation.js`, which reads the game from ``. +- **`apps/server`:** the server, serving `apps/web/dist` as plain static files. +- **`apps/mobile`:** the phone app (Expo). + +Shared code stays in `packages/*`, as before. + +### Alternatives considered +- **Raise Turborepo's concurrency:** fixes the error, but keeps 13 dev servers and the shells. +- **`npm run dev` only for the main game and the server:** works, but the other pages need a second command, and the shells stay. +- **Move the games into `packages/`:** packages are code shared between apps. A page's entry point isn't shared, so it would only move the problem. + +## Consequences + +- **One dev server** (`:5173`, every page) and one build. A new page is a folder with an `index.html`, not a new workspace. +- **Admin is still its own bundle:** Vite splits each page's code, so the games never load it. The server still checks every admin request. +- **The URLs don't change,** so links, bookmarks and the deployment don't notice. +- **One build for every page:** a change to one page rebuilds all of them. It takes seconds. diff --git a/docs/decisions/0016-shared-keycloak.md b/docs/decisions/0016-shared-keycloak.md new file mode 100644 index 0000000..9ba4927 --- /dev/null +++ b/docs/decisions/0016-shared-keycloak.md @@ -0,0 +1,38 @@ +ADR-0016 + +# ADR-0016: Development signs in through one shared Keycloak + +Status: accepted +Date: 2026-10-23 +Supersedes: Keycloak in the chart, for the workspace cluster, from [ADR-0009](0009-one-chart.md) + +## Context + +[ADR-0009](0009-one-chart.md) put Keycloak in Blockout's chart, with a `blockout` realm. In the workspace cluster that turned out to be the wrong owner: +- **Keycloak is the platform's, not the app's.** Other projects on the cluster need sign-in too, and each bringing its own Keycloak means one Postgres, one Google redirect address and one admin console each. +- **Coder's port URLs can't be allowed in advance.** Apps in workspaces are reached at `https://------.coder.deepspacecartel.com`. Keycloak only accepts a `*` at the end of a redirect address's path, never in the host, so each workspace's URLs must be listed one by one. +- **The cluster was rebuilt,** and Blockout's Keycloak went with it. + +Everything on deepspacecartel.com is development. Production gets its own cluster. + +## Decision + +- **One Keycloak for the cluster,** deployed by rts-terraform at `https://id.deepspacecartel.com`, with one realm, `dev`: Google sign-in and self-registration. Terraform owns only the platform part: Keycloak, the realm, Google, and a **registrar** client that may create and change clients in the realm. +- **Each project owns its clients.** Blockout describes `blockout` and `blockout-mobile` in `.devcontainer/keycloak-clients.json`. A Dev Container Feature, `keycloak-clients`, creates or updates them on every workspace start, signed in as the registrar. It also adds the workspace's Coder URLs for the ports whose `portsAttributes` label ends in `[sign-in]`, and drops those of deleted workspaces. The Coder template knows nothing about Keycloak. +- **Blockout's admins are a client role,** `admin` on the `blockout` client (`resource_access.blockout.roles`), not a realm role. Other apps in the realm can't make Blockout admins, and Blockout ignores their roles. +- **An unverified email address counts as none.** Anyone can register any address in the shared realm, and there's no mail to verify it yet. The email domain picks a school, so Blockout only uses it when the token says `email_verified`. Google accounts are verified. +- **The chart keeps its own Keycloak as an option** (`keycloak.enabled`), off in `values-workspace.yaml`. Production decides with its own cluster. + +### Alternatives considered +- **A realm per project or per workspace, linked to a hub realm with Google:** Google only needs one address that way too, but every realm adds a link to keep working, and signing out has to go through both. +- **Workspace URLs added by the Coder template:** the template would have to know about Keycloak, and about each project's ports. +- **A job in the cluster that adds every workspace's URLs:** it can't know a project's ports without a convention in the template either. +- **Allowing any redirect address on the development client:** an attacker could start a sign-in, send the link to someone, and receive their code at the attacker's own address. + +## Consequences + +- **A new app needs no platform change:** a client file and the Feature. +- **The registrar can change any client in the `dev` realm.** Any workspace can read its credentials. That's no more than what workspaces can already do with their cluster-admin access, but it means the `dev` realm is only for development. +- **A workspace can sign in only after a successful sync.** If Keycloak is down when the workspace starts, the script says so and the workspace starts anyway. Run `keycloak-clients sync` later. +- **Self-registered accounts get no school** until there's mail to verify addresses. +- **Existing admins must be granted the client role.** The old realm role doesn't count any more. diff --git a/docs/decisions/index.md b/docs/decisions/index.md new file mode 100644 index 0000000..e4d74b9 --- /dev/null +++ b/docs/decisions/index.md @@ -0,0 +1,24 @@ +Decision log + +# Decision log + +Architecture Decision Records: one hard-to-reverse decision per file, numbered, never reused. To change one, write a new ADR that supersedes it rather than editing the old one. + +| ADR | Decision | Status | +|---|---|---| +| [0001](0001-turborepo-monorepo.md) | A Turborepo of plain-JavaScript Vite apps and shared packages | accepted (one app per game superseded by [0015](0015-one-web-app.md)) | +| [0002](0002-swappable-sign-in.md) | Sign-in behind one provider interface: dev sign-in now, Keycloak later | accepted | +| [0003](0003-sqlite-with-node-builtin.md) | Store accounts in sqlite through Node's built-in `node:sqlite` | superseded by [0006](0006-mongodb-and-redis.md) | +| [0004](0004-store-minimum-child-data.md) | Keep only a first name, last initial, role and email domain for each person | accepted | +| [0005](0005-game-modules-run-phase.md) | Game modules declare at import and do their load-time work in `run()` | accepted | +| [0006](0006-mongodb-and-redis.md) | Accounts in MongoDB and live classes in Redis (Valkey), so several servers can run | accepted | +| [0007](0007-live-classes-in-redis.md) | Live classes in Redis for up to 4 hours, one server running each class's timers | accepted | +| [0008](0008-roles-in-blockout.md) | Blockout keeps each account's role; one-time invites change it, only `admin` comes from Keycloak | accepted | +| [0009](0009-one-chart.md) | One Helm chart deploys Blockout with its databases and Keycloak | accepted (Keycloak in the workspace cluster superseded by [0016](0016-shared-keycloak.md)) | +| [0010](0010-journey-progression.md) | Boards and difficulties are Journey levels, not Shop items | accepted (level shapes superseded by [0012](0012-journey-stages.md)) | +| [0011](0011-practice-journey.md) | Practice is free, and its times tables are Practice Journey levels | accepted (level shapes superseded by [0012](0012-journey-stages.md)) | +| [0012](0012-journey-stages.md) | Each Journey level has three stages; Practice's longer rounds are stages too | accepted (Practice rounds superseded by [0013](0013-practice-expert-rounds.md), the first boards' stages and stage 3's goal by [0014](0014-first-boards-one-stage.md)) | +| [0013](0013-practice-expert-rounds.md) | Practice's Journey has Expert rounds; Expert practice and its timers are reached, not bought | accepted | +| [0014](0014-first-boards-one-stage.md) | The first boards have one stage; stage 3 asks for every answer right first try | accepted | +| [0015](0015-one-web-app.md) | Every browser page is one Vite app | accepted | +| [0016](0016-shared-keycloak.md) | Development signs in through one shared Keycloak; projects create their own clients | accepted | diff --git a/docs/guides/admins.md b/docs/guides/admins.md new file mode 100644 index 0000000..6ec113d --- /dev/null +++ b/docs/guides/admins.md @@ -0,0 +1,36 @@ +Admins + +# Admins + +Admins look after the accounts: who's a teacher, removing accounts on request, and the schools Blockout knows. An admin is an account with the `blockout` client's `admin` role in Keycloak ([how to give it](set-up-keycloak.md#make-someone-an-admin)); on a practice server, tick **Admin** in the dev sign-in. + +Open **Admin** on your account page, or go to `/admin/`. + +## How roles work + +- **Everyone who signs in starts as a student.** Signing in never changes a role. +- **A teacher code** makes a student account a teacher. Admins make them, and so do teachers (for a colleague: up to 10 waiting at a time, 20 a day). +- **A grown-up's code** makes an account a child's grown-up and links them. The child makes it, or their teacher, or an admin. +- **Codes work once,** for 7 days (teachers) or 14 days (grown-ups). Blockout keeps only a scrambled copy, so a code is shown once, when it's made. +- **An account already in use keeps its role:** a student in a class, or anyone already linked to a child or a grown-up, can't take a teacher's code. They sign in with another account. + +## Accounts + +![The admin page: accounts with their roles](../assets/screenshots/admin-accounts.png) + +**Accounts** lists everyone, newest sign-ins first. Blockout only keeps a first name, a last initial and an email domain ([Privacy](../privacy.md)), so find people by first name and their school's domain. + +- **Role:** change it from the list. A teacher who still has classes or tournaments stays a teacher until those are deleted. +- **Delete:** removes the account and everything in it for good: progress, class memberships, links to grown-ups or children, the classes they teach (with those rosters, homework and tournaments), and their codes. Type `DELETE` to confirm. Use it when a school or a family asks for an account to be removed. + +## Teacher codes + +**Teacher codes** › **Make a teacher code** shows the code and its link once: send it to one teacher. The list shows every teacher code, who made it, whether it was used and by whom; **Remove** cancels an unused one. + +## Schools + +A school is matched by its students' email domain (`iusd.org`): only accounts from that domain can join its classes, and a teacher signed in with it makes classes for that school. **Add school** takes its name and domain. + +## Everything is logged + +Role changes, deletions, codes made and used, and new schools are written to the server's log as audit records, with the ids of who did what (never names). See [Security](../concepts/security.md). diff --git a/docs/guides/back-up-the-database.md b/docs/guides/back-up-the-database.md new file mode 100644 index 0000000..f875f9e --- /dev/null +++ b/docs/guides/back-up-the-database.md @@ -0,0 +1,34 @@ +Back up the database + +# Back up the database + +Accounts, classes, homework and synced progress live in MongoDB ([set up](set-up-the-databases.md)). Live classes are in Valkey and only last hours, so they aren't backed up. + +In the cluster, backups run every night by themselves, encrypted, to object storage: see [Run it day to day › Backups](operate.md#backups). This page is for a copy by hand. + +## Back up + +With `npm run services` running (or from inside the cluster), `mongodump` writes the whole database to one compressed file: + +```sh +mongodump --uri "$BLOCKOUT_MONGO_URL" --gzip --archive="backups/blockout-$(date +%F).archive.gz" +``` + +Or in the cluster, without anything installed locally: + +```sh +pw="$(kubectl -n blockout get secret blockout-mongo -o jsonpath='{.data.root-password}' | base64 -d)" +kubectl -n blockout exec blockout-mongo-0 -c mongo -- \ + mongodump -u root -p "$pw" --authenticationDatabase admin --db blockout --gzip --archive > "blockout-$(date +%F).archive.gz" +``` + +## Restore + +```sh +mongorestore --uri "$BLOCKOUT_MONGO_URL" --gzip --archive=blockout-2026-10-15.archive.gz --drop +``` + +`--drop` replaces each collection with the backup's copy. Restore into a scratch database first (`--nsFrom 'blockout.*' --nsTo 'restore_test.*'`) when you only want to look. + +!!! warning "Backups hold children's names" + Keep them as carefully as the database: encrypted, and not on a shared drive. diff --git a/docs/guides/deploy.md b/docs/guides/deploy.md new file mode 100644 index 0000000..9367d78 --- /dev/null +++ b/docs/guides/deploy.md @@ -0,0 +1,113 @@ +Deploy to a cluster + +# Deploy to a cluster + +One Helm chart, `blockout` (in `deploy/blockout`, released as `oci://ghcr.io/deepspacecartel/charts/blockout`), installs Blockout and everything it runs on into one namespace: + +- two or more identical servers +- MongoDB for accounts, and Valkey for live classes +- Keycloak for sign-in, with its Postgres +- nightly backups, and a monthly restore check + +A server can stop at any time: its classes carry on at another one. Nothing is created by hand: every object belongs to the release ([ADR-0009](../decisions/0009-one-chart.md)). + +## Before the first deploy + +1. **The gateway and DNS:** a listener (and certificate) on the cluster's shared Gateway for Blockout's hostname, and a DNS record. In the workspace cluster they're in rts-terraform (`platform/blockout.tf`, `gateway.tf`): `blockout.deepspacecartel.com`. +2. **Sign-in:** in the workspace cluster, the shared Keycloak (`id.deepspacecartel.com`, realm `dev`): nothing to set up here, since `values-workspace.yaml` turns the chart's own Keycloak off ([Sign in with Keycloak](set-up-keycloak.md)). With the chart's own Keycloak (`keycloak.enabled`), you need a hostname for it too, and a Google OAuth client (Google Cloud Console › Clients › Web application) with `https:///realms/blockout/broker/google/endpoint` among its redirect URIs. Its id is in `files/realm-blockout.json`; keep its secret at hand. +3. **A released image:** tagging `v…` publishes `ghcr.io/deepspacecartel/blockout` ([CI](../project/ci.md#releases)). The package is internal to the DeepSpaceCartel organization, so the cluster pulls it with a token that can read packages (`imageCredentials`). + +## Secrets + +Every password the chart needs inside the cluster (MongoDB, Valkey, Postgres, Keycloak's admin) is made by the chart on the first install and kept in its Secrets, also through `helm uninstall`. What only you have comes from the environment, or from a values file. + +**From 1Password (recommended):** `deploy/blockout/.env.1password` names each secret by its 1Password reference (`op:////`), so it holds no secrets and is in git. `op run` fills the environment from it, and `install.sh` hands the values to Helm on its standard input (never its command line, where `ps` would show them): + +```sh +op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh 0.11.0 +``` + +| Variable | Value | +|---|---| +| `BLOCKOUT_GHCR_USER`, `BLOCKOUT_GHCR_TOKEN` | `imageCredentials`: a token that can read packages | +| `BLOCKOUT_GOOGLE_CLIENT_SECRET` | `keycloak.google.clientSecret`: only with the chart's own Keycloak, where it's required | +| `BLOCKOUT_SMTP_HOST`, `_PORT`, `_FROM`, `_USER`, `_PASSWORD` | `keycloak.smtp`: only with the chart's own Keycloak (optional) | +| `RCLONE_CONFIG_*` | `backup.rclone`; any of them turns backups on | +| `BLOCKOUT_OPENAI_API_KEY` | `openai.apiKey` (optional) | + +Point the references at your own vault and items. The same variables work from anywhere else (a CI secret store, `export`). + +**Or a values file** that stays out of git (`secrets*.yaml` is ignored): + +```yaml +# secrets.yaml +imageCredentials: + username: + password: +# backup: +# rclone: { … } (see Run it day to day › Backups) +# keycloak: (only with the chart's own Keycloak) +# google: { clientSecret: } +# smtp: { … } (mail) +``` + +```sh +deploy/blockout/install.sh 0.11.0 -f secrets.yaml +``` + +## Deploy, update, roll back + +From a checkout (the chart as it is in the checkout, `values-workspace.yaml` by default), with the secrets from either of the above: + +```sh +op run --env-file=deploy/blockout/.env.1password -- deploy/blockout/install.sh 0.11.0 +VALUES=values-production.yaml deploy/blockout/install.sh 1.0.0 -f secrets.yaml +``` + +Or the released chart, with no checkout: + +```sh +helm upgrade --install blockout oci://ghcr.io/deepspacecartel/charts/blockout --version 0.11.0 \ + -n blockout --create-namespace -f values-workspace.yaml -f secrets.yaml --atomic --wait --timeout 15m +``` + +`--atomic --wait`: the update finishes only when every part is ready, and goes back to the previous release by itself if they don't get there. Servers are replaced one at a time, and each finishes its streams first, so classes keep going. The first install takes a few minutes (Keycloak's first start). + +To go back by hand: + +```sh +helm -n blockout history blockout +helm -n blockout rollback blockout +``` + +## What the chart sets up + +| Object | Why | +|---|---| +| Deployment `blockout` | 2 servers (3 in production), spread over nodes, read-only root filesystem, no privileges, no service account token. Probes on `/health/startup`, `/health/live` and `/health/ready`; 5 seconds' pause before stopping, 30 to finish. | +| HTTPRoute `blockout` | Blockout's hostname on the shared Gateway, with no request timeout: live classes keep event streams open for hours. | +| PodDisruptionBudget | Node maintenance never takes the last server. | +| CiliumNetworkPolicy | In: only from the gateway. Out: DNS, MongoDB and Valkey, and HTTPS (Keycloak's keys at its public address, OpenAI). | +| HorizontalPodAutoscaler | Production: 3 to 12 servers on CPU. | +| StatefulSet `blockout-mongo` | MongoDB 8.0, a replica set of one (`rs0`, started by the `replica-set` container beside it), 10 GB volume. | +| StatefulSet `blockout-valkey` | Valkey 8.1, no volume (live classes only last hours). | +| StatefulSet `blockout-keycloakx` | Keycloak 26 (the `codecentric/keycloakx` chart), production mode behind the gateway. | +| StatefulSet `blockout-keycloak-db` | Postgres 17 for Keycloak, 10 GB volume. | +| HTTPRoute `blockout-keycloak` | Keycloak's hostname: sign-in (`/realms/blockout`, `/resources`), and the admin console with `keycloak.httpRoute.adminConsole` (on in the workspace cluster). | +| Job `blockout-keycloak-realm` | After every install and upgrade: the realm's redirect addresses, the `admin` role, brute-force protection, mail. | +| CronJobs `blockout-backup-*`, `blockout-restore-check` | With `backup.enabled`: nightly encrypted dumps, and the monthly restore check ([Run it day to day](operate.md#backups)). | +| Secrets | `blockout-mongo` and `blockout-redis` (passwords and `url`), `blockout-keycloak-db`, `blockout-keycloak-admin`, `blockout-keycloak-google`, `blockout-pull`. | + +Settings are in `values.yaml` (checked by `values.schema.json`), and the ones for each cluster in `values-workspace.yaml` and `values-production.yaml`. The chart refuses to render without `publicUrl` (https), and either `keycloak.issuer` (a shared Keycloak, `keycloak.enabled: false`) or, for its own Keycloak, `keycloak.url` and the Google client secret. + +**Using databases or a Keycloak run elsewhere:** `mongo.enabled: false` with `mongo.external.secretName` (a Secret holding the URL), the same for `valkey`, and `keycloak.enabled: false` with `keycloak.issuer`. + +## Check it + +```sh +kubectl -n blockout get pods +kubectl -n blockout logs deployment/blockout | head # JSON lines: server.started, http.request, audit records +curl -s https://blockout.deepspacecartel.com/health/ready +``` + +Then make the first admin ([Switch sign-in to Keycloak › Make someone an admin](set-up-keycloak.md#make-someone-an-admin)). diff --git a/docs/guides/operate.md b/docs/guides/operate.md new file mode 100644 index 0000000..0640fe9 --- /dev/null +++ b/docs/guides/operate.md @@ -0,0 +1,95 @@ +Run it day to day + +# Run it day to day + +What to do when something happens, for whoever looks after Blockout in the cluster. Everything is in the namespace `blockout`: one Helm release, `blockout` ([Deploy to a cluster](deploy.md)). + +## Where to look + +| What | Where | +|---|---| +| Is it up? | `https:///health/ready` answers `{"status":"ready"}` | +| Logs | Grafana › Explore › Loki: `{namespace="blockout"} \| json`. Every request is an `http.request` record (route, status, time); errors are `http.error`; account changes are audit records (`class.created`, `invite.redeemed`, `role.changed`, `user.deleted`, …), by id only | +| Metrics | Grafana › Explore › Prometheus: `blockout_http_requests_total`, `blockout_http_request_duration_seconds`, `blockout_streams`, `blockout_polls`, `blockout_rooms_owned`, `blockout_rate_limited_total` | +| Alerts | Prometheus › Alerts (and Grafana): `BlockoutDown`, `BlockoutServersMissing`, `BlockoutServerErrors`, `BlockoutSlow`, `BlockoutBackupMissing`. The workspace cluster has no Alertmanager, so nothing sends them on yet | + +## Deploy, and go back + +```sh +deploy/blockout/install.sh 0.11.0 -f secrets.yaml # waits for every part; rolls back by itself if they don't get ready +helm -n blockout history blockout +helm -n blockout rollback blockout # back to an earlier one by hand +``` + +Updates replace one server at a time. Each one ends its event streams (browsers reconnect to another server) and hands its classes over, so classes keep going. Database changes run once, as the first new server starts (the others wait). + +## Backups + +With `backup.enabled`, every night `blockout-backup-mongo` dumps MongoDB, and `blockout-backup-keycloak` dumps Keycloak's Postgres. rclone encrypts each dump and copies it to object storage: `daily/` is kept 31 days and `monthly/` a year (Keycloak's under `keycloak/`). `BlockoutBackupMissing` fires when the last good MongoDB backup is more than 26 hours old. + +**Set up:** rclone's settings go in your secrets file. For S3-style storage: + +```yaml +backup: + enabled: true + rclone: + RCLONE_CONFIG_STORE_TYPE: s3 + RCLONE_CONFIG_STORE_PROVIDER: Other + RCLONE_CONFIG_STORE_ENDPOINT: https:// + RCLONE_CONFIG_STORE_ACCESS_KEY_ID: + RCLONE_CONFIG_STORE_SECRET_ACCESS_KEY: + RCLONE_CONFIG_CRYPT_TYPE: crypt + RCLONE_CONFIG_CRYPT_REMOTE: store:/blockout + RCLONE_CONFIG_CRYPT_PASSWORD: '> +``` + +Or name a Secret with those keys in `backup.existingSecret`. Keep the plain encryption password in 1Password: without it, the backups can't be read. + +**Run one now:** `kubectl -n blockout create job backup-now --from=cronjob/blockout-backup-mongo`, then `kubectl -n blockout logs job/backup-now -c upload`. + +### The monthly restore check + +`blockout-restore-check` runs on the 2nd of each month. It restores the newest backup into a scratch database, prints how many users, classes and saves it holds, and drops it again. The counts should look like the real ones: + +```sh +kubectl -n blockout get jobs +kubectl -n blockout logs job/ -c restore +``` + +To run it now: `kubectl -n blockout create job restore-now --from=cronjob/blockout-restore-check`. + +### Restore for real + +1. Stop the servers writing: `kubectl -n blockout scale deployment/blockout --replicas=0`. +2. Fetch the backup: `rclone copyto crypt:daily/blockout-.archive.gz .` (with the same settings). +3. Restore over the database: `mongorestore --uri "$BLOCKOUT_MONGO_URL" --gzip --archive=blockout-.archive.gz --drop`, with the URL from the `blockout-mongo` Secret and a port-forward to `blockout-mongo-0` (`directConnection=true`). +4. Start them again: `kubectl -n blockout scale deployment/blockout --replicas=2`. + +Keycloak: `pg_restore --clean -h … -U keycloak -d keycloak keycloak-.dump`, with `blockout-keycloakx` scaled to 0. + +## When a database is down + +- **MongoDB:** `/health/ready` reports `database` and the gateway stops sending traffic, so pages don't load. Look at `kubectl -n blockout logs blockout-mongo-0 -c mongo`. Live classes in Valkey are untouched and carry on once it's back. +- **Valkey:** live classes and rate limits stop: class requests fail, and the rest of the game still works (rate limits let requests through). Valkey keeps nothing on disk, so classes running at the time are lost: teachers start new ones. +- **Keycloak:** nobody can sign in. People already signed in keep going while their token lasts (15 minutes) and can't renew it. + +### MongoDB and the Linux kernel + +Every MongoDB 8 refuses to start on Linux kernels 6.19 to 7.0.13 ("MongoDB cannot start: Linux kernel versions 6.19 and newer has a known incompatibility", [SERVER-121912](https://jira.mongodb.org/browse/SERVER-121912)); 7.0.14 and later are fine. The workspace cluster's Talos nodes run 6.18. **Check the kernel before upgrading Talos** (or moving to new nodes): on an affected one, MongoDB stops and nobody can sign in. + +## Accounts + +- **Make someone an admin:** the `blockout` client's `admin` role in Keycloak ([how](set-up-keycloak.md#make-someone-an-admin)). +- **A request to delete a child's data** (from a school or a family): `/admin/` › Accounts › find them › **Delete**. Everything in the account goes with it. Backups keep it until they age out (31 days, or a year for the monthly ones). Say so when you answer the request. +- **Someone was made a teacher by mistake:** `/admin/` › change the role (they may need to delete their classes first). The account's `invitedBy` shows whose code it was; their codes are under **Teacher codes**. + +## Change a password or key + +The chart makes the passwords once and keeps them. To change one, change it where it's used, then in its Secret; the next upgrade keeps the new one. + +| Secret | How | +|---|---| +| MongoDB root (`blockout-mongo`) | Change it in MongoDB (`db.changeUserPassword`), update `root-password` and `url` in the Secret, restart Blockout (`kubectl -n blockout rollout restart deployment/blockout`) | +| Valkey (`blockout-redis`) | Update `password` and `url` in the Secret, restart Valkey, then Blockout | +| Keycloak admin, Google client secret | The shared Keycloak's: rts-terraform's 1Password item "Keycloak", then apply `platform` (its `docs/keycloak.md`). With the chart's own Keycloak: `blockout-keycloak-admin` in the admin console and the Secret, and the Google secret in your secrets file, then upgrade and restart Keycloak | +| Image pull token, backup storage keys, mail | Your secrets file, then upgrade | diff --git a/docs/guides/parents.md b/docs/guides/parents.md new file mode 100644 index 0000000..d9cc79d --- /dev/null +++ b/docs/guides/parents.md @@ -0,0 +1,17 @@ +Parents + +# Parents + +Follow your child's progress and set extra homework. + +1. **Get a code for your child.** Your child makes one on their account page (they tap their name at the top of the menu, then **Make a grown-up code**), or their teacher makes one for you. It works once, for 14 days. +2. **Sign in** with your own account (not your child's), and open the code's link, or type the code on your account page. +3. You're now a grown-up account: **👪 My kids** shows your child. For another child, type their code in **👪 My kids** › **Link a child**. + +![My kids: a linked child with their Times Table progress and homework](../assets/screenshots/parent-my-kids.png) + +Tap your child for their page: the Times Table (mastered, learning, needs practice), their homework from the teacher and from you, and what they've played recently. **Add homework** there sets homework just for them; it only offers what they've already unlocked. + +Your child's page also has **Delete Maya's account**: it removes the account and everything in it, for good. Your own account page has **Delete my account**. + +What's kept about your child, and who can see it: [Privacy](../privacy.md). diff --git a/docs/guides/playing.md b/docs/guides/playing.md new file mode 100644 index 0000000..56f7250 --- /dev/null +++ b/docs/guides/playing.md @@ -0,0 +1,124 @@ +Playing + +# Playing + +Everything here works without an account. Progress is saved in the browser; [signing in](#accounts) keeps it with the student instead. + +## Single player + +Pick **Single player** and tap **Play Level 1**. The [Journey](../concepts/progression.md) picks the board and the dice: it starts on a 1×1 board with a 1-sided die and grows to 12×12 with a 12-sided die, and beyond. From 4×4 on, each board has three stages: beat the CPU with 4 out of 5 answers right first try, then do it without “Help me count”, then win with every answer right first try. From the 6×6 board, **Free play** lets you pick any difficulty and board you've reached. Roll, draw the rectangle (drag it out, or let the game place it once *Auto* placing is unlocked), and answer. The CPU takes its turn and shows its working. + +![A single-player game: the board, the roll and the scores](../assets/screenshots/single-player.png) + +When the board is full, or nobody can place anything, the game ends. The results show the scores; **Show details** has the full stats, and the shop, Wardrobe and stickers are one tap away when there's something new. + +![The results: You win, the scores, and buttons to play again or open the shop](../assets/screenshots/results.png) + +## Practice + +**Practice** asks questions without a board, picking the facts that need it most. It's free and goes one times table at a time, each in five rounds: 5, 10 and 20 questions in **Learn** (with help), then 10 in **Expert** (no picture, no help, double bonus points), then 10 in Expert against a timer. Each round needs 4 out of 5 right first try. Start with **Practice ×1** and go on to ×2, and so on up to ×12. From ×2 there's also **Auto**: the game picks what you need from every table you've reached. First the facts you've missed, then the ones that need practice, then Learning and new facts mixed. You pick the length, level and timer from what you've unlocked. Longer rounds earn a finishing bonus. + +![Practice settings: difficulty, level, times tables and number of questions](../assets/screenshots/practice-menu.png) + +![A practice question with the keypad](../assets/screenshots/practice.png) + +## Difficulties + +| Difficulty | The dice | +|---|---| +| Easy | Two 6-sided dice (free) | +| Medium | 8-sided dice | +| Hard | 12-sided dice | +| Tricky | Only the harder facts: 3, 4, 6, 7, 8, 9 and 12 | +| Master | The dice go after your weakest facts | +| Legend | Half the rolls are teens like 14 × 7 (split into 10 × 7 and 4 × 7), half Tricky facts; needs a 12×12 board or bigger | + +Each difficulty unlocks when the Journey reaches it: Medium at Level 7, Hard at 9, then Tricky, Master and Legend among the challenge levels. + +## Points, shop and Wardrobe + +Right answers earn points: more for first-try answers, streaks, quick answers on a timer, and finishing bigger boards. Points buy things in the **Shop**: Expert and its timers, multiplayer, and a few settings like the board progress bar. (Boards, difficulties, times tables, practice rounds and the speed-ups, like auto roll and auto placing, come from the Journeys.) Unlocks come as a skill tree: only the next step shows its price. + +![The shop: unlocks grouped by kind, with the wallet at the bottom](../assets/screenshots/shop.png) + +The **Wardrobe** has looks: colours, dice, board themes, keypads and titles. + +![The Wardrobe](../assets/screenshots/wardrobe.png) + +**Stats** shows every game and the Times Table: which facts are mastered, still being learned, or need practice. + +![Stats: recent games and the Times Table](../assets/screenshots/stats.png) + +## Home multiplayer + +**Multiplayer → Home** is 2–4 players taking turns on one screen. (It's unlocked in the shop.) + +## Accounts + +Tap **👤 Sign in** at the top of the menu. A student's progress then follows them to any device, and the first time an account is used on a device, that device's guest progress is added to it. Signed-in students see **📚 Homework** on the menu; **Play ›** on a homework starts that game, set up and ready. At the top of Homework (for guests too) is **⭐ Today's practice**: three cards to pick from, each a small task from your own Times Table or Journey. Doing any one earns the day's reward, and new cards cost a few points. + +![A signed-in student's menu with the Homework button and a tournament banner](../assets/screenshots/student-menu.png) + +![A student's homework list with Play buttons](../assets/screenshots/student-homework.png) + +## More games + +**More games** on the main menu leads to the same roll, place and answer game for other operations: addition, subtraction, division, and adding, subtracting, multiplying and dividing fractions. Each keeps its own maths explanations ("Show me how", and the CPU's working). + +![The More games page](../assets/screenshots/more-games.png) + +Each game has: + +- **Single player**, **Home** (two players on one screen) and **Practice** (Learn, or Expert: a timer, no help and double points) +- **Points** for right answers (more for right first time and streaks), wins and finished practice rounds, in the game's own wallet +- **A shop** with its own unlock chains: Medium, then Hard; 20-question practice rounds, then Expert; Home multiplayer +- **Achievements** with the same banners as the main game, and **Stats** with every question coloured by how well it's known + +![A game's menu: its wallet, three modes, and locked Medium and Hard](../assets/screenshots/other-game-menu.png) + +![The shop: the next step of each chain, with its price](../assets/screenshots/other-game-shop.png) + +![A win: the scores, a bonus toast and an achievement banner](../assets/screenshots/other-game-win.png) + +![Stats: totals and every question by how well it's known](../assets/screenshots/other-game-stats.png) + +![Adding fractions: pieces go into fraction bars](../assets/screenshots/fraction-game.png) + +## Number Snake + +A snake game that eats numbers instead of apples (on More games). In **Answer it**, a question shows at the top (10 × 1 = ?): steer to the right answer and the snake grows; the other numbers are near misses. In **Multiples**, eat every multiple of a number to move on to the next table. In **Add it up**, every number is fair game: the one you eat is added to a running total, and the snake waits while you type the new total (or tap it on the keypad). Bigger numbers are harder sums and score more; reach the goal (30, 100 or 500) for a bonus, and the total starts again from 0. A wrong number costs one of three hearts and shows the right fact, and running into your own tail ends the game. + +Two choices on the menu make it calmer or harder: + +| Choice | Options | +|---|---| +| **Movement** | **Auto** (the default): the snake keeps going and the arrows steer it. **Manual**: it only moves when you press, one square per press, with no time pressure (Speed doesn't apply). | +| **Speed** | 🐌 Slow (the default), 🐢 Normal, 🐇 Fast. It speeds up a little with every right answer, but never past a limit for each speed. | +| **Walls** | **Stop** (the default): bump into the wall and wait for a turn, no harm done. **Wrap**: come back in on the other side. **Lose a heart**: stop, and lose a heart. **Game over**: the classic rule. | + +Unless the walls wrap, numbers never appear along the edges, so you're never lured into a wall. + +Steer with the arrow keys (or WASD), a swipe on the board, or the arrows beside it; Space pauses. Easy is ×2, ×5 and ×10; Medium ×1 to ×6; Hard ×1 to ×12, on bigger boards. In Add it up the numbers are 1–9, 5–25 or 11–99. Your best score is kept for each way to play. + +![Number Snake: eat the answer to the question](../assets/screenshots/number-snake.png) + +![Add it up: the snake waits while you give the new total](../assets/screenshots/number-snake-add.png) + +## Demos + +**Demos** on the main menu opens 26 quick demos: classic games turned into math games, to try out ideas. Each has Easy, Medium and Hard (the times tables), a score, and a message with the right fact after every mistake. + +| Group | Demos | +|---|---| +| Arcade | Times Tetris, Fact Invaders, Product Breakout, Multiple Muncher (Pac-Man), Answer Hopper (Frogger), Whack-a-Multiple, Flappy Facts, Factor Asteroids | +| Puzzle | 2048 ×2, Product Match (Match-3), Fact Pairs (Memory), Multiple Sweeper (Minesweeper), Times Cages (KenKen), Picture Products (Nonogram), Area Puzzle (Tangram) | +| Board & card | Four in a Row, Snakes & Ladders, Fact Battleship, Product War, Times Bingo, Fact Dominoes | +| Other genres | Fact Towers (tower defense), Fact Racer, Skip-Count Beats (rhythm), Multiplier Bakery (idle clicker), Jump the Answer (platformer) | + +![The Demos hub](../assets/screenshots/demos-hub.png) + +![All 26 demos](../assets/screenshots/demos-all.png) + +## Cheat codes + +Press ++grave++ (the key left of 1) for the cheat code box. Codes are secret; each one found earns a hidden achievement, and the box lists the active ones so they can be switched off. Cheats never work in class games. diff --git a/docs/guides/run-the-phone-app.md b/docs/guides/run-the-phone-app.md new file mode 100644 index 0000000..1ed5fea --- /dev/null +++ b/docs/guides/run-the-phone-app.md @@ -0,0 +1,73 @@ +Run the phone app + +# Run the phone app + +`apps/mobile` is Blockout as a React Native app, built with Expo, for phones and tablets. It's not the web page in a frame: the board, dice and keypad are native. It plays by the same rules as the web game (`@blockout/engine/match`), and keeps the same save (`@blockout/progress`), synced with the account when you sign in. + +**What's in it:** everything in the web game's multiplication, except the other games (addition, fractions and the rest): + +- you against the CPU, or 2 to 4 players on one phone; every difficulty, Expert levels and timers, boards up to 24 × 24, and drawing or auto placing +- practice, with its tables, question counts and timers +- the Shop, achievements, stickers, stats, the daily check-in, settings and the Wardrobe +- signing in with Google (through Keycloak), with the save synced with the web game +- for a student: homework, joining a class, grown-up codes, and live classes (the whole class's games, pairs and tournaments) +- for a teacher: My classes (roster, homework, planned tournaments, teacher codes) and hosting a live class +- for a grown-up: My kids; for an admin, the admin page + +The rules, words and rewards are shared with the web game (`@blockout/engine`, `@blockout/progress`), so both play the same. + +## Try it in Expo Go + +1. Install **Expo Go** on the phone, from the App Store or Google Play. The app uses Expo SDK 57, the newest one the App Store's Expo Go runs. (Expo Go runs one SDK at a time: move the app to a newer SDK only once the stores' Expo Go supports it, or a phone shows "Project is incompatible with this version of Expo Go".) +2. In the dev container, start the app with a tunnel. The phone can't reach the container directly: + ```sh + npm run tunnel --workspace @blockout/mobile + ``` +3. In a terminal, Expo shows a QR code: scan it with the phone's camera (iPhone) or with Expo Go (Android). Without a terminal, the address is the manifest's host, `exp://.exp.direct`: in Expo Go, **Enter URL manually**. + +Changes to the code reload the app on the phone by themselves. + +**An Expo account on both ends:** Expo Go only opens a project from a tunnel when the dev server signs its manifest, so sign in once with the same free account (expo.dev) on both: + +- **Expo CLI:** `npx expo login`, in `apps/mobile`. It's kept in `~/.expo/state.json`, so do it again after the dev container is rebuilt. Or set `EXPO_TOKEN` to an access token from expo.dev. +- **Expo Go:** its **Settings** tab. + +Without them, Expo Go says "You need to be signed in to Expo Go and Expo CLI to open your project". With them, the tunnel's address carries the account's name (`exp://--8081.exp.direct`). + +The "React Native DevTools" error when the dev server starts is about the desktop debugger, which needs GTK libraries the dev container doesn't have. It doesn't affect the app. + +## Signing in + +The app signs in as its own Keycloak client, **`blockout-mobile`**: public, with PKCE, in the system browser. On iOS it uses a private browser session, so a shared iPad never stays signed in. Its access tokens name `blockout` as their audience, so the server accepts them like the web game's. + +The chart sets the client up: `keycloak.mobile.redirectUrls` lists the addresses Keycloak may send the app back to. + +| Address | For | +|---|---| +| `blockout://*` | The app built with its own scheme (`app.json` › `scheme`) | +| `exp://*` | Expo Go. Its addresses change with the network, so this allows any of them: **development only**, never in production | +| `http://localhost:8081/*` | The app's web build, for automated checks | + +The app finds the server and the client in `app.json` › `extra` (`server`, `keycloakClientId`). + +## Check it without a phone + +The same code runs in a browser through `react-native-web`, so Playwright can play it: + +```sh +cd apps/mobile && npx expo export --platform web --output-dir /tmp/mobile-web +``` + +Check both a big and a small phone: 390 × 844 and 375 × 667 (iPhone SE). Nothing on the answer screen may fall below the bottom edge. + +In the web build only, `window.BLOCKOUT_FAST = true` (set before the page loads) makes every wait 20 times shorter. `window.BlockoutMobile` holds the running `match` and the `Match` functions, so a test can find where a rectangle fits and then draw it with the mouse. + +## How it's built + +| File | What | +|---|---| +| `src/App.js` | The menu or the game, the save, signing in, and the bonus and achievement banners | +| `src/Game.js` | A game: turns from `@blockout/engine/match`, on screens sized to the phone (nothing scrolls). **Roll:** big dice, then "Draw it ›", which also moves on by itself after a moment. **Board:** drawing, the CPU's turn (its rectangle grows, it explains the counting) and the results. **Answer:** the question and the keypad. **Help:** "Help me count" (or two wrong answers): the rectangle on its own, its rows lighting up with the running total, then "I know it! ›" back to the answer; the time spent counting doesn't count against the bonuses | +| `src/Board.js` | The board in SVG (`react-native-svg`); a drag (`PanResponder`) draws a rectangle | +| `src/account.js` | Signing in (`expo-auth-session`), tokens in `expo-secure-store`, the API, saving and syncing | +| `src/sync.js`, `src/rewards.js`, `src/boardMath.js` | The parts with no React Native in them, tested with `node --test` | diff --git a/docs/guides/set-up-keycloak.md b/docs/guides/set-up-keycloak.md new file mode 100644 index 0000000..71ec0af --- /dev/null +++ b/docs/guides/set-up-keycloak.md @@ -0,0 +1,86 @@ +Sign in with Keycloak + +# Sign in with Keycloak + +Keycloak replaces the practice "dev sign-in" with real accounts, and lets people sign in with Google. Guests can still play everything except hosting a class. + +In the workspace cluster, Blockout doesn't run a Keycloak of its own. It signs in through the cluster's **shared Keycloak** at `https://id.deepspacecartel.com`, in its **`dev` realm**, which other apps use too ([ADR-0016](../decisions/0016-shared-keycloak.md)). That Keycloak belongs to the platform: it's deployed by rts-terraform (`platform/keycloak.tf`, its `docs/keycloak.md`). Everything on deepspacecartel.com is development; a production cluster gets its own Keycloak. + +## What's where + +| Piece | Where | Who changes it | +|---|---|---| +| Keycloak, its Postgres, the `dev` realm, Google sign-in | rts-terraform, `platform/keycloak.tf` and `keycloak-realm.tf` | the platform | +| Blockout's clients: `blockout` (the web game, with the client role `admin`) and `blockout-mobile` (the phone app) | `.devcontainer/keycloak-clients.json` in this repository | Blockout, on every workspace start | +| Which ports' Coder URLs may sign in | `portsAttributes` labels ending in `[sign-in]` in `.devcontainer/devcontainer.json` | Blockout | +| The issuer and client the server checks | `KEYCLOAK_ISSUER` and `KEYCLOAK_CLIENT_ID` (dev container), `keycloak.issuer` (`values-workspace.yaml`) | Blockout | + +Sign-in goes Blockout → Keycloak → Google and back. Blockout only knows its Keycloak clients; only Keycloak knows the Google client. + +## How a workspace gets signed in + +Keycloak only accepts redirect addresses with a `*` at the end of the path, never in the host, so it can't allow "every Coder URL". Each workspace's own URLs are listed instead, like `https://8080--main--coffee-rooster-24--technopriest.coder.deepspacecartel.com/*`. + +The dev container's **keycloak-clients** Feature does that on every start (`.devcontainer/features/keycloak-clients`): + +1. It reads `keycloak-clients.json` and creates or updates Blockout's clients and their roles. +2. It works out this workspace's URL for each port labelled `[sign-in]` (8080 for the server, 5173 for Vite), and adds them to the `blockout` client. +3. It drops the URLs of workspaces that no longer exist. + +It signs in as the platform's registrar, whose credentials are in the cluster (Secret `keycloak/workspace-registrar`). If Keycloak can't be reached, it says why and lets the workspace start anyway. Run it by hand any time: + +```sh +keycloak-clients urls # this workspace's sign-in URLs +keycloak-clients sync # create or update the clients, add the URLs +``` + +The deployed Blockout (`https://blockout.deepspacecartel.com`) and `localhost` are in `keycloak-clients.json` itself. + +## Sign in from a development server + +The dev container already sets `KEYCLOAK_ISSUER` and `KEYCLOAK_CLIENT_ID`: + +```sh +BLOCKOUT_AUTH=keycloak npm start +``` + +Then open the server's Coder URL (or `http://localhost:8080` through VS Code's port forwarding), **Sign in** › **Sign in** › **Google**, or **Register** for an account with an email address and password. If it doesn't send you to Keycloak, check that `/api/auth/config` shows `"provider":"keycloak"`. + +**Signing out** of Blockout also signs out of Keycloak, so the next **Sign in** on a shared Chromebook asks again instead of quietly being the same child. It doesn't sign out of Google (only Google can, and then for all of Google in that browser), but Google always shows its account chooser, so picking another account is one tap. After signing out of a Google account, Blockout offers **Also sign out of Google**, which opens Google's sign-out in a new tab. It knows the account came from Google from the `identity_provider` claim that both clients add to the ID token. + +## Who can sign in + +Anyone: with Google, or by registering an email address and password. That's safe because a new account is only a student until a teacher's or a grown-up's code changes it ([ADR-0008](../decisions/0008-roles-in-blockout.md)). + +There's no mail yet, so a registered address isn't verified, and **an unverified address counts as none**: it doesn't put the account in a school, so it can't join that school's classes. Otherwise anyone could register `someone@iusd.org` and join Cadence Park. Google accounts are verified. + +## Make someone an admin + +Blockout keeps everyone's role itself: anyone who signs in is a student, and one-time codes make teachers and grown-ups ([Admins](admins.md)). Keycloak only says who's an **admin**, with the `blockout` client's role `admin`. Realm roles don't count: the realm is shared with other apps. + +1. Open the admin console, `https://id.deepspacecartel.com/admin` (user `admin`, password in 1Password, item "Keycloak"), and switch to the **dev** realm. +2. **Users** › the person › **Role mapping** › **Assign role** › switch the filter to client roles › `blockout` `admin`. +3. They sign out of Blockout and in again, and find **Admin** on their account page (or go to `/admin/`). + +They need to have signed in once first, so that Keycloak has their account. Taking the role away works the same way, and counts from their next token (15 minutes at most). + +## Change Blockout's clients + +Edit `.devcontainer/keycloak-clients.json` (Keycloak's own client fields, plus `roles` and `workspaceUrls`), then run `keycloak-clients sync`. Fields in the file are set on every sync; fields it doesn't mention are left as they are. Protocol mappers and roles are only added, never removed: remove one in the admin console. + +## When it goes wrong + +| What you see | Why, and the fix | +|---|---| +| Keycloak says **Invalid parameter: redirect_uri** | This address isn't on the `blockout` client. In a workspace: is the port's label ending in `[sign-in]`? Run `keycloak-clients sync` and read what it says. Elsewhere: add the address to `keycloak-clients.json`. | +| `keycloak-clients: skipped: …` in the workspace's start-up log | Keycloak or its registrar Secret isn't there yet (rts-terraform not applied), or Keycloak is down. Sign-in from this workspace's Coder URLs won't work until a sync succeeds. | +| Back in Blockout, "Sign-in token is from somewhere else" | `KEYCLOAK_ISSUER` isn't exactly `https://id.deepspacecartel.com/realms/dev`. | +| Back in Blockout, "Sign-in token is for another app" | `KEYCLOAK_CLIENT_ID` isn't `blockout`. | +| An admin isn't an admin | The role must be the `blockout` client's `admin`, not a realm role; then sign in again. | +| Signed out after a while | Access tokens last 15 minutes and Blockout renews them with the refresh token, until the Keycloak session ends (8 hours idle, 10 at most): then sign in again. | + +## A Keycloak of Blockout's own + +The chart can still run its own Keycloak (`keycloak.enabled: true`, with `realm-blockout.json` and its realm job), for a cluster without a shared one. [Deploy to a cluster](deploy.md) covers its settings. Whether production uses it is decided with the production cluster. + +See [ADR-0002](../decisions/0002-swappable-sign-in.md) for how sign-in is built. diff --git a/docs/guides/set-up-the-databases.md b/docs/guides/set-up-the-databases.md new file mode 100644 index 0000000..8975919 --- /dev/null +++ b/docs/guides/set-up-the-databases.md @@ -0,0 +1,62 @@ +Set up MongoDB and Valkey + +# Set up MongoDB and Valkey + +Blockout keeps accounts in MongoDB and live classes in Valkey, a Redis-compatible server ([ADR-0006](../decisions/0006-mongodb-and-redis.md)). In a deployment, the `blockout` chart runs both next to the servers ([Deploy to a cluster](deploy.md)). This guide is about the ones for development. + +You don't need them to try Blockout on your own computer: without `BLOCKOUT_MONGO_URL` and `BLOCKOUT_REDIS_URL`, `npm start` keeps accounts and live classes in memory, and forgets them when it stops. + +## What gets installed + +The same chart with `values-dev.yaml`: only MongoDB and Valkey, no servers and no Keycloak. + +| Piece | What | +|---|---| +| StatefulSet `blockout-dev-mongo` | MongoDB 8.0, a replica set of one member (`rs0`), with a password and a 10 GB volume | +| StatefulSet `blockout-dev-valkey` | Valkey 8.1 with a password, no volume (live classes only last hours) | +| Secret `blockout-dev-mongo` | the root password, the members' shared key and `url`, the connection string inside the cluster | +| Secret `blockout-dev-redis` | the password and `url` | + +The passwords are random, made once by the chart, and never stored in the repository. + +## Install + +You need `helm` with access to the cluster. + +```sh +helm repo add codecentric https://codecentric.github.io/helm-charts && helm dependency build deploy/blockout +helm upgrade --install blockout-dev deploy/blockout -n blockout-dev --create-namespace \ + -f deploy/blockout/values-dev.yaml --wait +``` + +It's safe to run again: it keeps the Secrets and the data. + +## Use them from your computer + +```sh +npm run services # release blockout-dev by default (RELEASE=…, NAMESPACE=…) +``` + +This forwards MongoDB to `localhost:27017` and Valkey to `localhost:6379`, and prints the two URLs to start Blockout with. Keep it running; Ctrl+C stops it. The URLs hold the passwords, so don't paste them anywhere public. + +The tests use the same URLs: with `BLOCKOUT_MONGO_URL` set, `npm test` also runs the store tests against MongoDB, each in a database of its own that it drops afterwards. + +## Move accounts from the sqlite file + +Before MongoDB, Blockout kept accounts in the sqlite file `data/blockout.db`. To bring them along: + +```sh +node apps/server/scripts/sqlite-to-mongo.js --dry-run # count what's there +BLOCKOUT_MONGO_URL='mongodb://…' node apps/server/scripts/sqlite-to-mongo.js [data/blockout.db] +``` + +It copies every account, save, class, link, homework and schedule by its id, so it's safe to run again, and prints the counts on both sides. Start Blockout with `BLOCKOUT_MONGO_URL` afterwards. + +## Troubleshooting + +| Problem | What to do | +|---|---| +| MongoDB isn't ready | `kubectl -n logs -mongo-0 -c replica-set` says when the replica set is up. On a first start MongoDB restarts once after creating the root user; that's fine. | +| `MongoServerSelectionError` from your computer | `npm run services` isn't running, or the URL lacks `directConnection=true` (the replica set's member name only resolves inside the cluster). | +| `BLOCKOUT_STORE=mongo needs BLOCKOUT_MONGO_URL` | Set the URL, or start without `BLOCKOUT_STORE`. | +| Accounts are gone after a restart | The server ran without `BLOCKOUT_MONGO_URL` (it says `Accounts: memory` when it starts). | diff --git a/docs/guides/share-through-a-tunnel.md b/docs/guides/share-through-a-tunnel.md new file mode 100644 index 0000000..1037803 --- /dev/null +++ b/docs/guides/share-through-a-tunnel.md @@ -0,0 +1,17 @@ +Share through a tunnel + +# Share the game through a Cloudflare tunnel + +When students' devices can't reach your computer directly. + +1. Start a quick tunnel and note the `https://….trycloudflare.com` address it prints: + ```sh + cloudflared tunnel --url http://localhost:8080 + ``` +2. Start (or restart) Blockout with that address, so join links and QR codes use it: + ```sh + npm start -- --public-url https://.trycloudflare.com + ``` +3. Check it: open the tunnel address; `…/api/health` should answer `{"ok":true,…}`. + +The address changes every time the tunnel restarts; repeat step 2 with the new one. Live classes work through the tunnel even though it buffers event streams: the game falls back to long polling after a few seconds. diff --git a/docs/guides/teachers.md b/docs/guides/teachers.md new file mode 100644 index 0000000..91ea8bb --- /dev/null +++ b/docs/guides/teachers.md @@ -0,0 +1,75 @@ +Teachers + +# Teachers + +Two ways to use Blockout with a class: **live class games** (everyone playing at once, run from your screen) and **your classes** (a class list that lasts, with stats, homework and planned tournaments). Both need a teacher sign-in; students can join live games without one. + +## Host a live class game + +**Multiplayer → Classroom → Host a class (teacher)**, then choose the kind of game: + +| Kind | How it plays | +|---|---| +| Whole class | Everyone plays the same rolls on their own board; your screen rolls and shows the leaderboard | +| Pairs | Students play in twos on a shared board, taking turns. With an odd number, you (on your screen or another device) or the CPU play the odd one out | +| Tournament | Knockout, double elimination, round robin, Swiss or king of the hill, made of short pairs games | + +![Choosing the kind of class game](../assets/screenshots/classroom-host-setup.png) + +Students join with the 4-letter code, the link, or the QR code. Every setting is free in class games, and **Make it quick** picks settings for a fast lesson. + +![The lobby: join code, address, QR code and the students who've joined](../assets/screenshots/classroom-lobby.png) + +During the game your screen shows the roll, who's done, the leaderboard and a small copy of every student's board, filling in as they play. Each student plays on their own device and sees the boards of the classmates nearest them on the leaderboard. Everyone's avatar and title from the Wardrobe shows next to their name. + +![Your screen during a game](../assets/screenshots/classroom-teacher.png) + +![A student's screen during a class game](../assets/screenshots/classroom-student.png) + +In Pairs, you watch your partner's turn as it happens: their roll, the rectangle they're placing, then a **?** on it while they work out the answer. + +![Pairs: Ann is working out 4 × 3 on her partner's screen](../assets/screenshots/classroom-pairs-live.png) + +!!! tip "Students' devices can't reach your computer?" + Share it through a tunnel: see [Share through a tunnel](share-through-a-tunnel.md). + +## Becoming a teacher + +Everyone who signs in starts as a student. To teach, you need a **teacher code**, from an admin or from a teacher who's already here: sign in, tap your name at the top of the menu, and type it under **Teachers and grown-ups** (or open the code's link). Use an account that isn't in a class yet. + +To bring in a colleague, open **🏫 My classes** › **Invite a teacher** › **Make a teacher code**. Each code works once, for 7 days; the list shows which were used, and **Remove** cancels one. + +## Your classes + +Open **🏫 My classes** on the main menu and make a class ("Room 12"). It gets a 6-character code and a join link; students sign in with their school account and type the code (or open the link) to join the class list. Any `@iusd.org` student with the code can join. + +![My classes: the join code, and the class list with each student's Times Table and homework](../assets/screenshots/teacher-my-classes.png) + +For each student you see: + +- how much of the Times Table is mastered, and which facts need practice +- when they last played, and how their homework is going +- **Grown-ups**: who follows them, and a one-time code for a new grown-up (it works once, for 14 days; take off a grown-up who shouldn't be there) + +![A student's grown-ups, with a new grown-up's code and its QR code](../assets/screenshots/teacher-grown-ups.png) + +Tap a name for their page: the full Times Table, their homework (and a form to set homework just for them) and what they've played recently. + +![A student's page](../assets/screenshots/teacher-student.png) + +## Homework + +**Add homework** sets it for the whole class: + +| Homework | Done when | +|---|---| +| Master a times table | Every ×N fact up to 10, both ways round, is mastered | +| Finish practice rounds | That many practice rounds are finished (on a difficulty, or any) | +| Beat the CPU on a board | They win on a board that big or bigger | +| Play games | That many games are finished | + +It only offers what every student in the class has already unlocked, so everything can be played straight away. Rounds, wins and games count from when the homework was set, and it ticks itself off as they play. + +## Tournaments on a schedule + +Under **Tournaments**, plan one for your class or the whole school: a name, a time, the format and the board. When it's due, the lobby opens by itself: you get **Open the lobby** in My classes, and your students see a banner on their menu with a **Join** button. diff --git a/docs/home/index.md b/docs/home/index.md new file mode 100644 index 0000000..3858cdd --- /dev/null +++ b/docs/home/index.md @@ -0,0 +1,60 @@ +Blockout + +# Blockout + +A multiplication game for 3rd graders, based on "Blockout" from [Math for Love](https://mathforlove.com/). Roll two dice, draw that rectangle on the grid, say how many squares it is, and claim the most of the board. It runs in the browser, mostly on classroom Chromebooks, and guests can always play without an account. + +![The main menu: Single player, Multiplayer and Practice, with difficulty and board size](../assets/screenshots/main-menu.png) + +## The whole idea, in one turn + +You roll a 5 and a 3. You draw a 5 × 3 rectangle anywhere it fits, touching what's already on the board, and answer **5 × 3 = 15**. Right first time earns bonus points; stuck, and **Help me count** skip-counts the rectangle with you. The CPU plays the same way and explains its own answers step by step. + +![A game in progress: the board on the left, the roll and scores on the right](../assets/screenshots/single-player.png) + +## What's in it + +
    + +- :material-account:{ .lg .middle } **Play and practice** + + --- + + Single player against the CPU, Practice that picks the facts you need most, and Home multiplayer for 2–4 on one screen. Points buy new boards, difficulties and looks. + + [:octicons-arrow-right-24: Playing](../guides/playing.md) + +- :material-school:{ .lg .middle } **Classroom** + + --- + + A whole class on the same rolls, pairs on a shared board, or tournaments, run from the teacher's screen with a join code. Teachers also get class stats, homework and scheduled tournaments. + + [:octicons-arrow-right-24: Teachers](../guides/teachers.md) + +- :material-human-male-child:{ .lg .middle } **Families** + + --- + + Parents link their child's account to see their Times Table and homework, and set extra homework. + + [:octicons-arrow-right-24: Parents](../guides/parents.md) + +- :material-puzzle:{ .lg .middle } **More games** + + --- + + The same roll, place and answer loop for addition, subtraction, division and fractions, each with its own points, shop, achievements and stats. + + [:octicons-arrow-right-24: Playing](../guides/playing.md#more-games) + +
    + +## Run it + +```console +$ npm install +$ npm start +``` + +Then open http://localhost:8080. [Quickstart](quickstart.md) walks through your first class; [Installing](../project/installing.md) has the details. diff --git a/docs/home/quickstart.md b/docs/home/quickstart.md new file mode 100644 index 0000000..6e55684 --- /dev/null +++ b/docs/home/quickstart.md @@ -0,0 +1,54 @@ +Quickstart + +# Quickstart: run your first class + +By the end you'll have hosted a whole-class game on your computer, with students joining from other browser windows. + +## 1. Start Blockout + +In the project folder: + +```console +$ npm install +$ npm start +``` + +Open http://localhost:8080. You'll see the main menu. + +## 2. Sign in as a teacher + +Tap **👤 Sign in** at the top, choose **🍎 Teacher**, type a first name and last initial, pick **Cadence Park**, and tap **Sign in**. The chip at the top now shows your name. + +![The sign-in dialog: Student, Teacher or Parent, first name and last initial](../assets/screenshots/sign-in.png){ width="460" } + +!!! note "Practice sign-in" + This is the "dev sign-in": anyone can pick any name and role. Real school sign-in comes with Keycloak (see [Sign in with Keycloak](../guides/set-up-keycloak.md)). + +## 3. Host a class + +Choose **Multiplayer → Classroom → Host a class (teacher)**, keep **Whole class**, and tap **Create**. + +![Choosing the kind of class game and its settings](../assets/screenshots/classroom-host-setup.png) + +## 4. Join as students + +Open a couple of private windows at the address on the teacher's screen. In each, choose **Multiplayer → Classroom → Join**, type the code and a first name, and tap **Join**. (A link or the QR code fills in the code for you.) + +![A student joining with the class code](../assets/screenshots/classroom-join.png) + +The names appear in the teacher's lobby: + +![The teacher's lobby with the join code, address, QR code and three students](../assets/screenshots/classroom-lobby.png) + +## 5. Play + +Tap **Start**. Each roll shows on the teacher's screen, with a leaderboard, and every student plays it on their own board. When everyone is done, the next roll comes by itself. + +![The teacher's screen during a game: the roll, who's done, and the leaderboard](../assets/screenshots/classroom-teacher.png) + +After the last roll the teacher's screen shows the podium and the facts the class found hardest. + +## Next + +- Share it with real Chromebooks: [Share through a tunnel](../guides/share-through-a-tunnel.md). +- Keep a class from lesson to lesson, set homework and plan tournaments: [Teachers](../guides/teachers.md). diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..b3cc047 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,73 @@ +Privacy + +# Blockout and children's privacy + +Blockout is used by 3rd graders, mostly under 13, starting at Cadence Park (Irvine Unified School District, California). That brings in FERPA (student education records), COPPA (children under 13 online) and California's SOPIPA (student data held by K–12 services). This page says what we keep and why. + +## Guests keep everything on their device +- Playing as a guest (the default) stores nothing on our server. Progress, stats and settings stay in the browser's local storage. +- A live class game keeps only the first name typed in. On a server with Redis (Valkey) it's kept there, with the class's results, until 4 hours after the class's last change or until the teacher closes it, so the class survives a server restart ([ADR-0007](decisions/0007-live-classes-in-redis.md)); otherwise it's in the server's memory. Either way it's gone soon after the class ends. + +## Signed-in accounts +When a student, teacher or parent signs in, the server (`apps/server/src/store/`) keeps: + +| What | Why | +|---|---| +| First name and **last initial** only | To show names to the teacher and parents ("Maya K.") | +| Role (student, teacher or parent) | To decide who can see what | +| The **email domain** only, e.g. `iusd.org` | To match a school; the address itself is never stored | +| An opaque ID from the sign-in provider | To recognise the same account next time | +| The game save: points, unlocks, which multiplication facts are mastered, recent games | Progress, class stats and homework | +| Class memberships, homework, parent links, scheduled tournaments | The features themselves | +| One-time codes (only a scrambled copy), who made them and who used them; for teachers and grown-ups, who invited them | Making teachers and linking grown-ups, and being able to trace who let whom in | + +We don't collect: full names, email addresses, birthdays, photos, location, contacts, device IDs or anything for advertising. + +## Who sees a student's data +- **The student.** +- **Teachers of a class the student joined**, through the roster and student pages. +- **Grown-ups linked with a one-time code** the student made on their account page, or their teacher made for them. A teacher can see who is linked and take off a grown-up who shouldn't be. +- **Blockout's admins** (named people with the admin role), who look after accounts and delete them on request. They see names, roles, email domains and dates, not progress. + +Nobody else sees it: not other students, other parents or other teachers. + +## No third parties +- There's no advertising and no analytics, and no student data is sold or shared. +- Signed-in students' names are not checked with OpenAI. +- The optional OpenAI name check (only when the server has `OPENAI_API_KEY`) only ever sees the first name a *guest* types to join a live class game. +- Sign-in goes through Keycloak: on deepspacecartel.com, the cluster's shared one (realm `dev`, also used by other development apps), which brokers Google sign-in. Keycloak holds the email address (and, for a Keycloak password, the password's hash); Blockout's servers never see either, only a first name, last initial and email domain from it. + +## Where it's kept +| Where | What | For how long | +|---|---|---| +| MongoDB, in the cluster Blockout runs in | Accounts and everything in the table above | Until the account is deleted, or it hasn't been used for 13 months (below) | +| Valkey (Redis), the same cluster | Live class games: first names and that game's results | 4 hours after the game's last change, or until the teacher closes it | +| Keycloak (and its Postgres), the same cluster, shared with other development apps | Sign-in: email address, name, a password hash or the Google link | Until the account is removed in Keycloak | +| Encrypted backups, in object storage | Copies of MongoDB (and of Keycloak's Postgres, when the chart runs its own Keycloak) | 31 days (daily copies), 1 year (monthly copies) | +| Server logs (the cluster's Loki) | Each request's route, status and time, and who changed what, by account id; never names, codes or tokens | As long as the cluster keeps logs | + +All of it is run by Blockout's own operators; there's no other company in between. + +## Unused accounts +An account nobody has signed in to for **13 months** (395 days: a school year and a summer) is deleted with everything in it, by a daily job. A server can change the period with `BLOCKOUT_RETENTION_DAYS`. + +## Getting a copy +- **A grown-up can download everything Blockout keeps about their child** (one JSON file) from the child's page under **👪 My kids** › **Download Maya's data**. +- **Teachers and grown-ups** download their own from their account page (**Download my data**). +- **For a school's request,** an admin can download any account's the same way (`GET /api/students/:id/export`). + +## Deleting data +- **A grown-up can delete their child's account,** with everything in it, from the child's page under **👪 My kids**. Teachers and grown-ups can delete their own accounts from their account page. +- **On request from a school or a family,** an admin deletes any account the same way ([Admins](guides/admins.md)). +- Deleting removes the account's progress, class memberships, links, homework, codes, and the classes it teaches (with their rosters, homework and tournaments). It can't be undone. Backups keep a copy until they age out (31 days, or a year for a monthly copy). +- The person's sign-in (email address) is in Keycloak: an admin removes it there too (Users › the person › Delete). + +## Asking us +A school or a family asks the school's Blockout contact for a copy or a deletion. They pass it to an admin, who does it from the admin page and confirms when it's done. (The contact's name and address go here before the first school starts.) +- Guests can clear their data from Stats → Reset stats, or by clearing the browser's site data. + +## Security +- Tokens are signed: HMAC in dev sign-in, RS256 from Keycloak. +- The databases are only reachable inside the cluster, with passwords; network policies keep everything else out. +- Codes for teachers and grown-ups are stored only as a scrambled copy (a hash), and work once. +- Deployments must use HTTPS. diff --git a/docs/project/changelog.md b/docs/project/changelog.md new file mode 100644 index 0000000..8215d05 --- /dev/null +++ b/docs/project/changelog.md @@ -0,0 +1,3 @@ +Changelog + +--8<-- "CHANGELOG.md" diff --git a/docs/project/ci.md b/docs/project/ci.md new file mode 100644 index 0000000..4eec899 --- /dev/null +++ b/docs/project/ci.md @@ -0,0 +1,33 @@ +CI + +# CI + +## Every push and pull request + +`.github/workflows/ci.yml` runs on every pull request and every push to `main` or a `v*` branch. + +| Job | What | +|---|---| +| **check** | `npm ci`, `node .devcontainer/check.mjs` (the dev container's Playwright matches the lock file), `npm run lint`, `npm test`, `npm run build`. MongoDB and Valkey run beside it, so the store and live-class tests also run against the real thing. | +| **image** | Builds the container image (`Dockerfile`), checks it refuses to start without its settings, and scans it with Trivy: a high or critical vulnerability that has a fix fails the job. | +| **chart** | Fetches the chart's dependency (`keycloakx`), runs `helm lint --strict`, and renders the chart with each cluster's values (with placeholder secrets). | +| **docs** | Builds this site with `mkdocs build --strict`, so a broken link or a page missing from the nav fails it. | + +`.github/workflows/devcontainer.yml` builds the dev container when its files change, and weekly. + +## Releases + +Pushing a tag like `v0.4.0` runs `.github/workflows/release.yml`: + +1. builds and pushes the image `ghcr.io//blockout:0.4.0` (and `:sha-…`), with an SBOM and build provenance, and a signed attestation (`gh attestation verify oci://ghcr.io//blockout:0.4.0 --owner `) +2. packages the Helm chart and pushes it to `oci://ghcr.io//charts/blockout` +3. makes a GitHub release with that version's section of `CHANGELOG.md` + +Before tagging, move the `[Unreleased]` changes in `CHANGELOG.md` under the new version, and set the version in the `package.json` files and `deploy/blockout/Chart.yaml` (SemVer). + +## How it's locked down + +- **Minimal permissions:** workflows get `contents: read`; only the release job can write packages, releases and attestations. Checkout doesn't keep the token (`persist-credentials: false`). +- **No script injection:** values from the event (tag names, the actor) reach shell steps as environment variables, never pasted into the script, and a release tag must look like a version. +- **Pinned actions:** actions are pinned to full commit SHAs, with the version in a comment. Dependabot (`.github/dependabot.yml`) updates them weekly, along with the npm packages, the dev container, the images in `Dockerfile` and `deploy/`, and the chart. +- **One run per branch:** a newer push cancels the older run. diff --git a/docs/project/installing.md b/docs/project/installing.md new file mode 100644 index 0000000..0144a06 --- /dev/null +++ b/docs/project/installing.md @@ -0,0 +1,39 @@ +Installing + +# Installing + +## What you need + +- **Node 26 or newer** (the server uses the built-in `node:sqlite`); CI and the dev container use Node 26. +- **npm** (it comes with Node). + +The dev container (`.devcontainer/`, Node 26) has all of this, plus Playwright's Chromium, `cloudflared`, the GitHub CLI, `kubectl` and `helm`, the `coder` CLI, the 1Password CLI (`op`), the docs site's Python venv (`.venv`) and the DeepSpaceCartel Agent Skills for Claude Code. Dev Container Features install the system tools (the local ones are in `.devcontainer/features/`); `postCreateCommand.sh` installs the npm packages and the docs venv. A CI workflow builds the container whenever `.devcontainer/` changes. + +## Build and run + +```console +$ npm install +$ npm start # build every app, then serve them on http://localhost:8080 +``` + +| Script | What it does | +|---|---| +| `npm start` | Build, then run the server on port 8080. Extra arguments go to the server: `npm start -- --public-url https://…` | +| `npm run dev` | One Vite dev server with hot reload for every page (http://localhost:5173, proxying `/api`) and the server with `--watch` | +| `npm run build` | Vite builds every page into `apps/web/dist` | +| `npm test` | Every package's unit tests | +| `npm run lint` | ESLint | + +All of them run through Turborepo, which caches results and runs packages in dependency order. + +## The docs site + +This site is [MkDocs](https://www.mkdocs.org/) with [Material](https://squidfunk.github.io/mkdocs-material/): + +```console +$ python3 -m venv .venv && .venv/bin/pip install -r docs/requirements.txt +$ .venv/bin/mkdocs serve # http://localhost:8000 +$ .venv/bin/mkdocs build --strict # what CI would check: broken links fail the build +``` + +Screenshots in `docs/assets/screenshots/` are taken from the real game with Playwright. diff --git a/docs/project/testing.md b/docs/project/testing.md new file mode 100644 index 0000000..6442ebe --- /dev/null +++ b/docs/project/testing.md @@ -0,0 +1,36 @@ +Running the tests + +# Running the tests + +```console +$ npm test # everything, through Turborepo +$ npm run lint +``` + +Or one package at a time: `cd packages/engine && node --test`. + +## What's tested where + +| Package | Tests | +|---|---| +| `packages/engine` | Board rules, placement, dice for every difficulty (including that Tricky, Master and Legend games last about as long as Hard), the other operations' rules | +| `packages/progress` | Shop chains and gates, bonuses, achievements, practice picking, migrations of old saves, cheats, homework goals and options, merging a device's save into an account | +| `apps/server` | Rooms (whole class, pairs), tournament formats, name checks, accounts (sign-in, classes, homework, parents, schedules, privacy), the Keycloak token checks with a generated key, and the HTTP API end to end on a real server with an in-memory database | +| `apps/web` | Invite links, Snake's rules, the Demos catalog | + +## In the browser + +Screens are checked with [Playwright](https://playwright.dev/) scripts that play the real flows (single player, practice, Home multiplayer, shop and Wardrobe, cheats, whole class, pairs, a tournament, accounts, and every other game) against a built copy on a spare port, then look at the screenshots. The rules for writing them are in `CLAUDE.md`. + +## Under load + +`apps/server/scripts/loadtest.js` plays whole classes against a server: a teacher hosts each one, students join as guests and keep an event stream open, and every round they all answer at the same instant (the worst case). It reports how long each new roll takes to reach every student, and any errors. + +```sh +TRUST_PROXY=1 npm start # or a deployment (dev sign-in, or TEACHER_TOKEN=) +node apps/server/scripts/loadtest.js http://localhost:8080 --classes 30 --students 30 --rounds 10 +``` + +Each class sends its own `X-Forwarded-For`, as a school would have its own address; the server only believes it with `TRUST_PROXY`. + +On 2026-10-15, two servers on a workspace (MongoDB and Valkey in the cluster, a round-robin proxy in front): 30 classes × 30 students (900 open streams), 10 rounds each. Every roll reached every student in 404 ms (median), 636 ms (p95) and 848 ms at worst, with no errors. The first run found answers lost when a whole class answered in the same instant on two servers (`503 busy`); the server now retries longer, and the game sends a busy answer again. diff --git a/docs/reference/CONFIGURATION.md b/docs/reference/CONFIGURATION.md new file mode 100644 index 0000000..a474946 --- /dev/null +++ b/docs/reference/CONFIGURATION.md @@ -0,0 +1,39 @@ +Configuration + +# Configuration + +The server (`node apps/server/src/index.js`, or `npm start`) is configured with arguments and environment variables. They're all read and checked at start-up (`apps/server/src/config.js`): a bad value stops the server with a message instead of running with a default. + +## Arguments + +| Argument | Default | Meaning | +|---|---|---| +| `` (a bare number) | `8080` (or `PORT`) | Port to listen on | +| `--public-url ` | none (or `PUBLIC_URL`) | The address students use when it isn't this computer's own, for example a tunnel. Class join links and QR codes use it. | + +## Environment variables + +| Variable | Default | Meaning | +|---|---|---| +| `NODE_ENV` | none | `production` for a public server: Blockout then refuses to start unless `BLOCKOUT_AUTH=keycloak` and `PUBLIC_URL` is an `https://` address, listing everything that's missing | +| `PORT` | `8080` | Port, when no port argument is given | +| `PUBLIC_URL` | none | Same as `--public-url` | +| `TRUST_PROXY` | `0` | How many proxies (a gateway, a tunnel) sit in front of the server. Rate limits then use the client address the nearest proxy saw in `X-Forwarded-For`; with `0` that header is ignored, because anyone can send it | +| `BLOCKOUT_MONGO_URL` | none | The MongoDB for accounts, like `mongodb://user:password@host:27017/blockout?authSource=admin&replicaSet=rs0`. Needed in production | +| `BLOCKOUT_REDIS_URL` | none | The Redis (Valkey) for live classes and rate limits, like `redis://:password@host:6379`, shared by every server. Needed in production | +| `BLOCKOUT_ROOMS` | `redis` with `BLOCKOUT_REDIS_URL`, otherwise `memory` | Where live classes are kept: `redis`, or `memory` (this server only) | +| `BLOCKOUT_REDIS_PREFIX` | none | A prefix for every Redis key, to keep several Blockouts (or test runs) apart in one Redis | +| `BLOCKOUT_STORE` | `mongo` with `BLOCKOUT_MONGO_URL`, otherwise `memory` | Where accounts are kept: `mongo`, or `memory` (nothing is kept after a restart: tests and quick tries). An old sqlite file is copied into MongoDB with `apps/server/scripts/sqlite-to-mongo.js` ([guide](../guides/set-up-the-databases.md)) | +| `BLOCKOUT_AUTH` | `dev` | Sign-in provider: `dev` or `keycloak` | +| `BLOCKOUT_AUTH_SECRET` | generated, kept in the database | Dev sign-in's token secret | +| `KEYCLOAK_ISSUER` | none | With `BLOCKOUT_AUTH=keycloak`: the realm URL, for example `https://id.deepspacecartel.com/realms/dev` (the dev container sets it) | +| `KEYCLOAK_CLIENT_ID` | none | With `BLOCKOUT_AUTH=keycloak`: the public client's ID | +| `BLOCKOUT_RETENTION_DAYS` | `395` | Accounts nobody has signed in to for this many days are deleted, with everything in them (a daily job, on one server). `0` keeps them | +| `METRICS_PORT` | none | Serves Prometheus metrics at `/metrics` on this port (the chart uses `9464`). It's a port of its own, so the public route never reaches it | +| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, `error` or `silent` | +| `LOG_FORMAT` | `json` in production, otherwise `text` | `json`: one JSON object per line with OpenTelemetry field names (`timestamp`, `severity_text`, `event.name`, `http.route`, …) for a log stack. `text`: short lines for a terminal; request lines only at `LOG_LEVEL=debug` | +| `OPENAI_API_KEY` | none | Adds OpenAI's free moderation model to the name check for guests joining live classes. Without it only the built-in word list runs, and no names leave the server. | + +## Schools + +Schools are rows in the database with an email domain. The first, **Cadence Park** (`iusd.org`), is created automatically. diff --git a/docs/reference/HTTP-API.md b/docs/reference/HTTP-API.md new file mode 100644 index 0000000..2d87df5 --- /dev/null +++ b/docs/reference/HTTP-API.md @@ -0,0 +1,143 @@ +HTTP API + +# HTTP API + +Everything under `/api` on the server. Requests and responses are JSON. The games use these through `@blockout/classroom` and `@blockout/auth`. + +## Conventions + +- **Sign-in:** endpoints marked with a role need `Authorization: Bearer ` from someone with that role. Without a valid token they answer `401`; with the wrong role, `403`. +- **Errors** are [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) problem details (`Content-Type: application/problem+json`): + + ```json + { + "type": "about:blank", + "title": "Unauthorized", + "status": 401, + "detail": "Please sign in first.", + "error": "signin", + "message": "Please sign in first." + } + ``` + + `error` is a short machine-readable code, and `message` (the same as `detail`) is written for the player; the games show it as is. +- **Rate limits** answer `429` (`rate`) with a `Retry-After` header in seconds: + - every request, per address: 6000 a minute (a whole school often shares one address) + - wrong class, room or invite codes, per address: 30, then 30 a minute. While they're used up, code lookups (joining, room info and actions, `POST /api/me/classes`, `POST /api/children`, `POST /api/invites/redeem`) get `429` too + - joining a class or linking a child, per account: 10 a minute + - dev sign-in, per address: 60 a minute + + Live updates (`/events`, `/poll`) answer `404` for a wrong teacher or player key, the same as for a class that doesn't exist. A class takes at most 200 open event streams, and an address at most 500; past that `/events` answers `429`. +- **Busy classes:** when a class changes too often from several places at once to save a change, the request gets `503` (`busy`); try it again. +- **Bad requests:** an address with a broken `%` escape gets `400` (`bad-request`). A request body that's cut off gets `400` (`aborted`), and one that takes longer than 10 seconds to arrive gets `408` (`timeout`). + +## Health + +For Kubernetes probes and uptime checks. No sign-in, not rate-limited, `Cache-Control: no-store`. + +| Method and path | Answer | +|---|---| +| `GET /health/startup` | `200 { status: 'started' }` once the server is set up; `503` before | +| `GET /health/live` | `200 { status: 'live' }` while the server keeps up; `503` if it has been stuck for 5 seconds or more. It doesn't check the database, so a database outage doesn't restart the server | +| `GET /health/ready` | `200 { status: 'ready' }` when it can take requests; `503` (`starting`, `draining` or `database`) while it starts, shuts down, or can't reach the database | +| `GET /api/health` | `{ ok, lan }`: the addresses students can use (only the public one on a production server) | + +On `SIGTERM` or `SIGINT` the server stops taking connections, reports `draining`, ends live-update streams (browsers reconnect) and answers held polls, then exits within 20 seconds. + +## Sign-in + +| Method and path | Who | Body | Answer | +|---|---|---|---| +| `GET /api/auth/config` | anyone | | `{ provider, issuer?, clientId?, schools: [{ id, name, domain }] }` | +| `POST /api/auth/dev` | anyone, dev sign-in only | `{ role, firstName, lastInitial, email?, schoolId?, admin? }` | `{ token, user }`. `role` is the account's role when it's new. | +| `GET /api/me` | signed in | | `{ user: { id, role, isAdmin, name, firstName, lastInitial, school } }` | +| `DELETE /api/me` | teacher (without classes or tournaments), parent | | `{ ok }`: the account and everything in it are deleted | +| `GET /api/me/export` | signed in | | everything kept about this account: `{ exportedAt, account, progress, classes, classesTaught, children, grownUps, homework, codesMade }` | + +## A student's own data + +| Method and path | Who | Body | Answer | +|---|---|---|---| +| `GET /api/me/progress` | signed in | | `{ progress }` (the game's save, or `null`) | +| `PUT /api/me/progress` | signed in | `{ progress }` | `{ ok, updatedAt }`. Cheats and what they unlocked are dropped. | +| `GET /api/me/homework` | student | | `{ homework: [{ id, goal, label, due, status: open\|late\|done, have, need, from, fromName }] }` | +| `GET /api/me/classes` | student | | `{ classes: [{ id, name }] }` | +| `POST /api/me/classes` | student | `{ code }` | `{ class }`. School classes only take that school's email domain. | +| `POST /api/me/parent-invites` | student | | `{ kind, code, expiresAt }`: a one-time code for a grown-up (14 days). A new one replaces the last unused one. | +| `GET /api/me/live` | anyone | | `{ live: [{ id, title, code }] }` tournaments open now for this student | + +## Invites + +One-time codes that change an account: 8 characters, kept only as a hash, shown once. Anyone who signs in starts as a student. + +| Method and path | Who | Body | Answer | +|---|---|---|---| +| `POST /api/invites/redeem` | signed in | `{ code }` | `{ user, child? }`. A teacher code makes a student account (not in a class, not linked) a teacher; a grown-up's code makes it a parent, or links another child to a parent. `404` for unknown, used or expired codes; `409` when this account can't take it | +| `GET /api/me/teacher-invites` | teacher, admin | | `{ invites: [{ id, kind, createdAt, expiresAt, by, usedBy, usedAt, expired }] }` (no codes) | +| `POST /api/me/teacher-invites` | teacher, admin | | `{ kind, code, expiresAt }` (7 days). Teachers: at most 10 unused, and 20 a day | +| `DELETE /api/me/invites/:id` | whoever made it, admin | | `{ ok }` | + +## Teachers + +| Method and path | Body | Answer | +|---|---|---| +| `GET /api/classes` | | `{ classes: [{ id, name, code, school, students }] }` | +| `POST /api/classes` | `{ name }` | the new class | +| `GET /api/classes/:id` | | `{ class, students: [stats], assignments, options }`: `options` is what every student has unlocked | +| `DELETE /api/classes/:id/students/:student` | | `{ ok }` | +| `GET /api/schedules` | | `{ schedules }` | +| `POST /api/schedules` | `{ classId, scope: class\|school, title, startsAt, settings: { format, size, difficulty } }` | `{ schedule }` | +| `DELETE /api/schedules/:id` | | `{ ok }` | +| `GET /api/schedules/:id/host` | | `{ code, teacherKey }` of the opened lobby | +| `POST /api/students/:id/parent-invites` | | `{ kind, code, expiresAt }`: a grown-up's code for a student in your class (admins: any student) | +| `GET /api/students/:id/parents` | | `{ parents: [{ id, name }] }` (admins too) | +| `DELETE /api/students/:id/parents/:parent` | | `{ ok }`: takes a wrong grown-up off (admins too) | + +## Parents + +| Method and path | Body | Answer | +|---|---|---| +| `GET /api/children` | | `{ children: [stats] }` | +| `POST /api/children` | `{ code }` | `{ user, child }`: `POST /api/invites/redeem` for grown-ups' codes only | +| `DELETE /api/children/:id` | | `{ ok }` | +| `DELETE /api/students/:id` | | `{ ok }`: deletes a linked child's account and everything in it (admins: any student) | +| `GET /api/students/:id/export` | | everything kept about a linked child, as `GET /api/me/export` (admins: any student) | + +## Teachers and parents + +| Method and path | Body | Answer | +|---|---|---| +| `GET /api/students/:id` | | the student's stats, homework, recent activity, `facts` and `options` (linked parents, and teachers of their classes) | +| `POST /api/homework` | `{ classId \| studentId, goal, due? }` | `{ assignment }` | +| `DELETE /api/homework/:id` | | `{ ok }` (only whoever set it) | + +A `goal` is one of `{ type: 'master', table }`, `{ type: 'practice', rounds, difficulty }`, `{ type: 'win', size, difficulty }` or `{ type: 'games', games }`; `difficulty` may be `any`. + +## Admins + +Accounts with the `blockout` client's `admin` role in Keycloak (`resource_access.blockout.roles`; dev sign-in: `admin: true`). + +| Method and path | Body | Answer | +|---|---|---| +| `GET /api/admin/users?q=&role=&page=&limit=` | | `{ users: [{ id, name, role, domain, schoolId, invitedBy, createdAt, lastSeen }], total, page, limit }`, newest sign-ins first; `q` matches the start of the first name | +| `PUT /api/admin/users/:id/role` | `{ role }` | `{ user }`. `409` for a teacher who still has classes or tournaments | +| `DELETE /api/admin/users/:id` | | `{ ok }`: the account and everything in it (the classes they teach, with rosters, homework and tournaments) | +| `GET /api/admin/invites?kind=teacher\|parent` | | `{ invites }` | +| `POST /api/admin/invites` | `{ schoolId? }` | a teacher code | +| `GET /api/admin/schools` | | `{ schools }` | +| `POST /api/admin/schools` | `{ name, domain }` | `{ school }` | + +## Live classes + +| Method and path | Who | Body | Answer | +|---|---|---|---| +| `GET /api/health` | anyone | | `{ ok, lan: [addresses] }` | +| `POST /api/rooms` | teacher | `{ settings }` | `{ code, teacherKey, lan }` | +| `GET /api/rooms/:code/info` | anyone | | `{ code, type, state }` | +| `POST /api/rooms/:code/join` | anyone | `{ name }` (signed-in students use their account name), or `{ teacher: key }` / `{ play: key }` | `{ id, key, name, teacher }` | +| `POST /api/rooms/:code/result` | player | `{ id, key, … }` | `{ ok, score }` | +| `POST /api/rooms/:code/roll` | player (pairs) | `{ id, key }` | the roll | +| `POST /api/rooms/:code/leave` | player | `{ id, key }` | `{ ok }` | +| `POST /api/rooms/:code/teacher` | teacher key | `{ key, action, … }`: `settings`, `pairOptions`, `swap`, `start`, `next`, `autoNext`, `end`, `lobby`, `remove`, `close` | `{ ok }` | +| `GET /api/rooms/:code/events` | teacher or player | query `teacher=KEY` or `id=ID&key=KEY` | Server-Sent Events: the room as that person sees it | +| `GET /api/rooms/:code/poll` | teacher or player | the same, plus `v=VERSION` | the next view (long poll, for proxies that buffer event streams) | diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 0000000..eccaa4b --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +mkdocs>=1.6,<2 +mkdocs-material>=9.7,<10 diff --git a/docs/v0.3.0-log.md b/docs/v0.3.0-log.md new file mode 100644 index 0000000..e789d6a --- /dev/null +++ b/docs/v0.3.0-log.md @@ -0,0 +1,206 @@ +# v0.3.0 work log + +Progress on [the v0.3.0 plan](v0.3.0-plan.md). Newest entries go at the bottom of each section. + +## Status + +> **Commits:** Stage 0 and the accounts server were committed and pushed as they were done. Then commit signing (your SSH key, through the agent your editor forwards) hung for several hours. I didn't turn signing off. Each finished stage was saved as a git tree object, and when signing came back they became three signed commits, pushed to `v0.3.0`: `d4abf94` (Stage 1), `2668072` (Stage 2) and `ac4d404` (Stage 3). + +| Stage | State | +|---|---| +| 0: Legend fix | done | +| 1: Accounts | done | +| 2: Turborepo | done | +| 3: Prototype games | done | + +## Stage 0: Legend fix + +**What changed** (`js/core.js`, `createPairDice` and `rollForBoard`): +- **The mix:** Legend deals a teen × 2–9 for half of its rolls and Tricky facts for the rest. +- **The size bias:** all pair dice (Tricky, Master, Legend) make big rectangles rarer on smaller boards, like the loaded dice on Easy, Medium and Hard do. +- **The fallback:** near the end, when none of the difficulty's facts fit the gaps left, the pair dice deal a smaller fact (up to 12 × 12) that does fit, so the board gets finished instead of both players passing. +- **Board size:** Legend now needs a 12×12 board or bigger (invites too). + +Measured, rectangles per CPU-vs-CPU game (a unit test guards the minimums): + +| Board | Hard | Tricky before → after | Legend before → after | Teen facts per Legend game | +|---|---|---|---|---| +| 12×12 | 11.7 | 4.1 → 10.0 | 2.7 → 8.7 | 1.8 | +| 16×16 | 12.8 | 6.5 → 13.6 | 3.9 → 12.7 | 2.7 | +| 20×20 | 15.1 | 9.9 → 18.8 | 5.5 → 16.9 | 3.8 | +| 24×24 | 19.1 | 13.3 → 23.2 | 7.7 → 21.5 | 5.1 | + +**Decisions made on your behalf:** +- **Teen share is 1/2, not the 1/3 in the plan.** With 1/3, the size bias and the end-of-game fill-in left only about 1 teen fact in a 12×12 game; 1/2 gives about 2 without making games shorter. It's `LEGEND_TEEN_SHARE` in core.js. +- **Tricky had the same problem** (4 rectangles on 12×12), so the size bias and fill-in apply to every pair-dice difficulty, not just Legend. +- **The fill-in facts can be easier than the difficulty** (for example 2 × 3 on Tricky). The alternative was ending games with a third of the board empty. + +**How to try it:** single player → Legend (or type IDDQD after pressing ~ to unlock everything) → 12×12 or bigger → play. The hint under the board size explains the mix. + +## Stage 1: accounts + +**What's new:** +- **Server:** + - `server/store.js` keeps everything in sqlite (`node:sqlite`, no npm packages), in `data/blockout.db` (`BLOCKOUT_DB` changes the path). + - `server/auth.js` has the swappable sign-in providers: + - dev: HMAC-signed tokens + - Keycloak: RS256 tokens checked against the realm's published keys; the browser uses PKCE + - `server/accounts.js` has the rules. The HTTP routes are in `server/classroom.js`. +- **Shared logic** (`js/progress.js`): an activity log, merging a device's save into an account, fact summaries and homework goals. +- **Browser:** + - `js/auth.js`: sign-in, the token, and progress sync. + - `js/accounts.js`: the new screens. + - The account chip at the top of the menu. + - A sign-in dialog, with play as a guest always offered. + - The student's 📚 Homework and account page (classes, join by code, the code for a grown-up). + - The teacher's 🏫 My classes (roster, stats, homework, tournaments). + - The parent's 👪 My kids (link a child, their stats and homework, set extra homework). + - A banner on the menu when a tournament is open. +- **Guests play exactly as before.** The only changes: hosting a class now needs a teacher sign-in, and there's a small "👤 Sign in" chip. +- **Privacy:** see [privacy.md](privacy.md). + +**How to try it** (`node server/classroom.js 8080`, open http://localhost:8080): +1. **Teacher:** + - Tap 👤 Sign in, choose Teacher, type a name, and pick Cadence Park. + - Open 🏫 My classes and make a class: you get a 6-letter code and a join link. + - Add class homework, and plan a tournament a few minutes ahead. +2. **Student** (another browser or a private window): + - Open the join link, `…/#roster=CODE`, and sign in as a Student. The email is filled in as `name@iusd.org`. You join the class. + - 📚 Homework shows what's set. Play; it ticks itself off. + - Tap your name at the top for your classes and the code for a grown-up. +3. **Parent** (a third browser): sign in as a Parent, open 👪 My kids, type the child's code, open the child, and add homework. +4. **The tournament:** when it's due, the lobby opens by itself (the server checks every 10 s). + - The teacher gets "Open the lobby" in My classes. + - Students of that class, or of the whole school for a school tournament, see "🏆 … is open! Join" on the menu. +5. **Tests:** `npm test` (accounts, the HTTP API end to end, and the Keycloak token checks with a generated key). +6. **Keycloak later:** `BLOCKOUT_AUTH=keycloak KEYCLOAK_ISSUER=https://…/realms/NAME KEYCLOAK_CLIENT_ID=blockout node server/classroom.js`. + +**Decisions made on your behalf:** +- **Hosting:** hosting any live class now needs a teacher sign-in, and the server enforces it. Students joining a live game still need no account. +- **Signed-in students in live games** play under their account's first name. No name check is needed, and nothing goes to OpenAI. +- **Roles from Keycloak:** a token's realm role `student`, `teacher` or `parent` decides the role. With no role, a school-domain account is a student and anyone else a parent. +- **Dev sign-in:** + - Teachers pick a school; that gives them a made-up `teacher@iusd.org`, used only for its domain. + - The same name, role and email always reach the same account. + - The token secret is kept in the database, so sign-ins survive restarts. +- **Separate saves:** a signed-in player's save is kept under its own key in the browser, so the guest save on a shared Chromebook isn't touched. +- **First use on a device:** the device's guest save is merged into the account once: the most points, every unlock and achievement, and for each fact the record that has seen it most. After that the server copy wins, and the device keeps a cache. +- **Cheats stay on the device.** The server stores saves with cheats and what they unlocked removed, and IDDQD is switched back on locally after syncing. +- **"Master the ×N table" homework** means every N × 1…10 and 1…10 × N fact is Mastered (19 facts). +- **Homework goals:** master a table; finish N practice rounds (any difficulty or one); beat the CPU on a board this big or bigger; finish N games. Rounds, wins and games count from when the homework was set. +- **Due dates:** late means past the due date's end, by the server's day. +- **Class and parent codes:** class codes are 6 characters and parent codes 8, with no 0/O/1/I/L so they're easy to read out. +- **Codes don't expire yet.** Parent codes stay the same until they're reset (not built yet). +- **Scheduled tournaments** use Easy, Medium or Hard and boards up to 12×12, like the lobby. The teacher needs to open the lobby from My classes to run it, just as with any tournament. +- **Keycloak token refresh isn't built.** When the access token expires, the player is signed out and signs in again. + +**Later changes you asked for** (made during Stage 2, in the new layout): +- **Homework only offers what the child can already play:** + - The kinds of homework, times tables, difficulties and board sizes come from the child's unlocks (`Progress.availableOptions`). Class homework uses what every student in the class has (`sharedOptions`); a class with nobody in it gets a new player's options. + - "Master a table" and "practice rounds" appear once practice (and a table) is unlocked. +- **"Play ›" on each open homework** sets up the menu and starts the game: + - "Master ×7": practice on a difficulty that has ×7, with only ×7 picked. + - Practice rounds: practice on that difficulty. + - Beat the CPU: single player on that board and difficulty. + - Games: single player. + +**Open questions:** +- Deleting a student's data from the teacher's screen (only a manual database task for now). +- Should parent codes expire, or be resettable? +- Keycloak isn't tested against a real server yet (only with a generated key in the unit tests). + +## Stage 2: Turborepo monorepo + +**The layout** (details in README.md and CLAUDE.md): + +| Path | What | +|---|---| +| `apps/multiplication` | The main game, at `/` | +| `apps/{addition,subtraction,division,fraction-add,fraction-subtract,fraction-multiply,fraction-divide}` | One app per game | +| `apps/more-games` | The hub | +| `apps/server` | The server, now ES modules | +| `packages/engine` | Board, dice, CPU, and the other operations' rules (`@blockout/engine/{fraction,boards,variants}`) | +| `packages/progress` | Progress, plus the Wardrobe | +| `packages/ui` | CSS, icons and the page plugin | +| `packages/game-kit` | The other games' engine | +| `packages/classroom`, `packages/auth` | The browser clients | + +- **Tooling:** npm workspaces, Turborepo, Vite 8 and ESLint 10. Root scripts are `dev`, `build`, `test`, `lint` and `start` (build, then serve on 8080). +- **How the 6,300-line `game.js` was split,** keeping behaviour identical: + - A script with a proper JavaScript parser and scope analysis (espree + eslint-scope, run once, not kept) cut it into 15 modules along its own section headers. + - Each module only declares things at import. What used to run at load moved into its `run()`, and `game/index.js` calls them in the original order. + - Variables written from other modules got setters (only 4 needed: `game`, `gameId`, `practice`, `wardrobeFilter`). +- **No more globals:** `window.BlockoutX` became imports, the account screens import from `game/api.js`, and the vendored QR library became the `qrcode-generator` npm package. +- **Styles:** the stylesheet was split along its sections into `@blockout/ui` (shared) and the app (classroom and account screens). They're imported in the original order, so the cascade is unchanged. +- **Icons and theme:** the icon sprite (the main game's plus two from the other games) and the theme script are added to every page by `@blockout/ui/vite`. +- **Tests:** they moved next to their code: 123 in all, the 122 from before plus one for homework options. + +**Checks** (the same Playwright pass before and after the move, on the same flows): +- Single player, practice, Home multiplayer, shop + Wardrobe + cheats, whole class, pairs and a tournament all PASS, with no page errors, before and after. +- The accounts flow PASSES on the new build: sign-in, class roster, homework (limited options, Play ›), parent link, the scheduled tournament opening, and phone width. +- The other games: single player and practice play through on the new URLs. + +**Decisions made on your behalf:** +- **Seven small apps:** each game is its own app, and all of them run the shared `@blockout/game-kit`. +- **URLs:** the More games hub is its own small app at `/more-games/`, and the games are at `//`. The old `prototypes/…html` addresses are gone; the More games button points at `/more-games/`. +- **CSS ownership:** most of the CSS is shared by all games, so it's in `@blockout/ui`. Only the classroom and account screens' CSS stays in the multiplication app. +- **JavaScript** stays plain JS (no TypeScript), as the plan says, and the code keeps its style. +- **Dev mode:** `npm run dev` runs a Vite dev server per app and the API server; the multiplication app proxies `/api` to it. For the full site with every game at its real address, use `npm start`. +- **Data:** the sqlite database stays at `data/blockout.db` in the repo root. +- **The debug hook:** `window.ProtoGame` now also has `boards`, for automated checks of the other games. + +**How to try it:** `npm install`, then `npm start`, then open http://localhost:8080. The More games button leads to the other games. Run `npm test` and `npm run lint` for the checks. + +## Improvements from the DeepSpaceCartel skills + +You asked me to install `DeepSpaceCartel/skills` globally (`npx skills add DeepSpaceCartel/skills -g -a claude-code`; it's in `~/.claude/skills`, not the repo) and use them. What each one led to: + +| Skill | What changed | +|---|---| +| docs + your devcontainer-builder docs as the model | A MkDocs Material site like devcontainer-builder's: Home (welcome, quickstart), Guides (playing, teachers, parents, tunnel, Keycloak, backups), Application (architecture with a diagram, security, privacy, configuration, HTTP API), Project (installing, testing, CI, changelog) and Decisions. Pages have ``s. The plan and this log stay out of the nav. | +| (your request) | 21 screenshots from real Playwright runs (menu, game, results, practice, shop, Wardrobe, stats, classroom setup, join, lobby, teacher and student screens, sign-in, My classes, a student's page, homework, My kids, More games, a fraction game) in `docs/assets/screenshots/`, used across the guides. | +| adr | Five ADRs in `docs/decisions/`: the Turborepo, swappable sign-in, sqlite, storing the minimum about children, and the game modules' `run()` phase. | +| keepachangelog, semver | `CHANGELOG.md` with `[Unreleased]` (0.3.0), 0.2.0 and 0.1.0. There are no release tags yet, so the links point at the release commits. | +| conventionalcommits | Commits from here on use `type(scope): …`. The rule is in CLAUDE.md. | +| rfc9457 | API errors are problem details (`application/problem+json`: `type`, `title`, `status`, `detail`), keeping `error` and `message` so the games' clients work unchanged. Tested. | +| owasp-asvs | `docs/concepts/security.md`: what's in place and the known gaps (dev sign-in on a public server, no rate limiting, tokens in localStorage, no refresh, HTTP by default, no audit log), by ASVS chapter. The server now also sends `nosniff`, `X-Frame-Options: DENY` and `Referrer-Policy: same-origin`. | +| github-actions | `.github/workflows/ci.yml`: lint, test and build, plus a strict docs build. Read-only permissions, actions pinned to commit SHAs, cancel superseded runs, and Dependabot for actions and npm. | +| devcontainer | Port 5173 for Vite added to `forwardPorts`, and the setup script installs `python3-venv` for the docs build. | +| core-principles, engineering-practices | Used to review the restructure, backed by evidence rather than assumed: old vs new screenshots compared pixel by pixel (11 multiplication screens and every other game's menu and game screen). | + +**A bug the pixel check caught:** after the move, the other games' side panel dropped below the board. The old stylesheet's "classroom" section also held the general game layout, and the other games don't load the classroom CSS. Those rules are now `@blockout/ui/css/play.css`. After the fix, every game screen matches the old one pixel for pixel. + +**Follow-up (not done):** the CSS files are still split along the old section headers, whose names don't always match their contents (`dice-skins.css` also has the keypad). A proper reorganisation by component, with the same pixel check, is next. + +## Stage 3: the other games + +**What's new in each of the seven games** (all in `@blockout/game-kit`): +- **Home multiplayer:** two players on one screen, taking turns (it's an unlock). +- **Points** in the game's own wallet: + - A right answer earns 1, +2 for right first time and +5 on every 5th in a row. Expert practice doubles it. + - Winning earns +10/+20/+40 on Easy/Medium/Hard. + - Finishing a practice round earns +10 for 10 questions and +30 for 20, doubled on Expert. +- **Shop** with skill-tree chains: Medium (120), then Hard (250); 20-question practice (60), then Expert practice (150); Home multiplayer (150). The next step shows its price, later ones are blurred placeholders, and tapping a locked choice on the menu offers to unlock it. +- **Achievements** (11, plus "Blockout!" in the fraction-bar games): first game, winner, Medium and Hard champion, ten wins, 5 and 10 in a row, 100 right, first, perfect and Expert practice. They use the same WoW-style banners, toasts and confetti as the main game (now shared as `@blockout/ui/rewards`). +- **Stats:** totals (games, wins, practice rounds, best streak, points) and every question of every practice family, coloured Mastered / Learning / Needs practice / Not seen yet by the same rules as the Times Table. +- **Each game's own maths explanations** are unchanged ("Show me how", the CPU's working). + +**Checks:** +- **Unit tests:** `packages/game-kit/test/meta.test.js` covers the shop chains, points, achievements and saves; 128 tests in all. +- **Playwright:** + - All seven games play a full game against the CPU; Learn practice rounds finish; no page errors. + - Addition as a new player: the locks are right; a full game earned points and four achievements. + - A player with points bought Medium in the shop; Home multiplayer starts with "Player 1's turn". + - Screenshots are in the docs (Playing → More games). + +**Decisions made on your behalf:** +- **Separate saves:** each game has its own save and wallet (`blockout.game.<id>`), separate from the multiplication game's. Sharing one wallet across games would mix unrelated unlocks; it can be joined later. +- **Now unlocked with points:** Medium, Hard, 20-question practice and Expert practice were free in the prototypes. Only Easy, 10-question practice and all practice families stay free. +- **No points at home:** Home multiplayer games earn no points or achievements, like the main game's Home games ("just for fun"). +- **Not added to the other games** (the plan said "where it makes sense"): + - **Classroom play and invites:** the classroom server's rooms deal multiplication rolls. Supporting other operations means making rooms operation-aware, which is a real project. + - **Accounts and homework:** those follow the multiplication game's save. + - **Stickers and the Wardrobe.** + - **Timer lengths:** Expert practice keeps each game's fixed timer rather than 30/10/5-second unlocks. + +**Follow-up:** the multiplication game still has its own copy of the toasts, banners and confetti (with the Wardrobe's emoji confetti). Moving it onto `@blockout/ui/rewards` is a small next step. diff --git a/docs/v0.3.0-plan.md b/docs/v0.3.0-plan.md new file mode 100644 index 0000000..068699b --- /dev/null +++ b/docs/v0.3.0-plan.md @@ -0,0 +1,101 @@ +# Blockout v0.3.0 plan + +Teacher, parent and student accounts; a Turborepo monorepo; polished prototype games. Read CLAUDE.md first and follow it. + +## Git rules (hard) +- **Branch:** work only on `v0.3.0`. Never check out, merge into, rebase or push `main`. +- **Pending changes:** commit the pending working-tree changes first. Leave `Blockout.pdf` untracked. +- **Commits and pushes:** commit after every finished step with a clear message, and push to `origin v0.3.0` after each stage. If pushing fails for permission reasons, keep committing locally and note it in the log. +- **Tests:** keep `npm test` green at every commit. + - Add unit tests for every new rule. + - Add a Playwright check (scratchpad scripts, per CLAUDE.md) for every new screen, and look at the screenshots. +- **Log:** keep `docs/v0.3.0-log.md` up to date: what's done, decisions made on the user's behalf, open questions, and how to try each feature. Where something is unclear, pick the sensible default, log it, and keep going. Never stop to ask. + +## Stage 0: Legend fix +Legend games fill the board in about 3–8 rectangles. +- Make about 1 in 3 rolls a teen × 2–9 and the rest Tricky-style facts, so games run a normal length and work from 12×12 up. +- Update the hints and tests. + +## Stage 1: accounts, on today's layout (zero dependencies, `node:sqlite`) + +### Auth +- **One module, four roles:** a single swappable auth module (client and server) with roles guest, student, teacher and parent. +- **No real registration.** For now a "dev login" picks or creates demo users. +- **Ready for Keycloak:** design it so Keycloak (OIDC, PKCE) drops in later. The server checks bearer tokens through one provider interface, and the dev provider issues HMAC-signed tokens. +- **Schools:** Keycloak will broker Google sign-in for students, restricted to `@iusd.org`. The first school is Cadence Park (IUSD, Irvine CA). Model a school with an allowed email domain. +- **Guest play always stays available, unchanged:** local progress, and join-by-code in live classes. + +### Storage +- **sqlite** behind a small store module, holding: + - users and schools + - classes and class members + - progress + - assignments + - parent links + - scheduled tournaments +- **Progress sync:** signed-in students' progress syncs to the server. On first sign-in, the device's local progress merges into the account; after that the server copy wins, with local as a cache. + +### Teacher +- **Hosting:** hosting a class requires a teacher account (gate at `openHostSetup()`). +- **Classes:** teachers create classes with a class code or link. Any `@iusd.org` student with the code joins the roster. +- **Class stats page:** each kid's Times Table mastery, facts that need practice, recent activity and homework status. +- **Scheduled tournaments:** class or school tournaments with a date and time, a format and a class. The lobby opens automatically at that time. + +### Homework +- **Assignments** have a goal and a due date. Example goals: + - master the ×7 table + - finish N practice rounds on a difficulty + - win on a board size + + Completion is detected automatically from progress events. +- **Who sets it:** the teacher sets class homework. Parents can also set extra homework for their own child. +- **Who sees it:** the student sees "📚 Homework" on the main menu. Teacher and parents see its status. + +### Parent +- **Linking:** links to a child with a code from the child's account, or one given by the teacher. +- **What they can do:** see that child's stats and all their homework, and set extra homework. + +### Privacy (kids under 13 in California: FERPA, COPPA, SOPIPA) +- **Names:** store first name + last initial only, and no Google email beyond an opaque subject ID. +- **Sharing:** no third-party sharing. Skip OpenAI name moderation for signed-in students. +- **Docs:** write a short `docs/privacy.md`. + +### UI +Same style as the rest of the game: dialogs with a bottom button panel, kid-friendly wording, Chromebook and phone widths. + +## Stage 2: Turborepo monorepo (make it very clean) +- **Tooling:** npm workspaces + Turborepo, Vite, plain JavaScript ES modules, no framework. +- **Layout:** + + | Path | What | + |---|---| + | `apps/multiplication` | the main game | + | one app per prototype game | addition, subtraction, division, and the four fraction games | + | `apps/server` | classroom + accounts + sqlite | + | `packages/ui` | buttons, dialogs, tokens, icons, CSS | + | `packages/engine` | board, dice, placement and CPU, with the operation passed in | + | `packages/progress` | wallet, shop, achievements, facts, bonuses | + | `packages/classroom` | classroom client | + | `packages/auth` | auth module | + +- **game.js:** split the 6000-line file into sensible modules. +- **Scripts and serving:** root scripts `dev`, `build`, `test` and `lint`. The server serves the built apps. The main game's More games button links to the other apps. +- **Docs:** update CLAUDE.md, the devcontainer script and README for the new layout. +- **Behaviour must be identical before and after.** Rerun every unit test and a full Playwright pass of the main flows: single player, practice, Home multiplayer, whole class, pairs, tournament, shop, wardrobe, cheats and accounts. + +## Stage 3: polish the prototype games on the shared packages +Give them the main game's feature set and feel: +- wallet, shop with skill-tree unlocks, stickers and stats +- achievements (WoW banners) +- practice with Learn/Expert and timers +- difficulties and bonuses +- results screens +- invites, Home multiplayer, and classroom play where it makes sense + +They stay under More games. Keep each game's own maths explanations. + +## Done when +- All stages are committed and pushed on `v0.3.0`. +- Tests pass. +- The Playwright checks pass, with screenshots reviewed. +- `docs/v0.3.0-log.md` says how to try everything and lists every decision made on the user's behalf. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..691be18 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,52 @@ +// Linting for every app and package (npm run lint runs it through Turborepo). +import js from '@eslint/js'; +import globals from 'globals'; + +// A component used only as a JSX tag (<Board …/>) counts as used. (The rule +// eslint-plugin-react has for this, without the plugin: it doesn't support +// ESLint 10 yet.) +const jsx = { + rules: { + 'uses-vars': { + meta: { type: 'problem', schema: [] }, + create(context) { + const root = (n) => (n.type === 'JSXMemberExpression' ? root(n.object) : n); + return { + JSXOpeningElement(node) { + const name = root(node.name); + if (name.type === 'JSXIdentifier' && /^[A-Z]/.test(name.name)) context.sourceCode.markVariableAsUsed(name.name, node); + }, + }; + }, + }, + }, +}; + +export default [ + { ignores: ['**/dist/**', '**/node_modules/**', 'data/**'] }, + js.configs.recommended, + { + files: ['**/*.js'], + languageOptions: { + ecmaVersion: 2024, + sourceType: 'module', + globals: { ...globals.browser }, + }, + rules: { + 'no-unused-vars': ['error', { args: 'none', caughtErrors: 'none' }], + 'no-empty': ['error', { allowEmptyCatch: true }], + }, + }, + { + // The phone app: React Native with JSX (components used only in JSX count as used) + files: ['apps/mobile/**/*.js'], + plugins: { jsx }, + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } }, globals: { ...globals.browser } }, + rules: { 'jsx/uses-vars': 'error' }, + }, + { + // Node: the server, tests and build configs + files: ['apps/server/**/*.js', '**/test/**/*.js', '**/vite.config.js', 'packages/ui/src/vite-plugin.js', 'eslint.config.js'], + languageOptions: { globals: { ...globals.node } }, + }, +]; diff --git a/js/classroom.js b/js/classroom.js deleted file mode 100644 index 2babd79..0000000 --- a/js/classroom.js +++ /dev/null @@ -1,185 +0,0 @@ -// Talking to the classroom server (server/classroom.js): JSON requests, live -// updates over Server-Sent Events, and remembering which class this tab is in -// so a reload or a Chromebook that went to sleep can rejoin. Kept per tab -// (sessionStorage), so several tabs can be different students when testing. Loaded as a plain script -// (window.BlockoutClassroom); the screens themselves live in game.js. -(function (root) { - 'use strict'; - - const SESSION_KEY = 'blockout.class'; // { code, id, key, name } for a student - const HOST_KEY = 'blockout.classHost'; // { code, teacherKey } for the teacher - - class ClassError extends Error { - constructor(code, message) { - super(message); - this.code = code; - } - } - - // The classroom needs the page to come from the classroom server. - const served = () => location.protocol === 'http:' || location.protocol === 'https:'; - - async function request(method, path, body) { - let res; - try { - res = await fetch(path, { - method, - headers: body ? { 'Content-Type': 'application/json' } : {}, - body: body ? JSON.stringify(body) : undefined, - }); - } catch (e) { - throw new ClassError('offline', 'Can’t reach the classroom server.'); - } - let data = null; - try { - data = await res.json(); - } catch (e) { - throw new ClassError('offline', 'The classroom server isn’t running here.'); - } - if (!res.ok) throw new ClassError(data.error || 'server', data.message || 'Something went wrong.'); - return data; - } - - // null when the server is there, otherwise why not - async function check() { - if (!served()) return 'file'; - try { - const h = await request('GET', '/api/health'); - return h.ok ? null : 'offline'; - } catch (e) { - return 'offline'; - } - } - - const health = () => request('GET', '/api/health'); - const roomInfo = (code) => request('GET', `/api/rooms/${encodeURIComponent(code)}/info`); - const createRoom = (settings) => request('POST', '/api/rooms', { settings }); - const join = (code, name) => request('POST', `/api/rooms/${encodeURIComponent(code)}/join`, { name }); - // The teacher as a player (pairs: playing the odd one out), from the projector - // screen (with the teacher key) or another device (with the play key from its link). - const joinAsTeacher = (h) => request('POST', `/api/rooms/${h.code}/join`, { teacher: h.teacherKey }); - const joinWithPlayKey = (code, play) => request('POST', `/api/rooms/${encodeURIComponent(code)}/join`, { play }); - const roll = (s) => request('POST', `/api/rooms/${s.code}/roll`, { id: s.id, key: s.key }); - const leave = (s) => request('POST', `/api/rooms/${s.code}/leave`, { id: s.id, key: s.key }); - const sendResult = (s, result) => request('POST', `/api/rooms/${s.code}/result`, { id: s.id, key: s.key, ...result }); - const teacher = (h, action, extra = {}) => request('POST', `/api/rooms/${h.code}/teacher`, { key: h.teacherKey, action, ...extra }); - - // Reloading or closing the page drops the connection too; that isn't the class going away. - let unloading = false; - root.addEventListener('pagehide', () => (unloading = true)); - root.addEventListener('beforeunload', () => (unloading = true)); - - // Live updates. handlers: { view(v), gone(reason) }. Returns a close() function. - // First an event stream (EventSource reconnects by itself after a blip). Some - // proxies hold streams back (Cloudflare quick tunnels, some school filters), so if - // nothing arrives in a few seconds we switch to long polling, which gets through - // anything. If the class or the player no longer exists we stop and say why. - const STREAM_WAIT_MS = 4000; - const RETRY_MS = 2000; - - function listen(base, query, handlers) { - let closed = false; - let got = false; - const stop = (reason) => { - if (closed) return; - closed = true; - if (src) src.close(); - handlers.gone(reason); - }; - const deliver = (view) => { - got = true; - handlers.view(view); - }; - - let src = new EventSource(`${base}/events?${query}`); - src.addEventListener('view', (e) => deliver(JSON.parse(e.data))); - src.addEventListener('closed', () => stop('closed')); - src.addEventListener('removed', () => stop('removed')); - src.addEventListener('error', () => { - if (closed || unloading || !src) return; - // A 404 closes the stream for good (readyState CLOSED): the class is gone. - if (src.readyState === EventSource.CLOSED) stop('lost'); - }); - setTimeout(() => { - if (closed || got) return; - src.close(); - src = null; - poll(); - }, STREAM_WAIT_MS); - - async function poll() { - let version = -1; - while (!closed) { - let data; - try { - const res = await fetch(`${base}/poll?${query}&v=${version}`, { cache: 'no-store' }); - if (res.status === 404) return stop('lost'); - data = await res.json(); - } catch (e) { - if (closed || unloading) return; - await new Promise((r) => setTimeout(r, RETRY_MS)); // offline for a moment: try again - continue; - } - if (closed) return; - if (data.closed) return stop('closed'); - if (data.removed) return stop('removed'); - if (data.v !== version) { - version = data.v; - deliver(data.view); - } - } - } - - return () => { - closed = true; - if (src) src.close(); - }; - } - - const listenStudent = (s, handlers) => listen(`/api/rooms/${s.code}`, `id=${encodeURIComponent(s.id)}&key=${encodeURIComponent(s.key)}`, handlers); - const listenTeacher = (h, handlers) => listen(`/api/rooms/${h.code}`, `teacher=${encodeURIComponent(h.teacherKey)}`, handlers); - - function load(key) { - try { - return JSON.parse(sessionStorage.getItem(key)); - } catch (e) { - return null; - } - } - function store(key, value) { - try { - if (value) sessionStorage.setItem(key, JSON.stringify(value)); - else sessionStorage.removeItem(key); - } catch (e) {} - } - - // Join links look like https://host/#class=ABCD; the teacher's other device - // gets #class=ABCD&play=KEY. - function linkFromHash(hash = location.hash) { - const m = /^#class=([A-Za-z]{4})(?:&play=([0-9a-f]+))?$/.exec(hash || ''); - return m ? { code: m[1].toUpperCase(), play: m[2] || null } : null; - } - - root.BlockoutClassroom = { - ClassError, - served, - check, - health, - roomInfo, - createRoom, - join, - joinAsTeacher, - joinWithPlayKey, - roll, - leave, - sendResult, - teacher, - listenStudent, - listenTeacher, - session: () => load(SESSION_KEY), - saveSession: (s) => store(SESSION_KEY, s), - host: () => load(HOST_KEY), - saveHost: (h) => store(HOST_KEY, h), - linkFromHash, - }; -})(window); diff --git a/js/core.js b/js/core.js deleted file mode 100644 index effb8b1..0000000 --- a/js/core.js +++ /dev/null @@ -1,325 +0,0 @@ -// Blockout game rules, independent of the UI. -// Loaded as a plain script in the browser (window.BlockoutCore) and via require() in tests. -(function (root) { - 'use strict'; - - const EMPTY = -1; - - // firstInCorner: the very first rectangle of the game must go in the top-left corner. - function createBoard(size, { firstInCorner = false } = {}) { - return { size, firstInCorner, cells: new Array(size * size).fill(EMPTY), rects: [] }; - } - - function fits(board, x, y, w, h) { - const n = board.size; - if (x < 0 || y < 0 || x + w > n || y + h > n) return false; - for (let r = y; r < y + h; r++) { - for (let c = x; c < x + w; c++) { - if (board.cells[r * n + c] !== EMPTY) return false; - } - } - return true; - } - - function mustUseCorner(board) { - return board.firstInCorner && board.rects.length === 0; - } - - function isValidPlacement(board, x, y, w, h) { - if (mustUseCorner(board) && (x !== 0 || y !== 0)) return false; - return fits(board, x, y, w, h); - } - - // Both orientations of an a-by-b roll (only one when a === b). - function orientations(a, b) { - return a === b ? [[a, b]] : [[a, b], [b, a]]; - } - - function validPlacements(board, a, b) { - const out = []; - for (const [w, h] of orientations(a, b)) { - for (let y = 0; y + h <= board.size; y++) { - for (let x = 0; x + w <= board.size; x++) { - if (isValidPlacement(board, x, y, w, h)) out.push({ x, y, w, h }); - } - } - } - return out; - } - - function place(board, x, y, w, h, player) { - if (!isValidPlacement(board, x, y, w, h)) throw new Error('Invalid placement'); - const n = board.size; - for (let r = y; r < y + h; r++) { - for (let c = x; c < x + w; c++) board.cells[r * n + c] = player; - } - const rect = { x, y, w, h, player, area: w * h }; - board.rects.push(rect); - return rect; - } - - // Take a rectangle back off the board (e.g. when a player runs out of time). - function removeRect(board, rect) { - const i = board.rects.indexOf(rect); - if (i === -1) throw new Error('Rectangle is not on this board'); - board.rects.splice(i, 1); - const n = board.size; - for (let r = rect.y; r < rect.y + rect.h; r++) { - for (let c = rect.x; c < rect.x + rect.w; c++) board.cells[r * n + c] = EMPTY; - } - } - - function emptyCount(board) { - return board.cells.reduce((sum, v) => sum + (v === EMPTY ? 1 : 0), 0); - } - - function isOpen(board, c, r) { - const n = board.size; - return c >= 0 && r >= 0 && c < n && r < n && board.cells[r * n + c] === EMPTY; - } - - // Number of perimeter neighbours that are walls or already filled. - // Snug placements leave fewer awkward gaps. - function contactScore(board, { x, y, w, h }) { - let contact = 0; - for (let c = x; c < x + w; c++) { - if (!isOpen(board, c, y - 1)) contact++; - if (!isOpen(board, c, y + h)) contact++; - } - for (let r = y; r < y + h; r++) { - if (!isOpen(board, x - 1, r)) contact++; - if (!isOpen(board, x + w, r)) contact++; - } - return contact; - } - - // Count open cells that would end up fully walled in on all four sides - // (only a 1x1 roll could ever use them). - function isolatedHolesAround(board, { x, y, w, h }) { - const n = board.size; - const filled = new Set(); - for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) filled.add(r * n + c); - const open = (c, r) => isOpen(board, c, r) && !filled.has(r * n + c); - let holes = 0; - for (let r = y - 1; r <= y + h; r++) { - for (let c = x - 1; c <= x + w; c++) { - if (!open(c, r)) continue; - if (!open(c - 1, r) && !open(c + 1, r) && !open(c, r - 1) && !open(c, r + 1)) holes++; - } - } - return holes; - } - - // Computer strategy: pack rectangles tightly against walls and other - // rectangles and avoid leaving single-cell holes. Returns null if nothing fits. - function chooseComputerMove(board, a, b, rng = Math.random) { - const options = validPlacements(board, a, b); - if (options.length === 0) return null; - let best = null; - let bestScore = -Infinity; - for (const p of options) { - const perimeter = 2 * (p.w + p.h); - const score = - contactScore(board, p) / perimeter - 0.6 * isolatedHolesAround(board, p) + rng() * 0.05; - if (score > bestScore) { - bestScore = score; - best = p; - } - } - return best; - } - - function rollDie(rng = Math.random, sides = 6) { - return 1 + Math.floor(rng() * sides); - } - - // Difficulty sets the dice: easy d6, medium d8, hard d12. After Hard the - // facts stay within the times table (or just past it) but get harder: - // tricky 12-sided dice without the giveaways (no ×1, ×2, ×5, ×10, ×11) - // master 12-sided dice that lean towards the player's weakest facts - // legend two-digit × one-digit: 11–19 on one die, 2–9 on the other - // DIFFICULTY_SIDES is the biggest face (numbered dice, fit maths). - const DIFFICULTIES = ['easy', 'medium', 'hard', 'tricky', 'master', 'legend']; - const DIFFICULTY_SIDES = { easy: 6, medium: 8, hard: 12, tricky: 12, master: 12, legend: 19 }; - const TRICKY_FACES = [3, 4, 6, 7, 8, 9, 12]; - const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i); - - // The faces of each die: { a: [...], b: [...] } (the same for both except Legend). - function diceFaces(difficulty) { - if (difficulty === 'tricky') return { a: TRICKY_FACES, b: TRICKY_FACES }; - if (difficulty === 'legend') return { a: range(11, 19), b: range(2, 9) }; - const n = DIFFICULTY_SIDES[difficulty] || 6; - return { a: range(1, n), b: range(1, n) }; - } - - // The new difficulties roll both dice at once from a list of pairs (Legend's - // dice differ; Master weights each fact). Easy/Medium/Hard use createDice. - const usesPairDice = (difficulty) => ['tricky', 'master', 'legend'].includes(difficulty); - - // weight(a, b): how likely a roll is (Master: weak facts more), default even. - // Pairs that can't fit the board at all are left out (Legend on small boards). - function createPairDice(boardSize, rng = Math.random, difficulty = 'tricky', weight = null) { - const { a: A, b: B } = diceFaces(difficulty); - const pairs = []; - for (const a of A) for (const b of B) if (a <= boardSize && b <= boardSize) pairs.push([a, b]); - const w = weight || (() => 1); - return { - sides: DIFFICULTY_SIDES[difficulty], - pairs, - // a weighted pick from `from` (default: every pair) - rollPair(from = pairs) { - const total = from.reduce((sum, [a, b]) => sum + w(a, b), 0); - let r = rng() * total; - for (const [a, b] of from) if ((r -= w(a, b)) <= 0) return [a, b]; - return from[from.length - 1]; - }, - }; - } - - // Can a difficulty be played on this board at all? (Legend needs 11×11 or bigger.) - const fitsDifficulty = (difficulty, boardSize) => !usesPairDice(difficulty) || createPairDice(boardSize, Math.random, difficulty).pairs.length > 0; - - // "Loaded" dice matched to the board size, so small boards aren't swallowed - // by huge rectangles and big boards don't take ages to fill with 1x1s. - // Most rolls come from the common range; each rare face can only turn up a - // limited number of times per game. For a d6 this gives: - // small (<=13): mostly 1-4; 5 at most twice, 6 once - // medium (<=17): mostly 2-5; 1 at most once, 6 twice - // large (18+): mostly 3-6; 1 at most once, 2 twice - // and the same shape stretched over 8 or 12 sides. - function diceProfile(boardSize, sides = 6) { - const rare = {}; - let common; - if (boardSize <= 13) { - common = [1, Math.round((sides * 2) / 3)]; - for (let f = common[1] + 1; f <= sides; f++) rare[f] = f === sides ? 1 : 2; - } else if (boardSize <= 17) { - common = [Math.floor(sides / 6) + 1, sides - Math.ceil(sides / 6)]; - for (let f = 1; f < common[0]; f++) rare[f] = 1; - for (let f = common[1] + 1; f <= sides; f++) rare[f] = 2; - } else { - common = [Math.round(sides / 3) + 1, sides]; - for (let f = 1; f < common[0]; f++) rare[f] = f === 1 ? 1 : 2; - } - return { sides, common, rare }; - } - - // Chance per die of trying rare faces: about 1 in 6 overall, split between them. - const RARE_TOTAL_CHANCE = 1 / 6; - - // Returns a per-game dice bag: bag.roll() gives one die. - function createDice(boardSize, rng = Math.random, sides = 6) { - const profile = diceProfile(boardSize, sides); - const remaining = { ...profile.rare }; - const perFace = RARE_TOTAL_CHANCE / Math.max(1, Object.keys(remaining).length); - const [lo, hi] = profile.common; - return { - sides, - remaining, - roll() { - for (const face of Object.keys(remaining)) { - if (remaining[face] > 0 && rng() < perFace) { - remaining[face]--; - return Number(face); - } - } - return lo + Math.floor(rng() * (hi - lo + 1)); - }, - }; - } - - // "No room": when a roll doesn't fit anywhere the player has to pass. To keep - // that from happening while there's still space, rolls can be limited to ones - // that fit. Modes: 'end' (only near the end of the game), 'always', 'never'. - // "Near the end" means fewer empty squares than the larger of the biggest - // possible rectangle (36 / 64 / 144 for 6 / 8 / 12-sided dice) and a fifth of - // the board, so it scales with both board size and difficulty. - // The classic 12x12 board with a d6 gives 36. - const FIT_MODES = ['end', 'always', 'never']; - - function fitThreshold(boardSize, sides = 6) { - return Math.max(sides * sides, Math.round(boardSize * boardSize * 0.2)); - } - - function fitOnlyNow(board, sides = 6, mode = 'end') { - if (mode === 'never') return false; - if (mode === 'always') return true; - return emptyCount(board) < fitThreshold(board.size, sides); - } - - function canFit(board, a, b) { - for (const [w, h] of orientations(a, b)) { - for (let y = 0; y + h <= board.size; y++) { - for (let x = 0; x + w <= board.size; x++) { - if (isValidPlacement(board, x, y, w, h)) return true; - } - } - } - return false; - } - - // Every [a, b] roll (1..sides each, both orders) that fits somewhere on the board. - function fittingRolls(board, sides = 6) { - const out = []; - for (let a = 1; a <= sides; a++) { - for (let b = 1; b <= sides; b++) if (canFit(board, a, b)) out.push([a, b]); - } - return out; - } - - // Roll two dice from the bag. When fitOnlyNow() says so, re-roll until the - // result fits (without using up rare faces on rejected rolls); if the bag - // can't produce a fitting roll, pick one of the fitting rolls at random. - function rollForBoard(board, bag, rng = Math.random, mode = 'end') { - const sides = bag.sides || 6; - if (bag.rollPair) { - // pair dice: near the end (or always), only pairs that fit - if (!fitOnlyNow(board, sides, mode)) return bag.rollPair(); - const fitting = bag.pairs.filter(([a, b]) => canFit(board, a, b)); - return fitting.length ? bag.rollPair(fitting) : bag.rollPair(); - } - if (!fitOnlyNow(board, sides, mode)) return [bag.roll(), bag.roll()]; - const fitting = fittingRolls(board, sides); - if (!fitting.length) return [bag.roll(), bag.roll()]; - const ok = new Set(fitting.map(([a, b]) => `${a}x${b}`)); - for (let i = 0; i < 100; i++) { - const saved = { ...bag.remaining }; - const roll = [bag.roll(), bag.roll()]; - if (ok.has(`${roll[0]}x${roll[1]}`)) return roll; - Object.assign(bag.remaining, saved); - } - return fitting[Math.floor(rng() * fitting.length)]; - } - - const api = { - EMPTY, - createBoard, - fits, - mustUseCorner, - isValidPlacement, - orientations, - validPlacements, - place, - removeRect, - emptyCount, - chooseComputerMove, - rollDie, - DIFFICULTIES, - DIFFICULTY_SIDES, - TRICKY_FACES, - diceFaces, - usesPairDice, - createPairDice, - fitsDifficulty, - diceProfile, - createDice, - FIT_MODES, - fitThreshold, - fitOnlyNow, - fittingRolls, - rollForBoard, - }; - - if (typeof module !== 'undefined' && module.exports) module.exports = api; - else root.BlockoutCore = api; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/cosmetics.js b/js/cosmetics.js deleted file mode 100644 index b6f23e2..0000000 --- a/js/cosmetics.js +++ /dev/null @@ -1,299 +0,0 @@ -// Blockout cosmetics: the Wardrobe catalog (dice, board, effects, CPU characters, -// avatars, keypads…), seasonal items, earned titles and the sticker album. -// Paid options are added to the shop as items 'cos.<category>.<option>'. -// Loaded as a plain script in the browser (window.BlockoutCosmetics) after -// progress.js, and via require() in tests. -(function (root) { - 'use strict'; - - const Progress = typeof module !== 'undefined' && module.exports ? require('./progress.js') : root.BlockoutProgress; - - // Seasons: month-day ranges (inclusive). Winter wraps past New Year. - const SEASONS = { - halloween: { label: 'October', from: '10-01', to: '10-31' }, - winter: { label: 'December and January', from: '12-01', to: '01-31' }, - spring: { label: 'March to May', from: '03-01', to: '05-31' }, - }; - - function seasonActive(season, now = new Date()) { - const s = SEASONS[season]; - if (!s) return true; - const md = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; - return s.from <= s.to ? md >= s.from && md <= s.to : md >= s.from || md <= s.to; - } - - // Every category and its options. The first option is the free default. - // `preview` is what the Wardrobe shows on the option chip. - const CATEGORIES = [ - { - id: 'dice', name: 'Dice', options: [ - { id: 'classic', name: 'Classic', price: 0 }, - { id: 'wood', name: 'Wooden', price: 300 }, - { id: 'candy', name: 'Candy stripes', price: 500 }, - { id: 'glass', name: 'Glass', price: 600 }, - { id: 'neon', name: 'Neon', price: 800 }, - { id: 'galaxy', name: 'Galaxy', price: 1500 }, - { id: 'golden', name: 'Golden ✨', price: 10000, item: 'goldenDice' }, - { id: 'pumpkin', name: 'Pumpkin 🎃', price: 666, season: 'halloween' }, - ], - }, - { - id: 'pips', name: 'Dice dots', options: [ - { id: 'dots', name: 'Dots', price: 0, preview: '●' }, - { id: 'hearts', name: 'Hearts', price: 400, preview: '❤️', glyph: '❤️' }, - { id: 'stars', name: 'Stars', price: 400, preview: '⭐', glyph: '⭐' }, - { id: 'paws', name: 'Paw prints', price: 400, preview: '🐾', glyph: '🐾' }, - { id: 'dinos', name: 'Dinosaurs', price: 400, preview: '🦖', glyph: '🦖' }, - ], - }, - { - id: 'roll', name: 'Rolling', options: [ - { id: 'wobble', name: 'Wobble', price: 0 }, - { id: 'tumble', name: 'Tumble', price: 500 }, - { id: 'bounce', name: 'Bounce', price: 500 }, - { id: 'sparkle', name: 'Sparkle spin', price: 500 }, - ], - }, - { - id: 'colors', name: 'Player colors', options: [ - { id: 'classic', name: 'Classic', price: 0, colors: ['#e4572e', '#2e86de', '#2a9d5c', '#8e5bd6'] }, - { id: 'pastel', name: 'Pastel', price: 300, colors: ['#e07a8f', '#5b9bd5', '#4fae8b', '#9b7fd1'] }, - { id: 'ocean', name: 'Ocean', price: 300, colors: ['#0b7a9e', '#1f5fa8', '#12948a', '#4a57b8'] }, - { id: 'jungle', name: 'Jungle', price: 300, colors: ['#3f8a2e', '#b8761f', '#1e7f5c', '#8a5a2b'] }, - { id: 'candy', name: 'Candy', price: 300, colors: ['#e8467c', '#9b4dd6', '#1fa3c7', '#f08a24'] }, - ], - }, - { - id: 'pattern', name: 'Rectangle pattern', options: [ - { id: 'none', name: 'Plain', price: 0 }, - { id: 'stripes', name: 'Stripes', price: 400 }, - { id: 'dots', name: 'Polka dots', price: 400 }, - { id: 'bricks', name: 'Bricks', price: 600 }, - { id: 'checker', name: 'Checkerboard', price: 600 }, - { id: 'wood', name: 'Wood grain', price: 800 }, - ], - }, - { - id: 'board', name: 'Board', options: [ - { id: 'graph', name: 'Graph paper', price: 0 }, - { id: 'notebook', name: 'Notebook', price: 600 }, - { id: 'chalkboard', name: 'Chalkboard', price: 800 }, - { id: 'beach', name: 'Beach', price: 1000 }, - { id: 'space', name: 'Space', price: 1500 }, - { id: 'snow', name: 'Snowy ❄️', price: 900, season: 'winter' }, - ], - }, - { - id: 'place', name: 'Placing effect', options: [ - { id: 'none', name: 'None', price: 0 }, - { id: 'pop', name: 'Pop in', price: 500 }, - { id: 'ripple', name: 'Ripple', price: 500 }, - { id: 'glow', name: 'Glow', price: 500 }, - ], - }, - { - id: 'confetti', name: 'Confetti', options: [ - { id: 'paper', name: 'Paper', price: 0, preview: '🎊' }, - { id: 'stars', name: 'Stars', price: 300, preview: '⭐' }, - { id: 'hearts', name: 'Hearts', price: 300, preview: '💖' }, - { id: 'snow', name: 'Snowflakes', price: 300, preview: '❄️' }, - { id: 'coins', name: 'Coins', price: 500, preview: '🪙' }, - { id: 'emoji', name: 'Fun emoji', price: 800, preview: '🍕', glyphs: ['🍕', '🦖', '⚽', '🌈', '🍩', '🚀'] }, - { id: 'blossom', name: 'Blossoms 🌸', price: 400, preview: '🌸', season: 'spring' }, - ], - }, - { - id: 'win', name: 'Win celebration', options: [ - { id: 'confetti', name: 'Confetti', price: 0, preview: '🎉' }, - { id: 'fireworks', name: 'Fireworks', price: 1000, preview: '🎆' }, - { id: 'balloons', name: 'Balloons', price: 1000, preview: '🎈' }, - { id: 'trophies', name: 'Trophy rain', price: 1000, preview: '🏆' }, - ], - }, - { - id: 'streak', name: 'Streak counter', options: [ - { id: 'plain', name: 'Plain', price: 0, preview: '🔥' }, - { id: 'flames', name: 'Growing flames', price: 500, preview: '🔥🔥' }, - ], - }, - { - id: 'cpu', name: 'CPU character', options: [ - { id: 'robot', name: 'Robot', price: 0, avatar: '🤖', cpuName: 'CPU', hello: '', short: 'CPU' }, - { id: 'owl', name: 'Professor Owl', price: 800, avatar: '🦉', cpuName: 'Professor Owl', hello: 'Hoo-hoo!', short: 'Owl' }, - { id: 'dragon', name: 'Dragon', price: 800, avatar: '🐉', cpuName: 'Dragon', hello: 'Roar!', short: 'Dragon' }, - { id: 'cat', name: 'Whiskers the Cat', price: 800, avatar: '🐱', cpuName: 'Whiskers', hello: 'Purr…', short: 'Whiskers' }, - { id: 'alien', name: 'Zorp the Alien', price: 800, avatar: '👽', cpuName: 'Zorp', hello: 'Beep-zorp!', short: 'Zorp' }, - ], - }, - { - id: 'avatar', name: 'Your avatar', options: [ - { id: 'none', name: 'None', price: 0, preview: '—' }, - { id: 'smile', name: 'Smile', price: 0, preview: '🙂' }, - { id: 'dog', name: 'Dog', price: 0, preview: '🐶' }, - { id: 'fox', name: 'Fox', price: 0, preview: '🦊' }, - { id: 'panda', name: 'Panda', price: 0, preview: '🐼' }, - { id: 'robot', name: 'Robot', price: 300, preview: '🤖' }, - { id: 'unicorn', name: 'Unicorn', price: 500, preview: '🦄' }, - { id: 'dino', name: 'Dino', price: 500, preview: '🦖' }, - { id: 'dragon', name: 'Dragon', price: 800, preview: '🐲' }, - { id: 'crown', name: 'Crown', price: 1000, preview: '👑' }, - ], - }, - { - id: 'keypad', name: 'Keypad', options: [ - { id: 'classic', name: 'Classic', price: 0 }, - { id: 'candy', name: 'Candy', price: 400 }, - { id: 'retro', name: 'Retro calculator', price: 400 }, - { id: 'neon', name: 'Neon', price: 400 }, - ], - }, - ]; - - const DEFAULTS = Object.fromEntries(CATEGORIES.map((c) => [c.id, c.options[0].id])); - - function category(id) { - return CATEGORIES.find((c) => c.id === id) || null; - } - - function option(categoryId, optionId) { - const cat = category(categoryId); - return (cat && cat.options.find((o) => o.id === optionId)) || null; - } - - // Shop item for a paid option (null when free). - function itemFor(categoryId, optionId) { - const opt = option(categoryId, optionId); - if (!opt || !opt.price) return null; - return opt.item || `cos.${categoryId}.${optionId}`; - } - - // Add every paid option to the shop (once), grouped by category. - // Each category's paid looks are a chain (a skill tree, like the main menu): - // each needs the one listed before it, so only the next one shows what it is - // and what it costs. Seasonal looks stay outside the chains. - function registerShopItems(shop) { - for (const cat of CATEGORIES) { - let previous = null; - for (const opt of cat.options) { - const id = itemFor(cat.id, opt.id); - if (!id) continue; - const existing = shop.find((i) => i.id === id); - const fields = { group: `Wardrobe: ${cat.name}`, name: `${cat.name}: ${opt.name}`, price: opt.price, cosmetic: [cat.id, opt.id] }; - if (opt.season) { - fields.season = opt.season; - fields.seasonLabel = SEASONS[opt.season].label; - fields.available = (now) => seasonActive(opt.season, now); - } else { - if (previous) fields.requires = previous; - previous = id; - } - if (existing) Object.assign(existing, fields); - else shop.push({ id, ...fields }); - } - } - } - - // Keep only choices the player owns; anything else goes back to the default. - function sanitize(chosen, isUnlocked) { - const out = { ...DEFAULTS }; - for (const cat of CATEGORIES) { - const want = chosen && chosen[cat.id]; - if (want && option(cat.id, want) && isUnlocked(itemFor(cat.id, want))) out[cat.id] = want; - } - return out; - } - - // ---------------------------------------------------------------- titles (earned, never bought) - - const count = (state, prefix, suffix = '') => - Object.keys(state.achievements || {}).filter((id) => id.startsWith(prefix) && id.endsWith(suffix)).length; - - const TITLES = [ - { id: 'none', name: 'No title', need: 'Always available', earned: () => true }, - { id: 'learner', name: 'Times Table Learner', need: 'Earn any ×N learner achievement', earned: (s) => count(s, 'table', '_learn') >= 1 }, - { id: 'ninja', name: 'Times Table Ninja', need: 'Earn 5 ×N master achievements', earned: (s) => count(s, 'table', '_master') >= 5 }, - { id: 'speed', name: 'Speed Demon', need: 'Earn 20 fact speed "under 1 s" tiers', earned: (s) => count(s, 'fact_', '_1') >= 20 }, - { id: 'royalty', name: 'Rectangle Royalty', need: 'Earn the Champion score achievement', earned: (s) => Boolean((s.achievements || {}).score400) }, - { id: 'streaker', name: 'Streak Star', need: 'Earn the On fire achievement', earned: (s) => Boolean((s.achievements || {}).streak10) }, - { id: 'collector', name: 'Sticker Collector', need: 'Collect 15 different stickers', earned: (s) => Object.keys(s.stickers || {}).length >= 15 }, - ]; - - function title(id) { - return TITLES.find((t) => t.id === id) || TITLES[0]; - } - - // ---------------------------------------------------------------- stickers - - // Every (non-minor) achievement drops one random sticker. Rarer stickers - // drop less often and trade for more points. - const RARITY = { - common: { weight: 10, trade: 5 }, - rare: { weight: 4, trade: 15 }, - epic: { weight: 1, trade: 40 }, - }; - const STICKERS = [ - ['apple', '🍎', 'common'], ['cat', '🐱', 'common'], ['dog', '🐶', 'common'], ['frog', '🐸', 'common'], - ['sun', '☀️', 'common'], ['flower', '🌼', 'common'], ['ball', '⚽', 'common'], ['pizza', '🍕', 'common'], - ['star', '⭐', 'common'], ['rainbow', '🌈', 'common'], ['bee', '🐝', 'common'], ['fish', '🐠', 'common'], - ['owl', '🦉', 'rare'], ['rocket', '🚀', 'rare'], ['octopus', '🐙', 'rare'], ['cactus', '🌵', 'rare'], - ['guitar', '🎸', 'rare'], ['volcano', '🌋', 'rare'], ['ufo', '🛸', 'rare'], ['penguin', '🐧', 'rare'], - ['unicorn', '🦄', 'epic'], ['dragon', '🐉', 'epic'], ['crown', '👑', 'epic'], ['gem', '💎', 'epic'], - ].map(([id, emoji, rarity]) => ({ id, emoji, rarity })); - - function dropSticker(state, rng = Math.random) { - const total = STICKERS.reduce((sum, s) => sum + RARITY[s.rarity].weight, 0); - let r = rng() * total; - let pick = STICKERS[STICKERS.length - 1]; - for (const s of STICKERS) { - r -= RARITY[s.rarity].weight; - if (r < 0) { - pick = s; - break; - } - } - state.stickers = state.stickers || {}; - state.stickers[pick.id] = (state.stickers[pick.id] || 0) + 1; - return { sticker: pick, isNew: state.stickers[pick.id] === 1 }; - } - - // Points you'd get for trading every duplicate (keeping one of each). - function duplicateValue(state) { - let points = 0; - for (const s of STICKERS) { - const extra = Math.max(0, ((state.stickers || {})[s.id] || 0) - 1); - points += extra * RARITY[s.rarity].trade; - } - return points; - } - - function tradeDuplicates(state) { - const points = duplicateValue(state); - for (const s of STICKERS) if ((state.stickers || {})[s.id] > 1) state.stickers[s.id] = 1; - Progress.addPoints(state, points); - return points; - } - - const api = { - SEASONS, - seasonActive, - CATEGORIES, - DEFAULTS, - category, - option, - itemFor, - registerShopItems, - sanitize, - TITLES, - title, - RARITY, - STICKERS, - dropSticker, - duplicateValue, - tradeDuplicates, - }; - - registerShopItems(Progress.SHOP); - - if (typeof module !== 'undefined' && module.exports) module.exports = api; - else root.BlockoutCosmetics = api; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/game.js b/js/game.js deleted file mode 100644 index c0da57c..0000000 --- a/js/game.js +++ /dev/null @@ -1,6280 +0,0 @@ -(function () { - 'use strict'; - - const Core = window.BlockoutCore; - const Progress = window.BlockoutProgress; - const Invite = window.BlockoutInvite; - const Cosmetics = window.BlockoutCosmetics; - const seededRandom = Invite.seededRandom; - - const DEFAULT_NAMES = ['Player 1', 'Player 2', 'Player 3', 'Player 4']; - const PIPS = { - 1: [4], - 2: [0, 8], - 3: [0, 4, 8], - 4: [0, 2, 6, 8], - 5: [0, 2, 4, 6, 8], - 6: [0, 2, 3, 5, 6, 8], - }; - // How long each computer action takes (ms): every explanation step, - // every square it draws and every number it counts. - const CPU_STEP_MS = { slow: 2000, normal: 1000, fast: 500 }; - const COUNT_STEP = 850; // ms between skip-counting steps when helping a human - const SETTINGS_KEY = 'blockout.settings'; - const DEFAULT_SETTINGS = { - firstInCorner: false, - cpuSpeed: 'normal', - placeMode: 'draw', - diceMode: 'virtual', - cpuEnd: 'auto', // 'auto': next turn starts by itself; 'button': wait for "My turn" - showProgress: false, // "Board: X of Y filled" meter in the header - answerTime: '0', // seconds to answer the multiplication; '0' = no timer - difficulty: 'easy', // easy d6, medium d8, hard d12 - cpuSteps: 'show', // 'show': CPU explains step by step; 'instant': CPU plays straight away - autoRoll: 'manual', // 'manual': press Roll dice; 'auto': the dice roll by themselves - fitRolls: 'end', // "no room": only roll what fits 'end' (near the end), 'always' or 'never' - // Times-table grid cells: 'icon' (✓ ~ !) or 'time' (best time), per status - timesMastered: 'icon', - timesLearning: 'icon', - timesPractice: 'icon', - theme: 'auto', // 'auto' follows the device; 'light' or 'dark' forces one (free, not in the shop) - }; - // Board sizes other than the small one are bought in the shop. - // Only the smallest board is free; every other size is bought in the shop. - const BOARD_UNLOCK = { 6: null, 8: 'board8', 10: 'board10', 12: 'board12', 16: 'board16', 20: 'board20', 24: 'board24', custom: 'boardCustom' }; - const PROGRESS_KEY = 'blockout.progress'; - const PASS_DELAY = 1600; - const AUTO_PLACE_MS = 900; // how long an auto-placed rectangle is previewed - const AUTO_ROLL_MS = 700; // pause before auto roll, so "Your turn" can be read first - const TRY_AGAIN = ['Not quite — try again!', 'Almost! Have another go.', 'Hmm, not that one. Try again!']; - - const $ = (id) => document.getElementById(id); - const el = { - startScreen: $('start-screen'), - gameScreen: $('game-screen'), - modeCards: document.querySelectorAll('.mode-card'), - singleSetup: $('single-setup'), - multiSetup: $('multi-setup'), - multiKind: $('multi-kind'), - localSetup: $('local-setup'), - classroomSetup: $('classroom-setup'), - classJoinOpen: $('class-join-open'), - classHostOpen: $('class-host-open'), - classOffline: $('class-offline'), - classJoin: $('class-join'), - classJoinForm: $('class-join-form'), - classCodeInput: $('class-code-input'), - classNameInput: $('class-name-input'), - classJoinError: $('class-join-error'), - classJoinBtn: $('class-join-btn'), - classJoinCancel: $('class-join-cancel'), - classWait: $('class-wait'), - classWaitName: $('class-wait-name'), - classWaitText: $('class-wait-text'), - classLeaveBtn: $('class-leave-btn'), - hostSetup: $('class-host-setup'), - hostSetupTitle: $('class-host-title'), - hostSize: $('host-size'), - hostTypeField: $('host-type-field'), - hostType: $('host-type'), - hostTypeHelp: $('host-type-help'), - hostDifficulty: $('host-difficulty'), - hostRounds: $('host-rounds'), - hostError: $('host-error'), - hostCancel: $('host-cancel'), - hostCreate: $('host-create'), - hostScreen: $('class-host-screen'), - hostCodeSmall: $('host-code-small'), - hostLobby: $('host-lobby'), - hostQr: $('host-qr'), - hostUrl: $('host-url'), - hostCode: $('host-code'), - hostSettingsGrid: $('host-settings-grid'), - hostQuick: $('host-quick'), - hostStartBtn: $('host-start-btn'), - hostLocalHelp: $('host-local-help'), - hostPlay: $('host-play'), - hostRound: $('host-round'), - hostDieA: $('host-die-a'), - hostDieB: $('host-die-b'), - hostRollText: $('host-roll-text'), - hostDoneFill: $('host-done-fill'), - hostDoneText: $('host-done-text'), - hostNextBtn: $('host-next-btn'), - hostEndBtn: $('host-end-btn'), - hostAuto: $('host-auto'), - hostEnded: $('host-ended'), - hostPodium: $('host-podium'), - hostReport: $('host-report'), - hostHardest: $('host-hardest'), - hostAgainBtn: $('host-again-btn'), - hostClose2Btn: $('host-close2-btn'), - hostCloseBtn: $('host-close-btn'), - hostRosterTitle: $('host-roster-title'), - hostRoster: $('host-roster'), - hostPairs: $('host-pairs'), - hostMatching: $('host-matching'), - hostOdd: $('host-odd'), - hostPairsHelp: $('host-pairs-help'), - hostPairList: $('host-pair-list'), - hostTeacherDevice: $('host-teacher-device'), - hostTeacherQr: $('host-teacher-qr'), - hostTeacherUrl: $('host-teacher-url'), - hostTeacherStatus: $('host-teacher-status'), - hostMyGameBtn: $('host-mygame-btn'), - hostCopyLink: $('host-copy-link'), - hostCopyMsg: $('host-copy-msg'), - hostShareBtn: $('host-share-btn'), - hostCopyTeacher: $('host-copy-teacher'), - hostMatches: $('host-matches'), - hostDice: $('host-dice'), - hostProgress: $('host-progress'), - hostAutoLabel: $('host-auto-label'), - hostMatchResults: $('host-match-results'), - hostTournament: $('host-tournament'), - hostTournamentFinal: $('host-tournament-final'), - resultTournament: $('result-tournament'), - singleName: $('single-name'), - playerCount: $('player-count'), - nameInputs: $('name-inputs'), - namesError: $('names-error'), - boardSize: $('board-size'), - gameLevel: $('game-level'), - gameLevelHelp: $('game-level-help'), - gameTimerField: $('game-timer-field'), - gameTimer: $('game-timer'), - gameTimerHelp: $('game-timer-help'), - boardSizeHelp: $('board-size-help'), - customSize: $('custom-size'), - customSizeInput: $('custom-size-input'), - customSizeEcho: $('custom-size-echo'), - setupForm: $('setup-form'), - menuBtn: $('menu-btn'), - progress: $('progress'), - progressText: $('progress-text'), - progressBar: $('progress-bar'), - boardWrap: $('board-wrap'), - canvas: $('board'), - turnPanel: $('turn-panel'), - turnLabel: $('turn-label'), - dieA: $('die-a'), - dieB: $('die-b'), - turnMsg: $('turn-msg'), - rollBtn: $('roll-btn'), - rotateBtn: $('rotate-btn'), - math: $('math'), - mathQ: $('math-q'), - answerBox: $('answer-box'), - countTrail: $('count-trail'), - mathFeedback: $('math-feedback'), - keypad: $('keypad'), - helpBtn: $('help-btn'), - steps: $('steps'), - diceEntry: $('dice-entry'), - diePicks: document.querySelectorAll('.die-pick'), - useDiceBtn: $('use-dice'), - continueBtn: $('continue-btn'), - timer: $('timer'), - timerFill: $('timer-fill'), - timerNum: $('timer-num'), - stats: $('stats'), - detailsBtn: $('details-btn'), - scores: $('scores'), - gameOver: $('game-over'), - resultTitle: $('result-title'), - resultSub: $('result-sub'), - resultList: $('result-list'), - againBtn: $('again-btn'), - toMenuBtn: $('to-menu-btn'), - settingsBtn: $('settings-btn'), - historyBtn: $('history-btn'), - historyDialog: $('history'), - historyBody: $('history-body'), - historyDone: $('history-done'), - resetHistory: $('reset-history'), - settingsDialog: $('settings'), - walletAmount: $('wallet-amount'), - wardrobeBtn: $('wardrobe-btn'), - unlockDialog: $('unlock'), - unlockTitle: $('unlock-title'), - unlockPrice: $('unlock-price'), - unlockMessage: $('unlock-message'), - unlockYes: $('unlock-yes'), - unlockNo: $('unlock-no'), - wardrobeDialog: $('wardrobe'), - wardrobeBody: $('wardrobe-body'), - wardrobeDone: $('wardrobe-done'), - stickersBtn: $('stickers-btn'), - stickersDialog: $('stickers'), - stickersBody: $('stickers-body'), - stickersTrade: $('stickers-trade'), - stickersDone: $('stickers-done'), - gameSettingsBtn: $('game-settings-btn'), - practiceSetup: $('practice-setup'), - practiceName: $('practice-name'), - practiceCount: $('practice-count'), - practiceCountHelp: $('practice-count-help'), - practiceTimerField: $('practice-timer-field'), - practiceTimer: $('practice-timer'), - practiceTimerHelp: $('practice-timer-help'), - practicePreview: $('practice-preview'), - practiceTables: $('practice-tables'), - practiceLevel: $('practice-level'), - practiceLevelHelp: $('practice-level-help'), - difficultyField: $('difficulty-field'), - boardSizeField: $('board-size-field'), - startBtn: $('start-btn'), - practiceScreen: $('practice-screen'), - practiceMenuBtn: $('practice-menu-btn'), - practiceSettingsBtn: $('practice-settings-btn'), - prCounter: $('pr-counter'), - prStreak: $('pr-streak'), - streakBadge: $('streak-badge'), - prBar: $('pr-bar'), - prArray: $('pr-array'), - prTimer: $('pr-timer'), - prTimerFill: $('pr-timer-fill'), - prTimerNum: $('pr-timer-num'), - prQ: $('pr-q'), - prAnswer: $('pr-answer'), - prTrail: $('pr-trail'), - prFeedback: $('pr-feedback'), - prKeypad: $('pr-keypad'), - prHelp: $('pr-help'), - practiceDone: $('practice-done'), - pdSub: $('pd-sub'), - pdBody: $('pd-body'), - pdDetailsBtn: $('pd-details-btn'), - pdShopBtn: $('pd-shop-btn'), - resultsShopBtn: $('results-shop-btn'), - pdAgain: $('pd-again'), - pdMenu: $('pd-menu'), - settingsNote: $('settings-note'), - toasts: $('toasts'), - shopBtn: $('shop-btn'), - shopDialog: $('shop'), - shopBody: $('shop-body'), - shopDone: $('shop-done'), - achievementsBtn: $('achievements-btn'), - achievementsDialog: $('achievements'), - achievementsBody: $('achievements-body'), - achievementsDone: $('achievements-done'), - rewards: $('rewards'), - inviteBtn: $('invite-btn'), - inviteMake: $('invite-make'), - inviteCodeInput: $('invite-code-input'), - inviteCodeOpen: $('invite-code-open'), - inviteCodeError: $('invite-code-error'), - invitePlayers: $('invite-players'), - inviteNote: $('invite-note'), - inviteBoard: $('invite-board'), - inviteCustomField: $('invite-custom-field'), - inviteCustom: $('invite-custom'), - inviteSeed: $('invite-seed'), - inviteCreate: $('invite-create'), - inviteResult: $('invite-result'), - inviteLink: $('invite-link'), - inviteCode: $('invite-code'), - inviteLocalWarning: $('invite-local-warning'), - inviteTry: $('invite-try'), - inviteQrWrap: $('invite-qr-wrap'), - inviteQr: $('invite-qr'), - inviteQrSave: $('invite-qr-save'), - inviteQrPrint: $('invite-qr-print'), - inviteDone: $('invite-done'), - inviteOpen: $('invite-open'), - inviteOpenNote: $('invite-open-note'), - inviteOpenSummary: $('invite-open-summary'), - inviteOpenNameField: $('invite-open-name-field'), - inviteOpenName: $('invite-open-name'), - inviteStart: $('invite-start'), - inviteCancel: $('invite-cancel'), - settingsDone: $('settings-done'), - cornerRuleHint: $('corner-rule-hint'), - }; - const ctx = el.canvas.getContext('2d'); - - // ---------------------------------------------------------------- setup - - // types[i] is 'human' or 'cpu' for each multiplayer slot. - // names start empty: multiplayer players type their own ("Player N" is only a placeholder) - // multiKind: 'local' (one screen) or 'classroom' (not built yet) - // level/timer: single player's Learn / Expert and Expert's answer timer (seconds) - const setup = { mode: 'single', multiKind: 'local', level: 'learn', timer: 0, count: 2, size: 6, names: ['', '', '', ''], types: ['human', 'human', 'human', 'human'] }; - - // ---------------------------------------------------------------- progress (wallet, unlocks, achievements) - - const progress = loadProgress(); - - function loadProgress() { - try { - return Progress.normalize(JSON.parse(localStorage.getItem(PROGRESS_KEY))); - } catch (_) { - return Progress.createProgress(); - } - } - - function saveProgress() { - try { - localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); - } catch (_) { - // not fatal: progress just won't persist - } - renderWallet(); - } - - function renderWallet() { - el.walletAmount.textContent = formatPoints(progress.wallet); - updateMenuBadges(); - } - - // iPhone-style red count badges on the main-menu Shop, Wardrobe and Stickers buttons. - function updateMenuBadges() { - if (!Cosmetics || !el.shopBtn) return; - setBadge(el.shopBtn, 'Shop', affordableCount(), 'you can unlock'); - setBadge(el.wardrobeBtn, 'Wardrobe', newWardrobe() + affordableLooks(), 'new or ready to unlock'); - setBadge(el.stickersBtn, 'Stickers', newStickers() + spareStickers(), 'new or spare'); - } - - function setBadge(btn, label, count, what) { - let badge = btn.querySelector('.badge'); - if (!count) { - if (badge) badge.remove(); - btn.removeAttribute('aria-label'); - return; - } - if (!badge) { - badge = make('span', 'badge'); - badge.setAttribute('aria-hidden', 'true'); - btn.append(badge); - } - badge.textContent = count > 99 ? '99+' : String(count); - btn.setAttribute('aria-label', `${label}, ${count} ${what}`); - } - - // 10000 → "10,000" (in the reader's locale). - function formatPoints(n) { - return Number(n).toLocaleString(); - } - - // Show newly earned achievements as toasts; returns them for the caller. - // Every big (non-minor) achievement drops one random sticker. - function grantStickers(earned) { - return earned.filter((a) => !a.minor).map(() => Cosmetics.dropSticker(progress)); - } - - function celebrate(earned) { - queueAchievements(earned); - for (const { sticker, isNew } of grantStickers(earned)) { - toast(`🎁 ${isNew ? 'New sticker' : 'Sticker'}: ${sticker.emoji}`, isNew ? `${sticker.rarity} · added to your album` : `a spare you can swap for points`); - } - // one full-screen celebration (bigger for several), not for minor ones like fact speed - const major = earned.filter((a) => !a.minor).length; - if (major) confettiFullScreen(major); - return earned; - } - - // ---- confetti: paper bits that fly, then fall and flutter. Every toast gets a - // burst from itself; achievements also get a full-screen celebration. - // Drawn on a see-through canvas that ignores clicks; skipped for reduced motion. - const CONFETTI_COLORS = ['#e4572e', '#2e86de', '#2a9d5c', '#8e5bd6', '#f2c94c', '#ff8fab']; - const confetti = { canvas: null, ctx: null, parts: [], frame: null, last: 0 }; - // Confetti doesn't need retina sharpness; 1× keeps the full-screen canvas small and fast. - const CONFETTI_DPR = 1; - // Emoji pieces are bigger than paper, so half as many look just as full (and draw faster). - const pieceCount = (n) => Math.round(n * (confettiGlyphs() ? 0.5 : 1)); - - // Make sure the canvas exists and matches the window; false when motion is reduced. - function confettiReady() { - if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false; - if (!confetti.canvas) { - confetti.canvas = document.createElement('canvas'); - confetti.canvas.className = 'confetti'; - confetti.canvas.setAttribute('aria-hidden', 'true'); - document.body.append(confetti.canvas); - confetti.ctx = confetti.canvas.getContext('2d'); - } - const dpr = CONFETTI_DPR; - const w = Math.round(innerWidth * dpr); - const h = Math.round(innerHeight * dpr); - if (confetti.canvas.width !== w || confetti.canvas.height !== h) { - confetti.canvas.width = w; - confetti.canvas.height = h; - } - confetti.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - return true; - } - - // Wardrobe confetti: paper, or emoji pieces (stars, hearts, snowflakes, coins…) - const CONFETTI_GLYPHS = { - stars: ['⭐', '🌟'], - hearts: ['💖', '💗', '❤️'], - snow: ['❄️'], - coins: ['🪙'], - blossom: ['🌸', '🌼'], - }; - const pick = (list) => list[Math.floor(Math.random() * list.length)]; - - function confettiGlyphs() { - const kind = settings.cosmetics.confetti; - return kind === 'emoji' ? look('confetti').glyphs : CONFETTI_GLYPHS[kind] || null; - } - - // Emoji drawn once per glyph and size into a small canvas, then stamped with - // drawImage. Drawing colour emoji as text every frame is very slow. - const glyphSprites = new Map(); - function glyphSprite(glyph, size) { - const px = Math.round(size); - const key = `${glyph}|${px}`; - let sprite = glyphSprites.get(key); - if (!sprite) { - const dpr = CONFETTI_DPR; - const cssSize = Math.ceil(px * 2.6); - sprite = document.createElement('canvas'); - sprite.width = sprite.height = Math.ceil(cssSize * dpr); - sprite.cssSize = cssSize; - const s = sprite.getContext('2d'); - s.scale(dpr, dpr); - s.font = `${px * 2}px serif`; - s.textAlign = 'center'; - s.textBaseline = 'middle'; - s.fillText(glyph, cssSize / 2, cssSize / 2 + px * 0.1); - glyphSprites.set(key, sprite); - } - return sprite; - } - - // extra: { glyphs, gravity, size } for special effects (balloons, trophies, fireworks) - function addPiece(x, y, angleDeg, speed, maxAge = 200, extra = {}) { - const angle = (angleDeg * Math.PI) / 180; - const glyphs = extra.glyphs !== undefined ? extra.glyphs : confettiGlyphs(); - confetti.parts.push({ - x, - y, - vx: Math.cos(angle) * speed, - vy: Math.sin(angle) * speed, - size: extra.size || (glyphs ? 9 + Math.random() * 6 : 5 + Math.random() * 6), - spin: Math.random() * Math.PI, - vspin: (Math.random() - 0.5) * (glyphs ? 0.12 : 0.4), - wobble: Math.random() * Math.PI * 2, - color: extra.color || CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)], - round: Math.random() < 0.3, - glyph: glyphs ? pick(glyphs) : null, - gravity: extra.gravity !== undefined ? extra.gravity : 0.28, - age: 0, - maxAge, - }); - } - - function startConfetti() { - if (!confetti.frame) { - confetti.last = performance.now(); - confetti.frame = requestAnimationFrame(stepConfetti); - } - } - - // A burst shooting up out of an element (mostly up, leaning right). - function confettiFrom(element, strength = 1) { - if (!confettiReady()) return; - const box = element.getBoundingClientRect(); - const count = pieceCount(Math.min(90 + 40 * (strength - 1), 200)); - for (let i = 0; i < count; i++) { - addPiece(box.left + Math.random() * box.width, box.top + box.height * 0.3, -90 + (Math.random() - 0.35) * 110, 7 + Math.random() * 9); - } - startConfetti(); - } - - // Winning: the Wardrobe's win celebration. - function celebrateWin() { - if (!confettiReady()) return; - const w = innerWidth; - const h = innerHeight; - const style = settings.cosmetics.win; - if (style === 'fireworks') { - for (let burst = 0; burst < 6; burst++) { - setTimeout(() => { - if (!confettiReady()) return; - const x = w * (0.15 + Math.random() * 0.7); - const y = h * (0.15 + Math.random() * 0.35); - const color = pick(CONFETTI_COLORS); - for (let i = 0; i < 70; i++) { - addPiece(x, y, (i / 70) * 360, 3 + Math.random() * 5, 90 + Math.random() * 40, { glyphs: null, gravity: 0.1, color, size: 4 }); - } - startConfetti(); - }, burst * 380); - } - } else if (style === 'balloons') { - for (let i = 0; i < 36; i++) { - addPiece(Math.random() * w, h + 20 + Math.random() * h * 0.6, -90 + (Math.random() - 0.5) * 16, 2 + Math.random() * 2.5, 520, { glyphs: ['🎈'], gravity: -0.01, size: 14 + Math.random() * 8 }); - } - startConfetti(); - } else if (style === 'trophies') { - for (let i = 0; i < 90; i++) { - addPiece(Math.random() * w, -20 - Math.random() * h * 0.9, 90 + (Math.random() - 0.5) * 20, 1 + Math.random() * 3, 420, { glyphs: ['🏆', '🪙', '⭐', '🥇'] }); - } - startConfetti(); - } else { - confettiFullScreen(3); - } - } - - // Full screen: a shower from the top across the whole width, plus a cannon - // in each bottom corner firing towards the middle. - function confettiFullScreen(strength = 1) { - if (!confettiReady()) return; - const w = innerWidth; - const h = innerHeight; - const shower = pieceCount(Math.min(Math.round((w / 6) * (0.8 + 0.3 * strength)), 420)); - for (let i = 0; i < shower; i++) { - // start above the screen at different heights so it keeps raining for a while - addPiece(Math.random() * w, -20 - Math.random() * h * 0.9, 90 + (Math.random() - 0.5) * 30, 1 + Math.random() * 3, 420); - } - const perCannon = pieceCount(Math.min(70 + 30 * (strength - 1), 150)); - const reach = Math.sqrt(h) * 0.75; // strong enough to reach the upper half on any screen - for (let i = 0; i < perCannon; i++) { - addPiece(0, h, -60 + (Math.random() - 0.5) * 30, reach * (0.7 + Math.random() * 0.5), 300); - addPiece(w, h, -120 + (Math.random() - 0.5) * 30, reach * (0.7 + Math.random() * 0.5), 300); - } - startConfetti(); - } - - function stepConfetti(now) { - const dt = Math.min(2, (now - confetti.last) / 16.7); // in 60fps frames, capped after tab switches - confetti.last = now; - const g = confetti.ctx; - g.clearRect(0, 0, innerWidth, innerHeight); - confetti.parts = confetti.parts.filter((p) => (p.y < innerHeight + 20 || p.vy < 0) && p.y > -120 && p.age < p.maxAge); - const dpr = CONFETTI_DPR; - const air = Math.pow(0.985, dt); - for (const p of confetti.parts) { - p.age += dt; - p.vy += p.gravity * dt; // gravity (balloons have a little negative gravity) - p.vx *= air; - p.vy = Math.min(p.vy, 6); // paper falls slowly - p.wobble += 0.15 * dt; - p.x += (p.vx + Math.sin(p.wobble) * 0.8) * dt; - p.y += p.vy * dt; - p.spin += p.vspin * dt; - // Position each piece with one setTransform (no save/restore per piece: much faster) - const cos = Math.cos(p.spin) * dpr; - const sin = Math.sin(p.spin) * dpr; - g.globalAlpha = Math.min(1, (p.maxAge - p.age) / 40); - if (p.glyph) { - // emoji: stamp a pre-drawn image instead of drawing the text every frame - const sprite = glyphSprite(p.glyph, p.size); - g.setTransform(cos, sin, -sin, cos, p.x * dpr, p.y * dpr); - g.drawImage(sprite, -sprite.cssSize / 2, -sprite.cssSize / 2, sprite.cssSize, sprite.cssSize); - } else if (p.round) { - g.setTransform(dpr, 0, 0, dpr, p.x * dpr, p.y * dpr); - g.fillStyle = p.color; - g.beginPath(); - g.arc(0, 0, p.size / 2.4, 0, Math.PI * 2); - g.fill(); - } else { - const flip = Math.cos(p.wobble); // flipping paper - g.setTransform(cos, sin, -sin * flip, cos * flip, p.x * dpr, p.y * dpr); - g.fillStyle = p.color; - g.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2); - } - } - g.globalAlpha = 1; - g.setTransform(dpr, 0, 0, dpr, 0, 0); - if (confetti.parts.length) confetti.frame = requestAnimationFrame(stepConfetti); - else { - confetti.frame = null; - g.clearRect(0, 0, innerWidth, innerHeight); - } - } - - const BONUS_TOASTS = { first: '✨', speedy: '⚡', streak: '🔥', timer: '⏱️' }; - - function capitalize(text) { - return text.charAt(0).toUpperCase() + text.slice(1); - } - - // Small pop-up message in the corner that disappears by itself. - // ---- achievements: a big banner at the bottom, one at a time, with confetti - - const achievementQueue = []; - let bannerUp = false; - const BANNER_MS = 3200; - const MINOR_BANNER_MS = 1800; - - // Minor ones in a row (fact speed badges) share one smaller banner. - function queueAchievements(list) { - for (const a of list) { - const last = achievementQueue[achievementQueue.length - 1]; - if (a.minor && last && last.minor) { - last.count++; - last.reward += a.reward; - continue; - } - achievementQueue.push({ icon: a.icon, name: a.name, reward: a.reward, minor: Boolean(a.minor), count: 1 }); - } - if (!bannerUp) showNextAchievement(); - } - - function showNextAchievement() { - const a = achievementQueue.shift(); - if (!a) { - bannerUp = false; - return; - } - bannerUp = true; - const box = make('div', 'achievement-banner' + (a.minor ? ' minor' : '')); - box.setAttribute('role', 'status'); - const text = make('div', 'banner-text'); - text.append( - make('span', 'banner-kicker', a.minor ? 'Badge earned' : 'Achievement earned'), - make('strong', 'banner-name', a.count > 1 ? `+${a.count} fact speed badges` : a.name), - make('span', 'banner-reward', `+${formatPoints(a.reward)} points`) - ); - box.append(make('span', 'banner-icon', a.icon), text); - document.body.append(box); - requestAnimationFrame(() => box.classList.add('show')); - setTimeout(() => confettiFrom(box), 250); - let gone = false; - const next = () => { - if (gone) return; - gone = true; - box.classList.remove('show'); - box.classList.add('leaving'); - setTimeout(() => { - box.remove(); - showNextAchievement(); - }, 350); - }; - // (clicks go straight through the banner, so it never blocks the buttons under it) - setTimeout(next, a.minor ? MINOR_BANNER_MS : BANNER_MS); - } - - function toast(title, detail = '') { - const box = document.createElement('div'); - box.className = 'toast'; - box.setAttribute('role', 'status'); - const strong = document.createElement('strong'); - strong.textContent = title; - box.append(strong); - if (detail) { - const small = document.createElement('span'); - small.textContent = detail; - box.append(small); - } - el.toasts.append(box); - // Keep at most 4 on screen: the oldest goes first. - const all = el.toasts.querySelectorAll('.toast:not(.leaving)'); - if (all.length > 4) all[0].remove(); - setTimeout(() => box.classList.add('leaving'), 3600); - setTimeout(() => box.remove(), 4000); - confettiFrom(box); // every toast gets a little burst - return box; - } - - // ---------------------------------------------------------------- settings - - const settings = loadSettings(); - - function loadSettings() { - try { - const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY)); - if (saved) return { ...DEFAULT_SETTINGS, ...saved }; - } catch (_) { - // storage unavailable or corrupt: fall back to defaults - } - return { ...DEFAULT_SETTINGS }; - } - - function saveSettings() { - try { - localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); - } catch (_) { - // not fatal: settings just won't persist - } - } - - function cpuStep() { - return CPU_STEP_MS[(game || settings).cpuSpeed] || CPU_STEP_MS.normal; - } - - // ---------------------------------------------------------------- wardrobe (cosmetics) - - // settings.cosmetics: { dice, pips, roll, colors, pattern, board, place, confetti, - // win, streak, cpu, avatar, keypad, title }. Only owned choices are kept. - function loadCosmetics() { - const chosen = { ...(settings.cosmetics || {}) }; - if (settings.diceStyle === 'golden' && !chosen.dice) chosen.dice = 'golden'; // old Dice style setting - delete settings.diceStyle; - const clean = Cosmetics.sanitize(chosen, (id) => Progress.isUnlocked(progress, id)); - clean.title = Cosmetics.title(chosen.title).earned(progress) ? Cosmetics.title(chosen.title).id : 'none'; - settings.cosmetics = clean; - } - loadCosmetics(); - - const look = (categoryId) => Cosmetics.option(categoryId, settings.cosmetics[categoryId]) || Cosmetics.category(categoryId).options[0]; - - function chooseCosmetic(categoryId, optionId) { - settings.cosmetics[categoryId] = optionId; - // bought or worn while the Wardrobe is open: you've seen it, so it isn't "new" - if (!el.wardrobeDialog.hidden) markSeen('wardrobe', [`${categoryId}:${optionId}`]); - saveSettings(); - applyCosmeticsToPage(); - if (!el.wardrobeDialog.hidden) renderWardrobe(); - } - - // Keypads get a skin class; dice get theirs when drawn; the board is redrawn. - function applyCosmeticsToPage(redraw = true) { - for (const pad of document.querySelectorAll('.keypad')) { - pad.className = `keypad skin-${settings.cosmetics.keypad}`; - } - if (redraw) drawBoard(); - } - - // ---- Wardrobe dialog - - function chipPreview(cat, opt) { - const box = make('span', 'chip-preview'); - if (cat.id === 'dice') { - const die = make('span', `die mini skin-${opt.id}`); - renderDie(die, 5, 6, opt.id); - box.append(die); - } else if (cat.id === 'colors') { - for (const c of opt.colors) { - const sw = make('span', 'swatch'); - sw.style.background = c; - box.append(sw); - } - } else if (cat.id === 'pattern' || cat.id === 'board') { - box.append(make('span', `tile-preview ${cat.id}-${opt.id}`)); - } else if (cat.id === 'keypad') { - box.append(make('span', `key-preview skin-${opt.id}`, '7')); - } else if (cat.id === 'roll' || cat.id === 'place') { - box.append(make('span', `motion-preview ${cat.id}-${opt.id}`, cat.id === 'roll' ? '🎲' : '▦')); - } else if (cat.id === 'cpu') { - box.append(make('span', 'emoji-preview', opt.avatar)); - } else { - box.append(make('span', 'emoji-preview', opt.preview || '')); - } - return box; - } - - let wardrobeFilter = null; // Set of 'category:option' keys (new or unlockable when picked), or null for all - let wardrobeNew = []; // keys that were new when the Wardrobe opened - - function wardrobeAvailable() { - const unlockable = Progress.SHOP.filter((i) => i.cosmetic && Progress.canBuy(progress, i.id).ok).map((i) => i.cosmetic.join(':')); - return new Set([...wardrobeNew, ...unlockable]); - } - - function renderWardrobe() { - const body = el.wardrobeBody; - body.innerHTML = ''; - renderWalletBar('wardrobe', !!wardrobeFilter); - const now = new Date(); - const shows = (key) => !wardrobeFilter || wardrobeFilter.has(key); - if (wardrobeFilter && !wardrobeFilter.size) body.append(make('p', 'filter-note', 'Nothing new to wear yet. Keep playing to earn points!')); - for (const cat of Cosmetics.CATEGORIES) { - if (!cat.options.some((o) => shows(`${cat.id}:${o.id}`))) continue; - body.append(make('h3', 'stats-heading', cat.name)); - const row = make('div', 'wardrobe-row'); - for (const opt of cat.options) { - if (!shows(`${cat.id}:${opt.id}`)) continue; - const item = Cosmetics.itemFor(cat.id, opt.id); - const owned = Progress.isUnlocked(progress, item); - const chip = make('button', 'wardrobe-chip' + (settings.cosmetics[cat.id] === opt.id ? ' selected' : '') + (owned ? '' : ' locked')); - chip.type = 'button'; - chip.dataset.cat = cat.id; - chip.dataset.opt = opt.id; - chip.append(chipPreview(cat, opt), make('span', 'chip-name', opt.name)); - if (!owned) { - const check = Progress.canBuy(progress, item, now); - chip.dataset.unlock = item; - const tag = make('span', 'lock-badge'); - if (Progress.isHidden(progress, item)) { - // further down this category's chain: a placeholder, like the main menu - chip.classList.add('skeleton'); - chip.setAttribute('aria-label', 'Locked: unlock the one before it first'); - tag.append(icon('lock')); - } else if (check.reason === 'season') tag.append(icon('lock'), ` ${Progress.shopItem(item).seasonLabel}`); - else { - chip.dataset.cost = opt.price; - tag.append(icon('lock'), ` ${formatPoints(opt.price)}`); - } - chip.append(tag); - } - row.append(chip); - } - body.append(row); - } - // Titles are earned, not bought - if (!Cosmetics.TITLES.some((t) => shows(`title:${t.id}`))) return; - body.append(make('h3', 'stats-heading', 'Your title')); - const row = make('div', 'wardrobe-row'); - for (const t of Cosmetics.TITLES) { - if (!shows(`title:${t.id}`)) continue; - const got = t.earned(progress); - const chip = make('button', 'wardrobe-chip title-chip' + (settings.cosmetics.title === t.id ? ' selected' : '') + (got ? '' : ' locked')); - chip.type = 'button'; - chip.dataset.cat = 'title'; - chip.dataset.opt = t.id; - chip.disabled = !got; - chip.append(make('span', 'chip-name', t.name)); - if (!got) chip.append(make('span', 'chip-need', t.need)); - row.append(chip); - } - body.append(row); - } - - // onlyNew: from a results screen, show just the looks and titles you haven't seen yet - function openWardrobe(onlyNew = false) { - loadCosmetics(); - const keys = wardrobeKeys(); - wardrobeNew = keys.filter((k) => !seenList('wardrobe').includes(k)); - wardrobeFilter = onlyNew === true ? wardrobeAvailable() : null; - renderWardrobe(); - markSeen('wardrobe', keys); - el.wardrobeDialog.hidden = false; - el.wardrobeDialog.querySelector('.dialog').scrollTop = 0; - el.wardrobeDone.focus({ preventScroll: true }); - } - - el.wardrobeBtn.addEventListener('click', () => openWardrobe()); - function closeWardrobe() { - el.wardrobeDialog.hidden = true; - refreshOpenNudges(); - updateMenuBadges(); - } - el.wardrobeDone.addEventListener('click', closeWardrobe); - el.wardrobeDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeWardrobe(); - }); - el.wardrobeDialog.addEventListener('click', (e) => { - if (e.target === el.wardrobeDialog) return closeWardrobe(); - const view = e.target.closest('#wardrobe-view [data-view]'); - if (view) { - wardrobeFilter = view.dataset.view === 'available' ? wardrobeAvailable() : null; - renderWardrobe(); - el.wardrobeDialog.querySelector('.dialog').scrollTop = 0; - return; - } - const chip = e.target.closest('.wardrobe-chip'); - if (!chip || chip.disabled) return; - if (chip.dataset.unlock) return confirmUnlock(chip.dataset.unlock, chip); - chooseCosmetic(chip.dataset.cat, chip.dataset.opt); - }); - - // ---- Sticker album - - let stickersFilter = null; // Set of sticker ids (new or with spares when opened), or null - - function renderStickers() { - const body = el.stickersBody; - body.innerHTML = ''; - const have = progress.stickers || {}; - const got = Cosmetics.STICKERS.filter((st) => have[st.id]).length; - body.append(make('p', 'stats-summary', `${got} of ${Cosmetics.STICKERS.length} collected`)); - if (stickersFilter) { - body.append(filterNote('Showing your new stickers and spares', () => { - stickersFilter = null; - renderStickers(); - })); - } - for (const rarity of ['common', 'rare', 'epic']) { - const list = Cosmetics.STICKERS.filter((x) => x.rarity === rarity && (!stickersFilter || stickersFilter.has(x.id))); - if (!list.length) continue; - body.append(make('h3', 'stats-heading', { common: 'Common', rare: 'Rare', epic: 'Epic' }[rarity])); - const grid = make('div', 'sticker-grid'); - for (const st of list) { - const n = have[st.id] || 0; - const slot = make('div', `sticker ${rarity}${n ? '' : ' missing'}`); - slot.append(make('span', 'sticker-emoji', n ? st.emoji : '?')); - if (n > 1) slot.append(make('span', 'sticker-count', `×${n}`)); - slot.title = n ? `${st.id} (${rarity})` : 'Not found yet'; - grid.append(slot); - } - body.append(grid); - } - const value = Cosmetics.duplicateValue(progress); - el.stickersTrade.disabled = value === 0; - el.stickersTrade.textContent = value ? `Swap spares for 🪙 ${formatPoints(value)}` : 'No spares to swap'; - } - - // onlyNew: from a results screen, show just the new stickers and the ones with spares - function openStickers(onlyNew = false) { - const have = progress.stickers || {}; - stickersFilter = - onlyNew === true ? new Set(Object.keys(have).filter((id) => !seenList('stickers').includes(id) || have[id] > 1)) : null; - renderStickers(); - markSeen('stickers', Object.keys(have)); - el.stickersDialog.hidden = false; - el.stickersDone.focus(); - } - function closeStickers() { - el.stickersDialog.hidden = true; - refreshOpenNudges(); - updateMenuBadges(); - } - el.stickersBtn.addEventListener('click', () => openStickers()); - el.stickersDone.addEventListener('click', closeStickers); - el.stickersDialog.addEventListener('click', (e) => { - if (e.target === el.stickersDialog) closeStickers(); - }); - el.stickersDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeStickers(); - }); - el.stickersTrade.addEventListener('click', () => { - const points = Cosmetics.tradeDuplicates(progress); - if (!points) return; - toast('🔁 Spares swapped', `+${formatPoints(points)} points`); - saveProgress(); - renderStickers(); - }); - - // Shop item that unlocks a setting's choice; null when it's free (the default). - function unlockFor(key, value) { - if (key === 'theme' || String(value) === String(DEFAULT_SETTINGS[key])) return null; - if (key === 'difficulty') return `difficulty.${value}`; - if (key === 'cpuSteps') return 'cpuInstant'; - if (key === 'answerTime') return `answerTime${value}`; // each length is its own unlock - if (key === 'placeMode') return value === 'auto' ? 'placeAuto' : 'placeMode'; // Click, then Auto - return key; - } - - function isAllowed(key, value) { - return Progress.isUnlocked(progress, unlockFor(key, value)); - } - - // Anything saved before it was locked (or not yet bought) falls back to the default. - function enforceLocks() { - for (const key of Object.keys(DEFAULT_SETTINGS)) { - if (!isAllowed(key, settings[key])) settings[key] = DEFAULT_SETTINGS[key]; - } - } - enforceLocks(); - - // A Lucide icon from the sprite in index.html, e.g. icon('lock'). - function icon(name) { - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - svg.setAttribute('class', 'icon'); - svg.setAttribute('aria-hidden', 'true'); - const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); - use.setAttribute('href', `#i-${name}`); - svg.append(use); - return svg; - } - - // Mark a button as locked with a "🔒 price" badge, or clear it. - // Locked options show their price; ones further down a chain (see SHOP - // `requires`) are a blurred placeholder with just a lock, so only the next - // step shows what it is and what it costs. - function setLockBadge(btn, itemId) { - const locked = !Progress.isUnlocked(progress, itemId); - const hidden = locked && Progress.isHidden(progress, itemId); - btn.classList.toggle('locked', locked); - btn.classList.toggle('skeleton', hidden); - btn.dataset.unlock = locked ? itemId : ''; - if (hidden) btn.setAttribute('aria-label', 'Locked: unlock the one before it first'); - else if (btn.getAttribute('aria-label') === 'Locked: unlock the one before it first') btn.removeAttribute('aria-label'); - let badge = btn.querySelector('.lock-badge'); - if (locked && !badge) { - badge = document.createElement('span'); - badge.className = 'lock-badge'; - btn.append(badge); - } - if (badge && !locked) badge.remove(); - if (badge && locked) badge.replaceChildren(icon('lock'), hidden ? '' : ` ${formatPoints(Progress.shopItem(itemId).price)}`); - } - - function syncSettingsUI() { - for (const group of document.querySelectorAll('.segmented[data-setting]')) { - const key = group.dataset.setting; - for (const btn of group.querySelectorAll('button')) { - btn.classList.toggle('selected', btn.dataset.value === String(settings[key])); - setLockBadge(btn, unlockFor(key, btn.dataset.value)); - } - } - el.cornerRuleHint.hidden = !settings.firstInCorner; - } - - // Setting buttons live in the Settings dialog and (difficulty) on the main menu. - function onSettingClick(e) { - const btn = e.target.closest('.segmented[data-setting] button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - const key = btn.parentElement.dataset.setting; - const raw = btn.dataset.value; - settings[key] = raw === 'true' ? true : raw === 'false' ? false : raw; - saveSettings(); - syncSettingsUI(); - updatePracticePreview(); - renderGameLevel(); - renderBoardHint(); - } - - el.settingsDialog.addEventListener('click', (e) => { - if (e.target === el.settingsDialog) return el.settingsDone.click(); // backdrop - onSettingClick(e); - applyTheme(); - }); - - function applyTheme(redraw = true) { - const root = document.documentElement; - if (settings.theme === 'light' || settings.theme === 'dark') root.dataset.theme = settings.theme; - else delete root.dataset.theme; - applyCosmeticsToPage(false); - if (redraw) drawBoard(); - } - applyTheme(false); // the board doesn't exist yet during setup - // In Auto, redraw the board when the device switches between light and dark. - window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => drawBoard()); - el.setupForm.addEventListener('click', onSettingClick); - - let settingsOpener = null; // button to return focus to - - function openSettings(opener) { - settingsOpener = opener; - const inGame = !el.gameScreen.hidden && game && game.phase !== 'over'; - el.settingsNote.hidden = !inGame; - if (inGame) { - el.settingsNote.textContent = game.invite - ? 'You’re playing an invite, so its rules stay the same until the game ends. Theme changes apply now.' - : 'Theme, CPU speed and the progress bar change now. Other changes start from the next turn. The corner rule applies to new games.'; - } - syncSettingsUI(); - el.settingsDialog.hidden = false; - el.settingsDone.focus(); - } - - el.settingsBtn.addEventListener('click', () => openSettings(el.settingsBtn)); - el.gameSettingsBtn.addEventListener('click', () => openSettings(el.gameSettingsBtn)); - el.settingsDone.addEventListener('click', () => { - el.settingsDialog.hidden = true; - applySettingsNow(); - if (settingsOpener) settingsOpener.focus(); - }); - - // Settings changed during a game (not an invite): these are safe to switch at once. - function applySettingsNow() { - if (!game || game.invite || game.phase === 'over') return; - game.cpuSpeed = settings.cpuSpeed; - game.cpuEnd = settings.cpuEnd; - game.showProgress = settings.showProgress; - render(); - } - - // ...and these wait for the start of the next turn, so a move is never changed halfway. - function applySettingsForTurn() { - if (!game || game.invite || game.fixed) return; - applySettingsNow(); - game.placeMode = settings.placeMode; - game.diceMode = settings.diceMode; - game.autoRoll = settings.autoRoll === 'auto'; - game.cpuInstant = settings.cpuSteps === 'instant'; - // single player uses its own Expert timer; the Settings timer is for Home multiplayer - game.answerTime = game.mode === 'single' ? game.levelTimer : Number(settings.answerTime) || 0; - game.fitMode = settings.fitRolls; - } - el.settingsDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') el.settingsDone.click(); - }); - syncSettingsUI(); - - function selectIn(container, button) { - container.querySelectorAll('button').forEach((b) => b.classList.toggle('selected', b === button)); - } - - // Single player and Classroom are free; Local multiplayer, 3/4 players and - // Practice are shop unlocks. - const MODE_UNLOCK = { single: null, multi: null, practice: 'practice' }; - const PLAYERS_UNLOCK = { 2: null, 3: 'players3', 4: 'players4' }; - - function syncModeLocks() { - el.modeCards.forEach((card) => setLockBadge(card, MODE_UNLOCK[card.dataset.mode])); - for (const btn of el.playerCount.querySelectorAll('button')) setLockBadge(btn, PLAYERS_UNLOCK[btn.dataset.count]); - setLockBadge(el.multiKind.querySelector('[data-kind="local"]'), 'multiplayer'); - } - syncModeLocks(); - - el.modeCards.forEach((card) => { - card.addEventListener('click', () => { - if (card.dataset.unlock) return confirmUnlock(card.dataset.unlock, card); - setup.mode = card.dataset.mode; - // Local multiplayer is bought in the shop; until then Multiplayer opens on Classroom - if (setup.mode === 'multi' && !Progress.isUnlocked(progress, 'multiplayer')) selectMultiKind('classroom'); - el.modeCards.forEach((c) => { - c.classList.toggle('selected', c === card); - c.setAttribute('aria-checked', String(c === card)); - }); - renderSetupSections(); - updatePracticePreview(); - }); - }); - - // Which parts of the setup form show for the chosen mode (and, for - // multiplayer, Local or Classroom). - function renderSetupSections() { - const classroom = setup.mode === 'multi' && setup.multiKind === 'classroom'; - el.singleSetup.hidden = setup.mode !== 'single'; - el.multiSetup.hidden = setup.mode !== 'multi'; - el.practiceSetup.hidden = setup.mode !== 'practice'; - if (setup.mode !== 'practice') renderBoardHint(); - el.localSetup.hidden = setup.multiKind !== 'local'; - el.classroomSetup.hidden = setup.multiKind !== 'classroom'; - el.boardSizeField.hidden = setup.mode === 'practice' || classroom; // practice has no board - el.difficultyField.hidden = classroom; // the teacher picks these when hosting - el.startBtn.hidden = classroom; // Join / Host instead - el.startBtn.textContent = setup.mode === 'practice' ? 'Start practice' : 'Start game'; - if (classroom) checkClassroom(); - } - - function selectMultiKind(kind) { - setup.multiKind = kind; - for (const b of el.multiKind.querySelectorAll('[data-kind]')) { - const on = b.dataset.kind === kind; - b.classList.toggle('selected', on); - b.setAttribute('aria-checked', String(on)); - } - renderSetupSections(); - } - - el.multiKind.addEventListener('click', (e) => { - const btn = e.target.closest('[data-kind]'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - selectMultiKind(btn.dataset.kind); - }); - - el.playerCount.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - saveNameInputs(); - setup.count = Number(btn.dataset.count); - selectIn(el.playerCount, btn); - renderNameInputs(); - }); - - const CUSTOM_MIN = 6; - const CUSTOM_MAX = 30; - - function syncBoardLocks() { - for (const btn of el.boardSize.querySelectorAll('button')) setLockBadge(btn, BOARD_UNLOCK[btn.dataset.size]); - renderBoardHint(); - renderGameLevel(); // (difficulty or purchases change it too) - } - - // ---- single player: Level (Learn / Expert) and Expert's timer, per difficulty, like Practice - const GAME_EXPERT_BONUS = 2; - const GAME_LEVEL_HELP = { - learn: 'Learn: “Help me count” is there whenever you need it.', - expert: 'Expert: no help counting, and bonus points are doubled!', - }; - function renderGameLevel() { - if (!el.gameLevel) return; - const d = settings.difficulty; - const expertId = Progress.gameExpertId(d); - if (!Progress.isUnlocked(progress, expertId)) setup.level = 'learn'; - for (const btn of el.gameLevel.querySelectorAll('button')) { - btn.classList.toggle('selected', btn.dataset.level === setup.level); - setLockBadge(btn, btn.dataset.level === 'expert' ? expertId : null); - } - el.gameLevelHelp.textContent = GAME_LEVEL_HELP[setup.level]; - el.gameTimerField.hidden = setup.level !== 'expert'; - const timerId = (secs) => (Number(secs) ? Progress.gameTimerId(Number(secs), d) : null); - if (!Progress.isUnlocked(progress, timerId(setup.timer))) setup.timer = 0; - for (const btn of el.gameTimer.querySelectorAll('button')) { - btn.classList.toggle('selected', Number(btn.dataset.secs) === setup.timer); - setLockBadge(btn, timerId(btn.dataset.secs)); - } - const top = (secs) => Progress.TIMER_BONUS_MAX[secs] * GAME_EXPERT_BONUS; - el.gameTimerHelp.textContent = setup.timer - ? `⏱️ Answer fast for a timer bonus of up to +${top(setup.timer)} a question: the quicker, the more. If time runs out, the rectangle comes off and scores nothing.` - : `⏱️ A timer earns a bonus for quick answers: up to +${top(30)} a question with 30 s, +${top(10)} with 10 s, +${top(5)} with 5 s.`; - } - el.gameLevel.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - setup.level = btn.dataset.level; - renderGameLevel(); - }); - el.gameTimer.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - setup.timer = Number(btn.dataset.secs); - renderGameLevel(); - }); - - // Under the board sizes: the finishing bonus for the chosen size - function renderBoardHint() { - if (!el.boardSizeHelp) return; - // Legend rolls a teen (11–19), so it needs a board it fits on - const fits = Core.fitsDifficulty(settings.difficulty, setup.size); - if (setup.mode !== 'practice') el.startBtn.disabled = !fits; - el.boardSizeHelp.classList.toggle('warn', !fits); - if (!fits) { - el.boardSizeHelp.textContent = `⚠️ ${Progress.DIFFICULTY_NAMES[settings.difficulty]} rolls numbers like 14 × 7, so it needs a board of 12×12 or bigger.`; - return; - } - const bonus = Progress.boardFinishBonus(setup.size); - if (settings.difficulty === 'legend' && setup.size < 19 && setup.mode !== 'practice') { - el.boardSizeHelp.textContent = `On ${setup.size}×${setup.size}, Legend rolls up to ${setup.size} × 9. Bigger boards bring bigger teens (all the way to 19 on 20×20).`; - return; - } - el.boardSizeHelp.textContent = bonus - ? `🏁 Finish a game against the CPU on ${setup.size}×${setup.size} for +${bonus} bonus points.` - : `🏁 Bigger boards earn a finishing bonus against the CPU: +${Progress.boardFinishBonus(8)} for 8×8, up to +${Progress.boardFinishBonus(24)} for 24×24.`; - } - syncBoardLocks(); - - el.boardSize.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - selectIn(el.boardSize, btn); - const custom = btn.dataset.size === 'custom'; - el.customSize.hidden = !custom; - setup.size = custom ? readCustomSize() : Number(btn.dataset.size); - renderBoardHint(); - }); - - function clampCustom(n) { - n = Math.round(Number(n)); - return Number.isFinite(n) ? Math.max(CUSTOM_MIN, Math.min(n, CUSTOM_MAX)) : 14; - } - - // Current custom size (clamped), mirrored next to the input. - function readCustomSize() { - const size = clampCustom(el.customSizeInput.value); - el.customSizeEcho.textContent = size; - return size; - } - - function setCustomSize(size) { - el.customSizeInput.value = clampCustom(size); - setup.size = readCustomSize(); - renderBoardHint(); - } - - el.customSizeInput.addEventListener('input', () => { - if (el.customSizeInput.value !== '') setup.size = readCustomSize(); - renderBoardHint(); - }); - el.customSizeInput.addEventListener('change', () => setCustomSize(readCustomSize())); - el.customSize.addEventListener('click', (e) => { - const btn = e.target.closest('[data-step]'); - if (btn) setCustomSize(readCustomSize() + Number(btn.dataset.step)); - }); - - function saveNameInputs() { - el.nameInputs.querySelectorAll('input').forEach((input, i) => { - if (setup.types[i] === 'human') setup.names[i] = input.value; - }); - } - - // CPU slots are numbered in order: CPU 1, CPU 2, … - function cpuName(index) { - const nth = setup.types.slice(0, index + 1).filter((t) => t === 'cpu').length; - return `CPU ${nth}`; - } - - function renderNameInputs() { - el.nameInputs.innerHTML = ''; - for (let i = 0; i < setup.count; i++) { - const isCpu = setup.types[i] === 'cpu'; - const row = document.createElement('div'); - row.className = 'name-row'; - const swatch = document.createElement('span'); - swatch.className = 'swatch'; - swatch.style.background = look('colors').colors[i]; - const input = document.createElement('input'); - input.type = 'text'; - input.maxLength = 14; - input.value = isCpu ? cpuName(i) : setup.names[i]; - input.placeholder = DEFAULT_NAMES[i]; - input.addEventListener('input', () => input.classList.remove('missing')); - input.disabled = isCpu; - input.setAttribute('aria-label', `Player ${i + 1} name`); - const kind = document.createElement('button'); - kind.type = 'button'; - kind.className = 'kind-toggle' + (isCpu ? ' cpu' : ''); - kind.dataset.index = i; - kind.textContent = isCpu ? '🤖 CPU' : '👤 Human'; - kind.setAttribute('aria-label', `Player ${i + 1}: ${isCpu ? 'CPU' : 'human'}. Click to switch.`); - row.append(swatch, input, kind); - el.nameInputs.append(row); - } - } - renderNameInputs(); - - el.nameInputs.addEventListener('click', (e) => { - const btn = e.target.closest('.kind-toggle'); - if (!btn) return; - saveNameInputs(); - const i = Number(btn.dataset.index); - setup.types[i] = setup.types[i] === 'cpu' ? 'human' : 'cpu'; - renderNameInputs(); - }); - - el.setupForm.addEventListener('submit', (e) => { - e.preventDefault(); - if (setup.mode === 'multi' && setup.multiKind === 'classroom') return; // not built yet - if (setup.mode === 'practice') { - const tables = practiceTableList(); - if (!tables.length) return; - const expert = practiceLevel === 'expert'; - startPractice(el.practiceName.value.trim() || 'You', practiceRoundSize, tables, expert, expert ? practiceTimerSecs : 0); - return; - } - saveNameInputs(); - if (!el.customSize.hidden) setCustomSize(readCustomSize()); - let players; - if (setup.mode === 'single') { - players = [ - { name: el.singleName.value.trim() || 'You', cpu: false }, - { name: 'CPU', cpu: true }, - ]; - } else { - // every human player needs a real name - const inputs = [...el.nameInputs.querySelectorAll('input')]; - const missing = inputs.filter((input, i) => setup.types[i] !== 'cpu' && !input.value.trim()); - inputs.forEach((input) => input.classList.toggle('missing', missing.includes(input))); - el.namesError.hidden = !missing.length; - if (missing.length) { - missing[0].focus(); - return; - } - players = setup.names.slice(0, setup.count).map((name, i) => - setup.types[i] === 'cpu' ? { name: cpuName(i), cpu: true } : { name: name.trim(), cpu: false } - ); - } - startGame(players, setup.size, setup.mode); - }); - - // ---------------------------------------------------------------- game state - - let game = null; - let gameId = 0; // bumped on every new game so pending timers from an old game do nothing - - // Wardrobe on players: the CPU character's name and avatar; in single player, - // your avatar and title. "CPU 2" in multiplayer becomes e.g. "Owl 2". - function wardrobeLook(p, mode) { - if (p.cpu) { - const ch = look('cpu'); - const name = p.name === 'CPU' ? ch.cpuName : p.name.replace(/^CPU\b/, ch.short); - return { name, avatar: ch.avatar, hello: ch.hello }; - } - if (mode === 'multi') return {}; - if (mode === 'pair' && !p.statsName) return {}; // your partner isn't wearing your Wardrobe - const avatar = look('avatar'); - const t = Cosmetics.title(settings.cosmetics.title); - return { avatar: avatar.id === 'none' ? '' : avatar.preview, title: t.id === 'none' ? '' : t.name }; - } - - // invite: a decoded invite, whose settings and dice seed apply to this game only. - // extra: { cfg, classroom } for a class game: fixed settings, dice from the teacher. - function startGame(playerDefs, size, mode, invite = null, extra = null) { - cancelContinue(); - stopTimer(); - stopPracticeTimer(); - practice = null; - el.practiceScreen.hidden = true; - el.practiceDone.hidden = true; - gameId++; - const cfg = { ...settings, ...(invite ? invite.s : {}), ...(extra ? extra.cfg : {}) }; - const rng = invite && invite.r !== undefined ? seededRandom(invite.r) : Math.random; - game = { - invite, - fixed: Boolean(extra), // settings don't change mid-game - classroom: extra ? extra.classroom : null, // { session, game, round, reported, view } - rng, - cpuSpeed: cfg.cpuSpeed, - cpuEnd: cfg.cpuEnd, - showProgress: cfg.showProgress, - id: gameId, - size, - mode, // 'single' (you vs the CPU), 'multi' (one screen) or 'class' (dice from the teacher) - board: Core.createBoard(size, { firstInCorner: cfg.firstInCorner }), - difficulty: cfg.difficulty, - sides: Core.DIFFICULTY_SIDES[cfg.difficulty] || 6, - cpuInstant: cfg.cpuSteps === 'instant', - autoRoll: cfg.autoRoll === 'auto', - fitMode: cfg.fitRolls, - players: playerDefs.map((p, i) => ({ - ...p, - ...wardrobeLook(p, mode), - color: look('colors').colors[i], - score: 0, // squares + bonus - squares: 0, - bonus: 0, - streak: 0, // first-try answers in a row - bestStreak: 0, - log: [], - answered: 0, - firstTry: 0, - stats: { placed: 0, biggest: null, wrong: 0, helped: 0, timeouts: 0, passes: 0, times: [], missed: [] }, - })), - answerTime: Number(cfg.answerTime) || 0, // seconds, 0 = untimed - startedAt: Date.now(), - turns: 0, - current: 0, - passes: 0, - dice: null, - rotated: false, - phase: 'roll', // roll | rolling | place | answer | cpu | over - hover: null, // { c, r } cell under pointer - cpuPreview: null, - lastRect: null, - placeMode: cfg.placeMode, // 'draw': drag out the rectangle; 'click': click to drop it - // 'virtual': the app rolls; 'real': players roll real dice and enter them - // (not after Hard: those dice can't be rolled at the table) - diceMode: Core.usesPairDice(cfg.difficulty) ? 'virtual' : cfg.diceMode, - picked: [null, null], // real-dice values entered so far - drag: null, // { c0, r0, c1, r1 } while a human is drawing - pending: null, // human rectangle waiting for its points to be worked out - counting: null, // { rect, byRows, shown, group } while skip-counting on the board - }; - // Loaded to suit board and difficulty. After Hard, the dice deal chosen - // pairs: Tricky skips the easy facts, Master leans on your weakest facts, - // Legend deals a teen × a one-digit number. - game.faces = Core.diceFaces(cfg.difficulty); - game.diceBag = Core.usesPairDice(cfg.difficulty) - ? Core.createPairDice(size, rng, cfg.difficulty, cfg.difficulty === 'master' ? weakFactWeight(playerDefs.find((p) => !p.cpu)) : null) - : Core.createDice(size, rng, game.sides); - // Single player's Level: Expert has no "Help me count", doubles bonus points, - // and can have an answer timer (its own, not the Settings one) - const levelled = mode === 'single' && !invite && !extra; - game.expert = levelled && setup.level === 'expert'; - game.levelTimer = game.expert ? setup.timer : 0; - if (levelled) game.answerTime = game.levelTimer; - buildDicePicks(game.sides); - el.resultTournament.hidden = true; - const classroomGame = mode === 'class' || mode === 'pair'; - el.againBtn.hidden = classroomGame; // the teacher starts the next class game - el.toMenuBtn.textContent = !classroomGame ? 'Main menu' : extra.classroom.session.host ? 'Back to the class' : 'Leave class'; - el.startScreen.hidden = true; - el.gameOver.hidden = true; - el.gameScreen.hidden = false; - renderDie(el.dieA, null); - renderDie(el.dieB, null); - resizeBoard(); - beginTurn(); - } - - const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - // Resolves after ms, or never if the game has changed in the meantime. - function waitInGame(ms) { - const id = gameId; - return wait(ms).then(() => (id === gameId ? true : new Promise(() => {}))); - } - - function currentPlayer() { - return game.players[game.current]; - } - - function currentDims() { - if (!game.dice) return null; - const [a, b] = game.dice; - return game.rotated ? [b, a] : [a, b]; - } - - function resetTurn() { - stopTimer(); - game.dice = null; - game.rotated = false; - game.hover = null; - game.cpuPreview = null; - game.drag = null; - game.pending = null; - game.counting = null; - el.math.hidden = true; - el.steps.hidden = true; - el.steps.innerHTML = ''; - renderDie(el.dieA, null); - renderDie(el.dieB, null); - } - - function beginTurn() { - applySettingsForTurn(); - const p = currentPlayer(); - game.turns++; - resetTurn(); - if (game.mode === 'class') { - game.phase = 'wait'; - setMsg(game.classroom.round ? 'Waiting for the next roll…' : 'Get ready! Waiting for the first roll…'); - render(); - return; - } - if (game.mode === 'pair') return pairTurnOrWait(); - if (p.cpu) { - game.phase = 'cpu'; - setMsg(`${p.name} is rolling…`); - render(); - runComputerTurn(); - } else { - game.phase = 'roll'; - const first = Core.mustUseCorner(game.board); - const corner = first ? ' Your first rectangle goes in the top-left corner.' : ''; - if (game.diceMode === 'real') { - game.picked = [null, null]; - renderDicePicks(); - setMsg(`Roll your two dice, then tap the numbers you got.${corner}`); - } else if (game.autoRoll) { - setMsg(`Rolling the dice…${corner}`); - } else { - setMsg(first ? `Roll the dice!${corner}` : 'Roll the dice!'); - } - render(); - // Real dice are rolled at the table, so auto roll only applies to app dice. - if (game.autoRoll && game.diceMode !== 'real') waitInGame(AUTO_ROLL_MS).then(humanRoll); - } - } - - // forced: the class game's roll, from the teacher - async function rollDice(forced = null) { - game.phase = 'rolling'; - render(); - const rollStyle = `roll-${settings.cosmetics.roll}`; // Wardrobe: wobble, tumble, bounce, sparkle - el.dieA.classList.add('rolling', rollStyle); - el.dieB.classList.add('rolling', rollStyle); - const face = (list) => list[Math.floor(Math.random() * list.length)]; - for (let i = 0; i < 8; i++) { - renderDie(el.dieA, face(game.faces.a)); - renderDie(el.dieB, face(game.faces.b)); - await waitInGame(55); - } - el.dieA.classList.remove('rolling', rollStyle); - el.dieB.classList.remove('rolling', rollStyle); - game.dice = forced ? [...forced] : Core.rollForBoard(game.board, game.diceBag, game.rng, game.fitMode); - renderDie(el.dieA, game.dice[0]); - renderDie(el.dieB, game.dice[1]); - return game.dice; - } - - async function humanRoll() { - if (!game || game.phase !== 'roll' || currentPlayer().cpu || game.diceMode === 'real') return; - if (game.mode === 'pair') return pairRoll(); // the server rolls for a pairs game - const [a, b] = await rollDice(); - await startPlacing(a, b); - } - - // ---- real dice: the player enters what they rolled - - function pickDie(index, value) { - if (!game || game.phase !== 'roll' || game.diceMode !== 'real') return; - game.picked[index] = value; - renderDicePicks(); - } - - // Keyboard entry: a digit fills the first empty die (or the second if both are set). - function typeDie(value) { - const i = game.picked[0] === null ? 0 : 1; - pickDie(i, value); - } - - function clearLastDie() { - if (game.picked[1] !== null) pickDie(1, null); - else pickDie(0, null); - } - - async function useRealDice() { - if (!game || game.phase !== 'roll' || game.diceMode !== 'real') return; - const [a, b] = game.picked; - if (!a || !b) return; - game.dice = [a, b]; - await startPlacing(a, b); - } - - function renderDicePicks() { - el.diePicks.forEach((row, i) => { - for (const btn of row.querySelectorAll('button')) { - const on = Number(btn.dataset.value) === game.picked[i]; - btn.classList.toggle('selected', on); - btn.setAttribute('aria-pressed', String(on)); - } - }); - renderDie(el.dieA, game.picked[0]); - renderDie(el.dieB, game.picked[1]); - el.useDiceBtn.disabled = !(game.picked[0] && game.picked[1]); - } - - async function startPlacing(a, b) { - const options = Core.validPlacements(game.board, a, b); - if (options.length === 0) { - await pass(a, b); - return; - } - if (game.placeMode === 'auto') { - await autoPlace(a, b); - return; - } - // Start in an orientation that actually fits somewhere. - if (!options.some((o) => o.w === a && o.h === b)) game.rotated = true; - game.phase = 'place'; - setMsg(placeInstructions()); - render(); - } - - // Auto mode: put the rectangle in a snug spot (same strategy as the - // computer), show it briefly, then go straight to the maths. - async function autoPlace(a, b) { - game.phase = 'busy'; - const move = Core.chooseComputerMove(game.board, a, b); - setMsg(`You rolled ${a} and ${b}. Placing your ${a} by ${b} rectangle…`); - game.cpuPreview = move; - render(); - await waitInGame(AUTO_PLACE_MS); - game.cpuPreview = null; - startAnswer(placeRect(move.x, move.y, move.w, move.h)); - } - - function placeInstructions() { - const [a, b] = game.dice; - const shape = `a ${a} by ${b} rectangle`; - const corner = Core.mustUseCorner(game.board); - if (game.placeMode === 'draw') { - return corner - ? `You rolled ${a} and ${b}. Draw ${shape}: start on the top-left square and drag.` - : `You rolled ${a} and ${b}. Draw ${shape}: press on a square and drag.`; - } - return corner - ? `You rolled ${a} and ${b}. Click the board to draw ${shape} in the corner.` - : `You rolled ${a} and ${b}. Click the board to draw ${shape}.${a !== b ? ' Press R to rotate.' : ''}`; - } - - async function pass(a, b) { - const p = currentPlayer(); - game.phase = p.cpu ? 'cpu' : 'rolling'; - p.log.push({ pass: true, a, b }); - p.stats.passes++; - game.passes++; - const who = game.mode !== 'multi' && !p.cpu ? 'You pass' : `${p.name} passes`; - setMsg(`No room for a ${a} × ${b} anywhere. ${who}.`); - if (game.mode === 'class') reportClass({ kind: 'pass' }); // (pairs: the server passes for you) - render(); - if (p.cpu) await finishComputerTurn(cpuStep()); - else await waitInGame(PASS_DELAY); - endTurn(); - } - - // Draw the rectangle on the board. Its points are awarded once the maths is done. - function placeRect(x, y, w, h) { - const [a, b] = game.dice; - const rect = Core.place(game.board, x, y, w, h, game.current); - rect.a = a; - rect.b = b; - rect.solved = false; - // Wardrobe placing effect (the player who placed it chose it; CPUs use yours too) - rect.fx = settings.cosmetics.place !== 'none' ? settings.cosmetics.place : null; - rect.placedAt = performance.now(); - if (rect.fx) animateBoard(PLACE_FX_MS + 50); - game.lastRect = rect; - game.passes = 0; - return rect; - } - - function awardPoints(rect, firstTry, bonus = 0) { - const p = currentPlayer(); - rect.solved = true; - p.squares += rect.area; - p.bonus += bonus; - p.score = p.squares + p.bonus; - p.answered++; - if (firstTry) p.firstTry++; - p.stats.placed++; - if (!p.stats.biggest || rect.area > p.stats.biggest.area) p.stats.biggest = rect; - p.log.push({ a: rect.a, b: rect.b, area: rect.area, bonus, star: !p.cpu && firstTry }); - } - - // ---- human: work out the points - - function startAnswer(rect) { - game.pending = { rect, entry: '', attempts: 0, helped: false, miss: null, startedAt: performance.now(), pausedMs: 0 }; - game.phase = 'answer'; - setMsg('How many squares did you just draw?'); - el.mathQ.textContent = `${rect.a} × ${rect.b}`; - el.answerBox.textContent = ''; - el.answerBox.className = 'answer-box'; - el.countTrail.textContent = ''; - el.mathFeedback.textContent = ''; - el.mathFeedback.className = 'math-feedback'; - el.math.hidden = false; - showStreak(el.streakBadge, currentPlayer().streak); - setKeypadEnabled(true); - el.helpBtn.disabled = false; - el.helpBtn.hidden = Boolean(game.expert); // Expert: no help counting - render(); - startTimer(); - if (window.innerWidth <= 820) el.math.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); - } - - function setKeypadEnabled(on) { - el.keypad.querySelectorAll('button').forEach((b) => (b.disabled = !on)); - } - - // Can more typing still change the outcome? No once it matches, overshoots, - // or has as many digits as the answer. - // "🔥 5 in a row"; with the Wardrobe's growing flames it gets bigger at 5, 10 and 20. - function showStreak(node, n) { - node.textContent = n >= 2 ? `🔥 ${n} in a row` : ''; - const level = n >= 20 ? 3 : n >= 10 ? 2 : n >= 5 ? 1 : 0; - node.classList.toggle('flames', settings.cosmetics.streak === 'flames' && level > 0); - node.dataset.level = level; - } - - function answerDecided(entry, answer) { - return entry !== '' && (Number(entry) >= answer || entry.length >= String(answer).length); - } - - function typeKey(key) { - const pend = game && game.phase === 'answer' && game.pending; - if (!pend) return; - if (key === 'check') return checkAnswer(); - if (key === 'clear') pend.entry = ''; - else if (key === 'back') pend.entry = pend.entry.slice(0, -1); - else if (pend.entry.length < 3) pend.entry = (pend.entry === '0' ? '' : pend.entry) + key; - el.answerBox.textContent = pend.entry; - el.answerBox.className = 'answer-box'; - // Submit by itself once the answer is decided: it's right, it's bigger than - // the answer, or it has as many digits as the answer. Shorter entries wait - // for ✓, since you might not be done typing. - if (answerDecided(pend.entry, pend.rect.area)) checkAnswer(); - } - - async function checkAnswer() { - const pend = game.pending; - if (!pend || pend.entry === '') return; - const { rect } = pend; - if (Number(pend.entry) === rect.area) { - // the timer bonus goes by how much time was left (not in class games: the server scores those) - const timerLeft = timer && game.answerTime && game.mode !== 'class' && game.mode !== 'pair' ? { secs: game.answerTime, left: timer.remaining / timer.total } : null; - stopTimer(); - const p = currentPlayer(); - const ms = performance.now() - pend.startedAt - pend.pausedMs; - const firstTry = pend.attempts === 0; - p.stats.times.push(ms); - p.streak = firstTry ? p.streak + 1 : 0; - p.bestStreak = Math.max(p.bestStreak, p.streak); - const bonus = Progress.answerBonus({ firstTry, ms, streak: p.streak, timer: timerLeft }); - game.phase = 'busy'; - setKeypadEnabled(false); - el.helpBtn.disabled = true; - el.answerBox.className = 'answer-box right'; - el.mathFeedback.className = 'math-feedback good'; - const squares = `+${rect.area} ${rect.area === 1 ? 'square' : 'squares'}`; - el.mathFeedback.textContent = firstTry ? `Correct! ${squares} 🎉` : `You got it! ${squares}`; - // Each bonus pops up on its own, like an achievement. - const mult = game.expert ? GAME_EXPERT_BONUS : 1; // Expert doubles bonus points - for (const part of bonus.parts) toast(`${BONUS_TOASTS[part.label.split(' ')[0]]} ${capitalize(part.label)}`, `+${part.points * mult} bonus points${mult > 1 ? ' (×2 Expert)' : ''}`); - awardPoints(rect, firstTry, bonus.total * mult); - showStreak(el.streakBadge, p.streak); - if (game.mode === 'class' || game.mode === 'pair') reportClass({ kind: 'placed', rect: { x: rect.x, y: rect.y, w: rect.w, h: rect.h }, firstTry, ms: Math.round(ms) }); - if (countsForStats()) { - Progress.recordAnswer(progress, statsName(p), { - a: rect.a, - b: rect.b, - firstTry, - correct: true, - wrongAnswers: pend.miss ? pend.miss.answers : [], - ms, - }); - const learned = Progress.learnedAfterHelp(progress, rect.a, rect.b, firstTry); - celebrate(Progress.awardAchievements(progress, { event: 'answer', correct: true, firstTry, ms, streak: p.streak, a: rect.a, b: rect.b, facts: playerFacts(statsName(p)), size: unlockedTableSize(), learned })); - saveProgress(); - } - game.pending = null; - render(); - await waitInGame(1400); - endTurn(); - return; - } - currentPlayer().streak = 0; - pend.attempts++; - noteMiss(pend, { answer: Number(pend.entry) }); - pend.entry = ''; - currentPlayer().stats.wrong++; - el.answerBox.className = 'answer-box'; - void el.answerBox.offsetWidth; // restart the shake animation - el.answerBox.className = 'answer-box wrong'; - el.answerBox.textContent = ''; - el.mathFeedback.className = 'math-feedback try'; - el.mathFeedback.textContent = TRY_AGAIN[(pend.attempts - 1) % TRY_AGAIN.length]; - if (pend.attempts >= 2 && !pend.helped && !game.expert) { - el.mathFeedback.textContent = "Let's count them together!"; - helpCount(); - } - } - - // Legend: a teen times a one-digit number, split into tens and ones - // (14 × 7 = 10 × 7 + 4 × 7). Null for anything else. - function legendSplit(rect) { - if (!game || game.difficulty !== 'legend') return null; - const teen = Math.max(rect.a, rect.b); - const other = Math.min(rect.a, rect.b); - if (teen < 11) return null; - const ones = teen - 10; - return { teen, other, ones, tens: 10 * other, rest: ones * other }; - } - - async function helpCount() { - const pend = game.pending; - if (!pend || pend.helped || game.counting || game.expert) return; // Expert: no help - pend.helped = true; - currentPlayer().stats.helped++; - if (countsForStats()) { - Progress.noteHelp(progress, pend.rect.a, pend.rect.b); - celebrate(Progress.awardAchievements(progress, { event: 'help' })); - saveProgress(); - } - el.helpBtn.disabled = true; - const { rect } = pend; - const split = legendSplit(rect); - if (split) { - // Too many to skip-count: split it, and leave the adding to the player - el.countTrail.textContent = - `Split ${split.teen} into 10 and ${split.ones}: 10 × ${split.other} = ${split.tens}, ` + - `${split.ones} × ${split.other} = ${split.rest}. Now add them up!`; - return; - } - const plan = countPlan(rect); - const totals = []; - const helpStarted = performance.now(); // the timer is paused while counting - await skipCount(rect, plan, COUNT_STEP, (total) => { - totals.push(total); - const units = plan.steps === 1 ? plan.unit : `${plan.unit}s`; - el.countTrail.textContent = `${plan.steps} ${units} of ${plan.group}: ${totals.join(', ')}`; - }); - pend.pausedMs += performance.now() - helpStarted; - if (game.pending === pend) { - el.mathFeedback.className = 'math-feedback'; - el.mathFeedback.textContent = `So what is ${rect.a} × ${rect.b}?`; - } - } - - // A fact goes on the "to practise" list the first time it's missed in a turn. - // Record a missed fact once per turn, with every wrong answer given for it. - // Entries look like { fact: '6 × 7', answers: [48, 36], timeout: false }. - function noteMiss(pend, { answer = null, timeout = false } = {}) { - if (!pend.miss) { - pend.miss = { fact: `${pend.rect.a} × ${pend.rect.b}`, answers: [], timeout: false }; - currentPlayer().stats.missed.push(pend.miss); - } - if (answer !== null) pend.miss.answers.push(answer); - if (timeout) pend.miss.timeout = true; - } - - // ---- answer timer (paused while "Help me count" is animating) - - let timer = null; // { total, remaining, last, interval } - - function startTimer() { - stopTimer(); - el.timer.hidden = !game.answerTime; - if (!game.answerTime) return; - const id = gameId; - const total = Progress.timerLength(progress, game.answerTime) * 1000; // (the bonus still follows answerTime) - timer = { total, remaining: total, last: performance.now() }; - renderTimer(); - timer.interval = setInterval(() => { - if (!timer || id !== gameId) return stopTimer(); - const now = performance.now(); - // Paused while "Help me count" is animating or Settings is open. - if (!game.counting && el.settingsDialog.hidden) timer.remaining -= now - timer.last; - timer.last = now; - renderTimer(); - if (timer.remaining <= 0) answerTimeout(); - }, 100); - } - - function stopTimer() { - if (timer) clearInterval(timer.interval); - timer = null; - } - - function renderTimer() { - const left = Math.max(0, timer.remaining); - el.timerNum.textContent = timerText(left); - el.timerFill.style.width = `${(left / timer.total) * 100}%`; - el.timer.classList.toggle('low', left <= 5000); - } - - // Out of time: show the answer, take the rectangle back off the board, no points. - async function answerTimeout() { - stopTimer(); - const pend = game.pending; - if (!pend || game.phase !== 'answer') return; - const p = currentPlayer(); - const { rect } = pend; - game.phase = 'busy'; - game.pending = null; - setKeypadEnabled(false); - el.helpBtn.disabled = true; - noteMiss(pend, { timeout: true }); - p.stats.timeouts++; - p.streak = 0; - if (game.mode === 'class' || game.mode === 'pair') reportClass({ kind: 'timeout' }); - if (countsForStats()) { - Progress.recordAnswer(progress, statsName(p), { a: rect.a, b: rect.b, firstTry: false, correct: false, wrongAnswers: pend.miss.answers, timeout: true }); - saveProgress(); - } - p.log.push({ timeout: true, a: rect.a, b: rect.b }); - el.answerBox.textContent = rect.area; - el.answerBox.className = 'answer-box timeout'; - el.mathFeedback.className = 'math-feedback try'; - el.mathFeedback.textContent = `⏰ Time's up! ${rect.a} × ${rect.b} = ${rect.area}. No points this turn.`; - Core.removeRect(game.board, rect); - game.lastRect = null; - game.passes = 0; // the roll did fit, so this isn't a pass - render(); - await waitInGame(3000); - endTurn(); - } - - // ---- skip counting, shared by the help button and the computer's explanation - - // Count a × b as "a rows (or columns) of b", matching the order of the dice. - function countPlan(rect) { - const byRows = rect.w === rect.b; - return { byRows, group: rect.b, steps: rect.a, unit: byRows ? 'row' : 'column' }; - } - - async function skipCount(rect, plan, stepMs, onStep) { - game.counting = { rect, byRows: plan.byRows, group: plan.group, shown: 0 }; - render(); - for (let i = 1; i <= plan.steps; i++) { - await waitInGame(stepMs); - game.counting.shown = i; - onStep(i * plan.group, i); - render(); - } - await waitInGame(stepMs); - } - - // ---- computer: show the working step by step - - function addStep(html, big = false) { - const li = document.createElement('li'); - li.innerHTML = html; - if (big) li.className = 'big'; - el.steps.append(li); - return li; - } - - async function explainComputerMove(rect) { - const plan = countPlan(rect); - const { a, b, area } = rect; - addStep(`I drew a <span class="num">${a}</span> by <span class="num">${b}</span> rectangle. How many squares is that?`); - await waitInGame(cpuStep()); - const split = legendSplit(rect); - if (split) { - addStep(`Split <span class="num">${split.teen}</span> into 10 and <span class="num">${split.ones}</span>.`); - await waitInGame(cpuStep()); - addStep(`10 × ${split.other} = <span class="num">${split.tens}</span> and ${split.ones} × ${split.other} = <span class="num">${split.rest}</span>.`); - await waitInGame(cpuStep()); - addStep(`${split.tens} + ${split.rest} = <span class="num">${area}</span>.`); - await waitInGame(cpuStep()); - } else if (a === 1 || b === 1) { - const n = a === 1 ? b : a; - addStep(`It's just 1 ${a === 1 ? 'row' : 'column'} of <span class="num">${n}</span>, so that's ${n}.`); - await waitInGame(cpuStep()); - } else { - addStep( - `That's <span class="num">${plan.steps}</span> ${plan.unit}s of <span class="num">${plan.group}</span>. ` + - `Let's count by ${plan.group}s:` - ); - let trail = null; // created with its first number so it never shows up empty - const totals = []; - await skipCount(rect, plan, cpuStep(), (total) => { - totals.push(total); - trail = trail || addStep(''); - trail.innerHTML = totals.map((t) => `<span class="num">${t}</span>`).join(', '); - }); - } - game.counting = null; - addStep(`${a} × ${b} = ${area}`, true); - awardPoints(rect, true); - render(); - } - - // ---- end of the computer's turn: carry on by itself, or wait for "My turn" - - let continueResolve = null; - - function gameEndsAfterThisTurn() { - return game.passes >= game.players.length || Core.emptyCount(game.board) === 0; - } - - async function finishComputerTurn(autoDelay) { - if (game.cpuEnd !== 'button' || gameEndsAfterThisTurn()) { - await waitInGame(autoDelay); - return; - } - const next = game.players[(game.current + 1) % game.players.length]; - el.continueBtn.replaceChildren( - game.mode === 'single' ? 'My turn ' : next.cpu ? `${next.name} ` : `${next.name}'s turn `, - icon('chevron-right') - ); - el.continueBtn.hidden = false; - el.continueBtn.focus({ preventScroll: true }); - await new Promise((resolve) => (continueResolve = resolve)); - } - - function pressContinue() { - if (!continueResolve) return; - const resolve = continueResolve; - continueResolve = null; - el.continueBtn.hidden = true; - resolve(); - } - - function cancelContinue() { - continueResolve = null; // the abandoned turn just never resumes - el.continueBtn.hidden = true; - } - - function endTurn() { - if (game.mode === 'class' || game.mode === 'pair') return beginTurn(); // the server ends these games - if (game.passes >= game.players.length || Core.emptyCount(game.board) === 0) { - finishGame(); - return; - } - game.current = (game.current + 1) % game.players.length; - beginTurn(); - } - - // Instant CPU turns: roll, place and score straight away, no explanation. - const INSTANT_CPU_MS = 250; // just long enough for the board to redraw between turns - - async function runInstantComputerTurn() { - const p = currentPlayer(); - const [a, b] = (game.dice = Core.rollForBoard(game.board, game.diceBag, game.rng, game.fitMode)); - renderDie(el.dieA, a); - renderDie(el.dieB, b); - const move = Core.chooseComputerMove(game.board, a, b); - if (move) { - const rect = placeRect(move.x, move.y, move.w, move.h); - awardPoints(rect, true); - setMsg(`${p.name} rolled ${a} and ${b}: ${a} × ${b} = ${rect.area}.`); - } else { - p.log.push({ pass: true, a, b }); - p.stats.passes++; - game.passes++; - setMsg(`${p.name} rolled ${a} and ${b}: no room, so it passes.`); - } - render(); - await waitInGame(INSTANT_CPU_MS); - endTurn(); - } - - async function runComputerTurn() { - if (game.cpuInstant) return runInstantComputerTurn(); - await waitInGame(cpuStep()); - const [a, b] = await rollDice(); - const move = Core.chooseComputerMove(game.board, a, b); - if (!move) { - await pass(a, b); - return; - } - setMsg(''); - el.steps.hidden = false; - const hello = currentPlayer().hello ? `${currentPlayer().hello} ` : ''; // CPU character's greeting - addStep(`${hello}I rolled a <span class="num">${a}</span> and a <span class="num">${b}</span>.`); - await waitInGame(cpuStep()); - if (game.placeMode === 'draw') { - const where = game.board.rects.length ? 'snug against the others' : 'in a corner'; - addStep( - `I’ll draw it ${where}: <span class="num">${move.w}</span> across ` + - `and <span class="num">${move.h}</span> down.` - ); - await drawComputerRect(move); - } else { - addStep(`I’ll put my rectangle here, ${game.board.rects.length ? 'snug against the others' : 'in a corner'}…`); - game.cpuPreview = move; - render(); - } - await waitInGame(cpuStep()); - game.cpuPreview = null; - const rect = placeRect(move.x, move.y, move.w, move.h); - render(); - await explainComputerMove(rect); - await finishComputerTurn(cpuStep()); - endTurn(); - } - - // Grow the computer's rectangle: the first row one square at a time (at - // double speed), then one whole row per step. - async function drawComputerRect(move) { - for (let w = 1; w <= move.w; w++) { - game.cpuPreview = { ...move, w, h: 1 }; - render(); - await waitInGame(cpuStep() / 2); - } - for (let h = 2; h <= move.h; h++) { - game.cpuPreview = { ...move, h }; - render(); - await waitInGame(cpuStep()); - } - } - - function finishGame() { - game.phase = 'over'; - game.dice = null; - render(); - const ranked = game.players - .map((p, i) => ({ ...p, index: i })) - .sort((p, q) => q.score - p.score); - const top = ranked[0].score; - const winners = ranked.filter((p) => p.score === top); - const isSingle = game.mode === 'single'; - if (countsForStats()) recordGame(new Set(winners.map((p) => p.index))); - - if (winners.length > 1) el.resultTitle.textContent = "It's a tie!"; - else if (isSingle) el.resultTitle.textContent = winners[0].cpu ? `${winners[0].name} wins!` : 'You win! 🎉'; - else el.resultTitle.textContent = `${winners[0].name} wins!${winners[0].cpu ? '' : ' 🎉'}`; - - const empty = Core.emptyCount(game.board); - el.resultSub.textContent = - empty === 0 ? 'The board is completely full!' : `Nobody could fit a rectangle. ${empty} squares left empty.`; - - el.resultList.innerHTML = ''; - for (const p of ranked) { - const li = document.createElement('li'); - li.style.setProperty('--c', p.color); - const name = document.createElement('span'); - name.className = 'result-name'; - name.textContent = p.avatar ? `${p.avatar} ${p.name}` : p.name; - if (p.title) name.append(make('span', 'player-title', p.title)); - const pts = document.createElement('span'); - pts.className = 'result-pts'; - pts.textContent = `${p.score} pts`; - // Bar length relative to the top score, so the gap is easy to see. - const bar = document.createElement('span'); - bar.className = 'result-bar'; - const fill = document.createElement('span'); - fill.style.width = `${top ? (p.score / top) * 100 : 0}%`; - bar.append(fill); - li.append(name, pts, bar); - el.resultList.append(li); - } - renderRewards(winners, empty); - refreshNudges('results'); - if (winners.length === 1 && !winners[0].cpu) celebrateWin(); - renderStats(); - setDetailsOpen(false); - el.gameOver.hidden = false; - el.gameOver.querySelector('.dialog').scrollTop = 0; - el.againBtn.focus(); - } - - // Human players' points go into the shared wallet, and game achievements are - // checked. Shows "+N points" and any new achievements on the end screen. - function renderRewards(winners, empty) { - el.rewards.innerHTML = ''; - const humans = game.players.filter((p) => !p.cpu); - if (!humans.length) return; - const points = humans.reduce((sum, p) => sum + p.score, 0); - Progress.addPoints(progress, points); - if (!countsForStats()) { - // local multiplayer: points only, nothing permanent - saveProgress(); - el.rewards.append(make('p', 'setting-help', 'Multiplayer games are just for fun: they don’t count towards Stats or achievements.')); - return; - } - progress.counters.games++; - // Finishing bonus for bigger boards (single player against the CPU) - const boardBonus = game.mode === 'single' ? Progress.boardFinishBonus(game.size) : 0; - if (boardBonus) { - Progress.addPoints(progress, boardBonus); - const an = [8, 11, 18].includes(game.size) ? 'an' : 'a'; // "an 8×8", "an 11×11", "an 18×18" - toast(`🏁 Finished ${an} ${game.size}×${game.size} game!`, `+${boardBonus} bonus points`); - } - const humanWon = winners.length === 1 && !winners[0].cpu; - if (humanWon && game.mode === 'single') { - Progress.recordBoardWin(progress, game.size); // reveals the next board - syncBoardLocks(); - } - const asked = (p) => p.answered + p.stats.timeouts; - const perfect = humans.some((p) => asked(p) >= 5 && p.firstTry === asked(p)); - const noHelp = humans.some((p) => asked(p) >= 5 && p.stats.helped === 0); - const earned = Progress.awardAchievements(progress, { - event: 'game', - noHelp, - games: progress.counters.games, - wonVsCpu: game.mode === 'single' && humanWon, - humanWon, - full: empty === 0, - perfect, - difficulty: game.difficulty, - bestScore: Math.max(...humans.map((p) => p.score)), - }); - grantStickers(earned); // they show up behind the Stickers button - saveProgress(); - showRewards(earned); - } - - // End of a game: new achievements get their banners. Points and stickers go - // in quietly: the Shop / Wardrobe / Stickers buttons below show what's new. - function showRewards(earned) { - queueAchievements(earned); - } - - // Single-player and class games (and practice) count towards Stats and achievements. - function countsForStats() { - if (game && game.classroom && game.classroom.session.teacher) return false; // the teacher playing an odd one out - return game && ['single', 'class', 'pair'].includes(game.mode); - } - - // Whose times tables an answer counts towards. In a class game you play under - // your first name, but it's still you on this device. - function statsName(p) { - return p.statsName || p.name; - } - - // ---- end-of-game stats - - function make(tag, cls, text) { - const node = document.createElement(tag); - if (cls) node.className = cls; - if (text !== undefined) node.textContent = text; - return node; - } - - // Games saved before wrong answers were recorded store plain '6 × 7' strings. - function normalizeMiss(miss) { - return typeof miss === 'string' ? { fact: miss, answers: [], timeout: false } : miss; - } - - // Merge misses of the same fact: how often, which wrong answers, how many time-outs. - function groupMisses(misses, keyOf) { - const groups = new Map(); - for (const raw of misses) { - const miss = normalizeMiss(raw); - const key = keyOf(miss.fact); - const g = groups.get(key) || { fact: key, count: 0, answers: [], timeouts: 0 }; - g.count++; - g.answers.push(...miss.answers); - if (miss.timeout) g.timeouts++; - groups.set(key, g); - } - return [...groups.values()].sort((a, b) => b.count - a.count); - } - - // "6 × 7 = 42 (×2)" with "said 48, 36 · ⏰ ran out of time" underneath. - function renderPractice(name, color, groups, noneText) { - const block = make('div', 'practice'); - block.style.setProperty('--c', color); - block.append(make('div', 'practice-name', name)); - if (!groups.length) { - block.append(make('span', 'practice-none', noneText)); - return block; - } - const list = make('div', 'facts'); - for (const g of groups) { - const [a, b] = g.fact.split('×').map((n) => Number(n.trim())); - const item = make('div', 'fact'); - item.append(make('span', 'fact-eq', `${g.fact} = ${a * b}${g.count > 1 ? ` (×${g.count})` : ''}`)); - const details = []; - const said = [...new Set(g.answers)].slice(0, 5); - if (said.length) details.push(`said ${said.join(', ')}`); - if (g.timeouts) details.push(g.timeouts > 1 ? `⏰ ran out of time ×${g.timeouts}` : '⏰ ran out of time'); - if (g.rate) details.push(g.rate); - if (details.length) item.append(make('span', 'fact-said', details.join(' · '))); - list.append(item); - } - block.append(list); - return block; - } - - // ---- lifetime times-table grid - - // Biggest times table the player can reach: 6 (Easy), 8 (Medium) or 12 (Hard). - function unlockedTableSize() { - if (Progress.isUnlocked(progress, 'difficulty.hard')) return 12; - if (Progress.isUnlocked(progress, 'difficulty.medium')) return 8; - return 6; - } - - // "2.1s", or "12s" once it's 10 seconds or more, to fit in a grid cell. - function shortTime(ms) { - const secs = ms / 1000; - return secs < 10 ? `${secs.toFixed(1)}s` : `${Math.round(secs)}s`; - } - - const FACT_STATUS = { - mastered: { mark: '✓', label: 'Mastered' }, - learning: { mark: '~', label: 'Learning' }, - practice: { mark: '!', label: 'Needs practice' }, - unseen: { mark: '', label: 'Not seen yet' }, - }; - - // Which statuses show a best time instead of the icon (Settings → Times-table grid). - const TIMES_SETTING = { mastered: 'timesMastered', learning: 'timesLearning', practice: 'timesPractice' }; - - function showsTime(status) { - return TIMES_SETTING[status] && settings[TIMES_SETTING[status]] === 'time'; - } - - function renderFactLegend() { - const legend = make('div', 'fact-legend'); - for (const [status, { mark, label }] of Object.entries(FACT_STATUS)) { - const item = make('span', 'fact-legend-item'); - item.append(make('span', `fact-cell ${status}`, mark), document.createTextNode(label)); - legend.append(item); - } - return legend; - } - - // Rows and columns 1..N (6, or up to 12 once bigger dice are in play); hover a cell for details. - // Single player and practice play as "You" (personal accounts will replace this). - function isDefaultPlayer(name) { - return Progress.playerKey(name) === 'you'; - } - - function renderFactGrid(name, facts) { - const seenMax = Math.max(0, ...Object.keys(facts).flatMap((k) => k.split('×').map((n) => Number(n.trim())))); - const unlocked = unlockedTableSize(); - const max = Math.max(6, seenMax > 8 ? 12 : seenMax > 6 ? 8 : 6, unlocked); - const wrap = make('div', 'fact-grid-wrap'); - if (name) wrap.append(make('div', 'practice-name', name)); // no label for the default "You" - const grid = make('div', 'fact-grid'); - grid.style.setProperty('--n', max + 1); - // Mastery badge: a gold ★ on a line's header once the whole line is mastered (both orders) - const head = (n) => { - const done = Progress.lineReached(facts, n, max, 'mastered'); - const cell = make('span', 'fact-head' + (done ? ' line-mastered' : ''), done ? `★${n}` : String(n)); - if (done) cell.title = `Every ${n} times fact is mastered!`; - return cell; - }; - grid.append(make('span', 'fact-head', '×')); - for (let c = 1; c <= max; c++) grid.append(head(c)); - for (let r = 1; r <= max; r++) { - grid.append(head(r)); - for (let c = 1; c <= max; c++) { - const f = facts[Progress.factKey(r, c)]; - const status = Progress.factStatus(f); - const best = Progress.factBestMs(f); - // Best time instead of the icon when that's switched on for this status. - const timed = showsTime(status) && best !== null; - const cell = make('span', `fact-cell ${status}${timed ? ' timed' : ''}`, timed ? shortTime(best) : FACT_STATUS[status].mark); - cell.title = f - ? `${r} × ${c} = ${r * c}: ${FACT_STATUS[status].label}. ${f.firstTry} of ${f.asked} right first time` + - (f.wrong ? `, ${f.wrong} wrong` : '') + - (f.timeouts ? `, ${f.timeouts} timed out` : '') + - (best !== null ? `, best ${(best / 1000).toFixed(1)} s` : '') + - (f.timed ? `, avg ${(f.totalMs / f.timed / 1000).toFixed(1)} s` : '') - : `${r} × ${c} = ${r * c}: not seen yet`; - grid.append(cell); - } - } - wrap.append(grid); - return wrap; - } - - function renderStats() { - const dash = '—'; - const secs = (ms) => `${(ms / 1000).toFixed(1)} s`; - const human = (fn) => (p) => (p.cpu ? dash : fn(p)); - const asked = (p) => p.answered + p.stats.timeouts; - const rows = [ - ['Points', (p) => p.score], - ['Squares', (p) => p.squares], - ['Bonus points', human((p) => p.bonus)], - ['Best streak', human((p) => p.bestStreak)], - ['Rectangles drawn', (p) => p.stats.placed], - ['Biggest rectangle', (p) => (p.stats.biggest ? `${p.stats.biggest.a} × ${p.stats.biggest.b} = ${p.stats.biggest.area}` : dash)], - ['Right on first try', human((p) => (asked(p) ? `${p.firstTry} of ${asked(p)}` : dash))], - ['Wrong answers', human((p) => p.stats.wrong)], - ['Used “Help me count”', human((p) => p.stats.helped)], - game.answerTime ? ['Ran out of time', human((p) => p.stats.timeouts)] : null, - ['Average answer time', human((p) => (p.stats.times.length ? secs(p.stats.times.reduce((s, t) => s + t, 0) / p.stats.times.length) : dash))], - ['Fastest answer', human((p) => (p.stats.times.length ? secs(Math.min(...p.stats.times)) : dash))], - ['Passes', (p) => p.stats.passes], - ].filter(Boolean); - - el.stats.innerHTML = ''; - - // Game summary - const total = game.size * game.size; - const filled = total - Core.emptyCount(game.board); - const elapsed = Math.round((Date.now() - game.startedAt) / 1000); - const time = elapsed >= 60 ? `${Math.floor(elapsed / 60)} min ${elapsed % 60} s` : `${elapsed} s`; - el.stats.append( - make('p', 'stats-summary', `⏱ ${time} · ${game.turns} turns · ${filled} of ${total} squares filled (${Math.round((filled / total) * 100)}%)`) - ); - - // Per-player table - const wrap = make('div', 'stats-table-wrap'); - const table = make('table', 'stats-table'); - const head = table.createTHead().insertRow(); - head.append(make('th')); - for (const p of game.players) { - const th = make('th'); - th.style.setProperty('--c', p.color); - th.append(make('span', 'dot'), document.createTextNode(p.name)); - head.append(th); - } - const body = table.createTBody(); - for (const [label, value] of rows) { - const tr = body.insertRow(); - tr.append(make('th', null, label)); - for (const p of game.players) tr.append(make('td', null, String(value(p)))); - } - wrap.append(table); - el.stats.append(wrap); - - // Facts to practise - const humans = game.players.filter((p) => !p.cpu); - if (humans.some((p) => asked(p) > 0)) { - el.stats.append(make('h3', 'stats-heading', 'Facts to practice')); - for (const p of humans) { - const none = asked(p) ? 'Nothing to practice. Every answer right first time! 🌟' : 'No answers this game.'; - el.stats.append(renderPractice(p.name, p.color, groupMisses(p.stats.missed, (f) => f), none)); - } - } - } - - // ---------------------------------------------------------------- all-time stats - - // Finished games are kept in this browser only, newest last. - const HISTORY_KEY = 'blockout.history'; - const HISTORY_LIMIT = 200; - - function loadHistory() { - try { - const saved = JSON.parse(localStorage.getItem(HISTORY_KEY)); - return Array.isArray(saved) ? saved : []; - } catch (_) { - return []; - } - } - - function saveHistory(history) { - try { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-HISTORY_LIMIT))); - } catch (_) { - // not fatal: stats just won't persist - } - } - - function recordGame(winnerIndexes) { - const tie = winnerIndexes.size > 1; - const history = loadHistory(); - history.push({ - at: Date.now(), - size: game.size, - duration: Date.now() - game.startedAt, - turns: game.turns, - difficulty: game.difficulty, - vsComputer: game.mode === 'single', // "vs CPU" record counts single-player games only - players: game.players.map((p, i) => ({ - name: p.name, - cpu: Boolean(p.cpu), - score: p.score, - bonus: p.bonus, - result: winnerIndexes.has(i) ? (tie ? 'tie' : 'win') : 'loss', - answered: p.answered, - firstTry: p.firstTry, - timeouts: p.stats.timeouts, - times: p.stats.times.map(Math.round), - missed: p.stats.missed, - })), - }); - saveHistory(history); - } - - // Practice rounds share the history list, marked kind: 'practice'. - function recordPracticeRound(pr) { - const history = loadHistory(); - history.push({ - kind: 'practice', - at: Date.now(), - duration: Date.now() - pr.startedAt, - name: pr.name, - size: pr.max, - level: pr.expert ? 'expert' : 'learn', - tables: pr.tables, - count: pr.count, - firstTry: pr.firstTry, - times: pr.times, - points: pr.points, - }); - saveHistory(history); - } - - function formatDuration(ms) { - const mins = Math.round(ms / 60000); - if (mins < 1) return `${Math.round(ms / 1000)} s`; - if (mins < 60) return `${mins} min`; - return `${Math.floor(mins / 60)} h ${mins % 60} min`; - } - - function renderHistory() { - const history = loadHistory(); - const body = el.historyBody; - body.innerHTML = ''; - const factPlayers = Object.values(progress.facts).filter((p) => Object.keys(p.facts).length); - el.resetHistory.hidden = !history.length && !factPlayers.length; - if (!history.length && !factPlayers.length) { - body.append(make('p', 'history-empty', 'No games or practice yet. Play one and your stats will show up here!')); - return; - } - const games = history.filter((g) => !g.kind); - const rounds = history.filter((g) => g.kind === 'practice'); - const classes = history.filter((g) => g.kind === 'class' && !g.type); - const pairGames = history.filter((g) => g.kind === 'class' && g.type === 'pairs'); - const tournaments = history.filter((g) => g.kind === 'class' && g.type === 'tournament'); - if (history.length) renderGameHistory(body, games, rounds, classes, pairGames, tournaments); - - // Lifetime times tables for everyone who has answered anything, in games or practice - const names = new Map(); - for (const g of games) for (const p of g.players) if (!p.cpu) names.set(Progress.playerKey(p.name), p.name); - for (const p of factPlayers) if (!names.has(Progress.playerKey(p.name))) names.set(Progress.playerKey(p.name), p.name); - body.append(make('h3', 'stats-heading centered', 'Times Table')); - body.append(renderFactLegend()); - for (const name of names.values()) { - const facts = playerFacts(name); - const label = isDefaultPlayer(name) ? '' : name; // "You" needs no label - body.append(renderFactGrid(label, facts)); - const toPractice = Object.entries(facts) - .filter(([, f]) => f.wrong + f.timeouts > 0) - .sort(([, a], [, b]) => b.wrong + b.timeouts - (a.wrong + a.timeouts)) - .slice(0, 8) - .map(([fact, f]) => ({ fact, count: f.wrong + f.timeouts, answers: f.said, timeouts: f.timeouts, rate: `${f.firstTry} of ${f.asked} right first time` })); - const none = Object.keys(facts).length ? 'Nothing missed so far! 🌟' : 'No answers yet.'; - body.append(renderPractice(label ? `${label}: facts to practice` : 'Facts to practice', 'var(--line)', toPractice, none)); - } - - if (games.length || classes.length || pairGames.length || tournaments.length) renderRecentGames(body, [...games, ...classes, ...pairGames, ...tournaments].sort((a, b) => a.at - b.at)); - } - - // Tiles and the per-player table (finished games only). - const DIFFICULTY_NAMES = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }; - const DIFFICULTY_ORDER = { easy: 0, medium: 1, hard: 2 }; - const avgTime = (times) => (times.length ? `${(times.reduce((x, t) => x + t, 0) / times.length / 1000).toFixed(1)} s` : '—'); - const percent = (n, of) => (of ? `${Math.round((n / of) * 100)}%` : '—'); - - // A table whose first column is a two-line label (bold title, muted details). - function statsTable(headings, rows) { - const wrap = make('div', 'stats-table-wrap'); - const table = make('table', 'stats-table by-type'); - const head = table.createTHead().insertRow(); - for (const label of headings) head.append(make('th', null, label)); - const tbody = table.createTBody(); - for (const row of rows) { - const tr = tbody.insertRow(); - const th = make('th'); - th.append(make('span', 'row-title', row.title), make('span', 'row-sub', row.sub)); - tr.append(th); - for (const v of row.values) tr.append(make('td', null, String(v))); - } - wrap.append(table); - return wrap; - } - - // Tiles, then one row per game type (mode · board · difficulty), per practice - // type and per classroom game type. - function renderGameHistory(body, games, rounds, classes, pairGames, tournaments) { - const vs = games - .filter((g) => g.vsComputer && g.players.some((p) => !p.cpu)) - .map((g) => g.players.find((p) => !p.cpu).result); - const count = (r) => vs.filter((x) => x === r).length; - const tiles = make('div', 'tiles'); - const tile = (value, label) => { - const t = make('div', 'tile'); - t.append(make('span', 'tile-value', value), make('span', 'tile-label', label)); - return t; - }; - const played = games.length + classes.length + pairGames.length + tournaments.length; // class games are games too - tiles.append( - tile(`${played}${rounds.length ? ` + ${rounds.length}` : ''}`, rounds.length ? 'games + practice rounds' : 'games played'), - tile(formatDuration([...games, ...rounds, ...classes, ...pairGames].reduce((s, g) => s + g.duration, 0)), 'time played'), - tile(vs.length ? `${count('win')}–${count('loss')}–${count('tie')}` : '—', 'vs CPU (W–L–T)') - ); - body.append(tiles); - - if (games.length) { - const groups = new Map(); - for (const g of games) { - const mode = g.vsComputer ? 'single' : 'multi'; - const difficulty = g.difficulty || 'easy'; // older games were all 6-sided dice - const key = `${mode}|${g.size}|${difficulty}`; - const row = groups.get(key) || { mode, size: g.size, difficulty, games: 0, wins: 0, best: 0, asked: 0, firstTry: 0, times: [] }; - row.games++; - const humans = g.players.filter((p) => !p.cpu); - if (humans.some((p) => p.result === 'win')) row.wins++; - for (const p of humans) { - row.best = Math.max(row.best, p.score); - row.asked += p.answered + p.timeouts; - row.firstTry += p.firstTry; - row.times.push(...p.times); - } - groups.set(key, row); - } - const rows = [...groups.values()] - .sort((a, b) => (a.mode === b.mode ? 0 : a.mode === 'single' ? -1 : 1) || a.size - b.size || DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]) - .map((r) => ({ - title: r.mode === 'single' ? 'Single player' : 'Multiplayer', - sub: `${r.size}×${r.size} · ${DIFFICULTY_NAMES[r.difficulty]}`, - values: [r.games, r.wins, r.best, percent(r.firstTry, r.asked), avgTime(r.times)], - })); - body.append(make('h3', 'stats-heading', 'Games')); - body.append(statsTable(['', 'Games', 'Wins', 'Best score', 'Right 1st try', 'Avg answer'], rows)); - } - - if (rounds.length) { - const groups = new Map(); - for (const r of rounds) { - const key = `${r.size}|${r.level}`; - const row = groups.get(key) || { size: r.size, level: r.level, rounds: 0, best: null, asked: 0, firstTry: 0, times: [] }; - row.rounds++; - if (!row.best || r.firstTry / r.count > row.best.firstTry / row.best.count) row.best = r; - row.asked += r.count; - row.firstTry += r.firstTry; - row.times.push(...r.times); - groups.set(key, row); - } - const rows = [...groups.values()] - .sort((a, b) => a.size - b.size || (a.level === b.level ? 0 : a.level === 'learn' ? -1 : 1)) - .map((r) => ({ - title: 'Practice', - sub: `up to ×${r.size} · ${r.level === 'expert' ? 'Expert' : 'Learn'}`, - values: [r.rounds, `${r.best.firstTry}/${r.best.count}`, percent(r.firstTry, r.asked), avgTime(r.times)], - })); - body.append(make('h3', 'stats-heading', 'Practice')); - body.append(statsTable(['', 'Rounds', 'Best round', 'Right 1st try', 'Avg answer'], rows)); - } - - if (classes.length) { - const groups = new Map(); - for (const g of classes) { - const key = `${g.size}|${g.difficulty}`; - const row = groups.get(key) || { size: g.size, difficulty: g.difficulty, games: 0, bestRank: null, best: 0, asked: 0, firstTry: 0, times: [] }; - row.games++; - if (!row.bestRank || g.rank < row.bestRank.rank) row.bestRank = g; - row.best = Math.max(row.best, g.score); - row.asked += g.answered + g.timeouts; - row.firstTry += g.firstTry; - row.times.push(...g.times); - groups.set(key, row); - } - const rows = [...groups.values()] - .sort((a, b) => a.size - b.size || DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]) - .map((r) => ({ - title: 'Classroom', - sub: `${r.size}×${r.size} · ${DIFFICULTY_NAMES[r.difficulty]}`, - values: [r.games, `${ordinal(r.bestRank.rank)} of ${r.bestRank.of}`, r.best, percent(r.firstTry, r.asked), avgTime(r.times)], - })); - body.append(make('h3', 'stats-heading', 'Classroom')); - body.append(statsTable(['', 'Games', 'Best place', 'Best score', 'Right 1st try', 'Avg answer'], rows)); - } - - if (pairGames.length) { - const groups = new Map(); - for (const g of pairGames) { - const key = `${g.size}|${g.difficulty}`; - const row = groups.get(key) || { size: g.size, difficulty: g.difficulty, games: 0, wins: 0, best: 0, asked: 0, firstTry: 0, times: [] }; - row.games++; - if (g.result === 'win') row.wins++; - row.best = Math.max(row.best, g.score); - row.asked += g.answered + g.timeouts; - row.firstTry += g.firstTry; - row.times.push(...g.times); - groups.set(key, row); - } - const rows = [...groups.values()] - .sort((a, b) => a.size - b.size || DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]) - .map((r) => ({ - title: 'Pairs', - sub: `${r.size}×${r.size} · ${DIFFICULTY_NAMES[r.difficulty]}`, - values: [r.games, r.wins, r.best, percent(r.firstTry, r.asked), avgTime(r.times)], - })); - body.append(make('h3', 'stats-heading', 'Classroom: pairs')); - body.append(statsTable(['', 'Games', 'Wins', 'Best score', 'Right 1st try', 'Avg answer'], rows)); - } - - if (tournaments.length) { - const groups = new Map(); - for (const g of tournaments) { - const row = groups.get(g.format) || { format: g.format, played: 0, titles: 0, best: null, wins: 0, losses: 0 }; - row.played++; - if (g.place === 1) row.titles++; - if (!row.best || g.place < row.best.place) row.best = g; - row.wins += g.wins; - row.losses += g.losses; - groups.set(g.format, row); - } - const rows = [...groups.values()].map((r) => ({ - title: 'Tournament', - sub: TOURNAMENT_NAMES[r.format] || r.format, - values: [r.played, r.titles, `${ordinal(r.best.place)} of ${r.best.of}`, `${r.wins}–${r.losses}`], - })); - body.append(make('h3', 'stats-heading', 'Classroom: tournaments')); - body.append(statsTable(['', 'Played', 'Titles', 'Best finish', 'Games W–L'], rows)); - } - } - - function renderRecentGames(body, history) { - body.append(make('h3', 'stats-heading', 'Recent games')); - const recent = make('ol', 'recent'); - for (const g of history.slice(-10).reverse()) { - const li = make('li'); - const when = new Date(g.at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); - if (g.kind === 'class' && g.type === 'tournament') { - li.append(make('span', 'meta', `${when} · Class ${g.code} · ${TOURNAMENT_NAMES[g.format] || 'Tournament'}`)); - li.append(document.createTextNode(`${g.name} ${g.wins}–${g.losses} · `)); - li.append(make('span', 'winner', g.place === 1 ? '🏆 Champion' : `${ordinal(g.place)} of ${g.of} · 🏆 ${g.champion}`)); - recent.append(li); - continue; - } - if (g.kind === 'class' && g.type === 'pairs') { - li.append(make('span', 'meta', `${when} · Class ${g.code} pairs · ${g.size}×${g.size}`)); - li.append(document.createTextNode(`${g.name} ${g.score} – ${g.opponent} ${g.opponentScore} · `)); - li.append(make('span', 'winner', g.result === 'win' ? `🏆 ${g.name}` : g.result === 'tie' ? 'Tie' : `🏆 ${g.opponent}`)); - recent.append(li); - continue; - } - if (g.kind === 'class') { - li.append(make('span', 'meta', `${when} · Class ${g.code} · ${g.size}×${g.size}`)); - li.append(document.createTextNode(`${g.name} ${g.score} · `)); - li.append(make('span', 'winner', g.rank === 1 && g.of > 1 ? `🏆 1st of ${g.of}` : `${ordinal(g.rank)} of ${g.of}`)); - recent.append(li); - continue; - } - li.append(make('span', 'meta', `${when} · ${g.size}×${g.size}`)); - li.append(document.createTextNode(g.players.map((p) => `${p.name} ${p.score}`).join(' – '))); - const won = g.players.filter((p) => p.result === 'win'); - li.append(document.createTextNode(' · ')); - li.append(make('span', 'winner', won.length ? `🏆 ${won[0].name}` : 'Tie')); - recent.append(li); - } - body.append(recent); - } - - function openHistory() { - renderHistory(); - el.historyDialog.hidden = false; - el.historyDialog.querySelector('.dialog').scrollTop = 0; - el.historyDone.focus(); - } - - function closeHistory() { - el.historyDialog.hidden = true; - el.historyBtn.focus(); - } - - el.historyBtn.addEventListener('click', openHistory); - - // How to play popup - const howto = $('howto'); - $('howto-btn').addEventListener('click', () => { - howto.hidden = false; - howto.querySelector('.dialog').scrollTop = 0; - $('howto-done').focus(); - }); - $('howto-done').addEventListener('click', () => { - howto.hidden = true; - $('howto-btn').focus(); - }); - howto.addEventListener('click', (e) => { - if (e.target === howto) howto.hidden = true; - }); - howto.addEventListener('keydown', (e) => { - if (e.key === 'Escape') howto.hidden = true; - }); - el.historyDone.addEventListener('click', closeHistory); - el.historyDialog.addEventListener('click', (e) => { - if (e.target === el.historyDialog) closeHistory(); - }); - el.historyDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeHistory(); - }); - el.resetHistory.addEventListener('click', () => { - if (!confirm('Delete all game history and times-table stats on this device? Points, unlocks and achievements are kept. This cannot be undone.')) return; - try { - localStorage.removeItem(HISTORY_KEY); - } catch (_) { - // nothing saved anyway - } - progress.facts = {}; - saveProgress(); - renderHistory(); - }); - - // ---------------------------------------------------------------- shop - - const BUY_REASONS = { - season: (r) => `In the shop in ${r.when}`, - points: (r) => `Need ${formatPoints(r.missing)} more`, - requires: (r) => `Unlock ${Progress.shopItem(r.requires).name.split(':')[0]} first`, - win: (r) => `Win a game on ${r.size}×${r.size} first`, - master: (r) => `Master your Times Table up to ${r.size} × ${r.size} first`, - }; - - // Opened from a results screen, the Shop / Wardrobe / Stickers show only what - // their button promised; this line says so and can switch to everything. - function filterNote(text, showAll) { - const note = make('p', 'filter-note', `${text} · `); - const btn = make('button', 'link-btn', 'Show everything'); - btn.type = 'button'; - btn.addEventListener('click', showAll); - note.append(btn); - return note; - } - - // Bottom bar of the Shop and Wardrobe: points you have, what an item would - // leave you with while it's hovered, the All / Available toggle and Done. - function renderWalletBar(prefix, onlyAvailable) { - $(`${prefix}-wallet`).textContent = formatPoints(progress.wallet); - previewCost(prefix, null); - for (const b of $(`${prefix}-view`).querySelectorAll('button')) { - const on = (b.dataset.view === 'available') === onlyAvailable; - b.classList.toggle('selected', on); - b.setAttribute('aria-pressed', on); - } - } - - function previewCost(prefix, cost) { - const after = $(`${prefix}-wallet-after`); - after.replaceChildren(); - after.className = 'wallet-after'; - if (cost == null) return; - const left = progress.wallet - cost; - if (left >= 0) { - after.append(' ', make('span', 'wallet-cost', `− ${formatPoints(cost)}`), ' = ', make('strong', 'wallet-left', formatPoints(left))); - } else { - after.classList.add('short'); - after.append(` · need ${formatPoints(-left)} more`); - } - } - - for (const [prefix, selector] of [['shop', '[data-cost]'], ['wardrobe', '.wardrobe-chip[data-cost]']]) { - const dialog = $(prefix); - const show = (e) => { - const target = e.target.closest(selector); - previewCost(prefix, target ? Number(target.dataset.cost) : null); - }; - dialog.addEventListener('pointerover', show); - dialog.addEventListener('focusin', show); - dialog.addEventListener('pointerleave', () => previewCost(prefix, null)); - } - - let shopFilter = null; // Set of item ids (affordable when picked), or null for everything - let shopHighlight = null; - - function renderShop(highlight = shopHighlight) { - shopHighlight = highlight; - el.shopBody.innerHTML = ''; - renderWalletBar('shop', !!shopFilter); - if (shopFilter && !shopFilter.size) el.shopBody.append(make('p', 'filter-note', 'Nothing to unlock right now. Keep playing to earn points!')); - let group = null; - for (const item of Progress.SHOP) { - if (item.cosmetic) continue; // looks are unlocked in the Wardrobe - if (shopFilter && !shopFilter.has(item.id)) continue; - if (item.group !== group) { - group = item.group; - el.shopBody.append(make('h3', 'stats-heading', group)); - } - const row = make('div', 'shop-item' + (item.id === highlight ? ' highlight' : '')); - const hiddenStep = !Progress.isUnlocked(progress, item.id) && Progress.isHidden(progress, item.id); - if (hiddenStep) row.classList.add('skeleton-row'); - const name = make('span', 'shop-name', hiddenStep ? 'Locked' : item.name); - if (!hiddenStep && item.perk) name.append(make('span', 'shop-perk', `🎁 ${item.perk}`)); - row.append(name); - const owned = Progress.isUnlocked(progress, item.id); - if (owned) { - row.classList.add('owned'); - const owned = make('span', 'shop-owned'); - owned.append(icon('check'), ' Unlocked'); - row.append(owned); - } else { - const check = Progress.canBuy(progress, item.id); - const btn = make('button', 'btn btn-small' + (check.ok ? ' btn-primary' : ''), `🪙 ${formatPoints(item.price)}`); - btn.type = 'button'; - btn.dataset.buy = item.id; - btn.dataset.cost = item.price; - btn.disabled = !check.ok; - row.append(btn); - if (hiddenStep) { - btn.replaceChildren(icon('lock')); - btn.setAttribute('aria-label', 'Locked'); - const earn = Progress.canBuy(progress, item.id); - row.append(make('span', 'shop-why', earn.reason === 'win' ? `Win a game on ${earn.size}×${earn.size} to reveal it` : earn.reason === 'master' ? `Master your Times Table up to ${earn.size} × ${earn.size} to reveal it (${earn.mastered} of ${earn.need})` : 'Unlock the one before it first')); - } else if (!check.ok) row.append(make('span', 'shop-why', BUY_REASONS[check.reason](check))); - } - el.shopBody.append(row); - } - } - - function shopAvailable() { - return new Set(Progress.SHOP.filter((i) => !i.cosmetic && Progress.canBuy(progress, i.id).ok).map((i) => i.id)); - } - - // What was tapped to open the shop, so buying it switches that exact choice on. - let shopSource = null; // { item, el } - - // onlyAffordable: from a results screen, list just what can be bought right now - function openShop(highlight, sourceEl = null, onlyAffordable = false) { - shopSource = highlight && sourceEl ? { item: highlight, el: sourceEl } : null; - shopFilter = onlyAffordable ? shopAvailable() : null; - renderShop(highlight); - el.shopDialog.hidden = false; - const target = highlight && el.shopBody.querySelector('.shop-item.highlight'); - if (target) target.scrollIntoView({ block: 'center' }); - else el.shopDialog.querySelector('.dialog').scrollTop = 0; - el.shopDone.focus({ preventScroll: true }); - } - - function closeShop() { - el.shopDialog.hidden = true; - } - - el.shopBtn.addEventListener('click', () => openShop()); - el.shopDone.addEventListener('click', closeShop); - el.resultsShopBtn.addEventListener('click', () => openShop(null, null, true)); - el.pdShopBtn.addEventListener('click', () => openShop(null, null, true)); - for (const prefix of ['results', 'pd']) { - $(`${prefix}-wardrobe-btn`).addEventListener('click', () => openWardrobe(true)); - $(`${prefix}-stickers-btn`).addEventListener('click', () => openStickers(true)); - } - el.shopDialog.addEventListener('click', (e) => { - if (e.target === el.shopDialog) return closeShop(); - const view = e.target.closest('#shop-view [data-view]'); - if (view) { - shopFilter = view.dataset.view === 'available' ? shopAvailable() : null; - renderShop(null); - el.shopDialog.querySelector('.dialog').scrollTop = 0; - return; - } - const btn = e.target.closest('[data-buy]'); - if (btn) purchase(Progress.shopItem(btn.dataset.buy)); - }); - - // Buy an item and switch it on; used by the shop and the quick unlock. - function purchase(item) { - if (!Progress.buy(progress, item.id).ok) return false; - toast(`🔓 Unlocked: ${item.name}`, `-${formatPoints(item.price)} points`); - celebrate(Progress.awardAchievements(progress, { event: 'buy' })); - saveProgress(); - refreshAvailableFilters(); - if (!el.shopDialog.hidden) renderShop(item.id); - syncSettingsUI(); - syncBoardLocks(); - syncModeLocks(); - updatePracticePreview(); - refreshOpenNudges(); - activatePurchase(item); - return true; - } - - // After a purchase, "Available" lists catch up: newly revealed tiers appear, - // things you can't afford any more go, and what you've bought stays (✓ Unlocked). - function refreshAvailableFilters() { - if (shopFilter) { - const bought = [...shopFilter].filter((id) => Progress.isUnlocked(progress, id)); - shopFilter = new Set([...shopAvailable(), ...bought]); - } - if (wardrobeFilter) { - const owned = [...wardrobeFilter].filter((key) => { - const [cat, opt] = key.split(':'); - return cat === 'title' || Progress.isUnlocked(progress, Cosmetics.itemFor(cat, opt)); - }); - wardrobeFilter = new Set([...wardrobeAvailable(), ...owned]); - } - } - - // ---- quick unlock: tapping a locked thing asks "Unlock X for 🪙 N?" (no whole shop) - - let unlockAsk = null; // { item, el } - - // What to do to reveal a locked board or difficulty. - function revealHint(r) { - if (r.reason === 'win') return `Win a game against the CPU on the ${r.size}×${r.size} board to reveal the next board.`; - const extra = r.practice ? ` and get the ${r.practice} ${r.practice === 1 ? 'fact' : 'facts'} that need practice back on track` : ''; - const which = r.faces ? `using ${tablesLabel(r.faces)}` : `up to ${r.size} × ${r.size}`; - return `Master ${r.need} of the ${r.total} facts ${which} on your Times Table${extra} to reveal the next difficulty. You have ${r.mastered} so far. Practice helps!`; - } - - function confirmUnlock(itemId, sourceEl) { - let item = Progress.shopItem(itemId); - const check = Progress.canBuy(progress, itemId); - let offer = check.ok; - let message; - if (Progress.isHidden(progress, itemId)) { - // a locked placeholder: offer the next step, without saying what this one is - item = Progress.shopItem(Progress.nextInChain(progress, itemId)); - const next = Progress.canBuy(progress, item.id); - if (next.reason === 'win' || next.reason === 'master') { - // earned, not bought: a win on the board before, or knowing the times table - unlockAsk = null; - el.unlockTitle.textContent = '🔒 Locked'; - el.unlockPrice.textContent = ''; - el.unlockMessage.textContent = revealHint(next); - el.unlockMessage.hidden = false; - el.unlockYes.hidden = true; - el.unlockNo.textContent = 'OK'; - el.unlockDialog.hidden = false; - el.unlockNo.focus(); - return; - } - offer = next.ok; - message = 'Unlock this first to see what comes next.'; - if (next.reason === 'points') message += ` You need ${formatPoints(next.missing)} more points for it.`; - sourceEl = null; - } else if (check.reason === 'requires') { - // offer the thing that has to come first - const first = Progress.shopItem(check.requires); - const firstCheck = Progress.canBuy(progress, first.id); - message = `${item.name} needs ${first.name} first.`; - item = first; - offer = firstCheck.ok; - if (!offer) message += firstCheck.reason === 'points' ? ` You need ${formatPoints(firstCheck.missing)} more points for it.` : ''; - sourceEl = null; // what gets switched on is the prerequisite - } else if (check.reason === 'points') { - message = `You need ${formatPoints(check.missing)} more points. Keep playing to earn them!`; - } else if (check.reason === 'season') { - message = `This one is in the shop in ${check.when}.`; - } - unlockAsk = offer ? { item, el: sourceEl } : null; - el.unlockTitle.textContent = offer ? `Unlock ${item.name}?` : item.name; - el.unlockPrice.textContent = `🪙 ${formatPoints(item.price)} · you have ${formatPoints(progress.wallet)}`; - // what it gives you (e.g. the bonus for finishing longer practice rounds) - const perk = offer && item.perk ? `🎁 ${item.perk}` : ''; - el.unlockMessage.textContent = [perk, message].filter(Boolean).join(' '); - el.unlockMessage.hidden = !perk && !message; - el.unlockYes.hidden = !offer; - if (offer) el.unlockYes.textContent = `Unlock for 🪙 ${formatPoints(item.price)}`; - el.unlockNo.textContent = offer ? 'Cancel' : 'OK'; - el.unlockDialog.hidden = false; - (offer ? el.unlockYes : el.unlockNo).focus(); - } - - function closeUnlock() { - el.unlockDialog.hidden = true; - unlockAsk = null; - } - - el.unlockYes.addEventListener('click', () => { - const ask = unlockAsk; - closeUnlock(); - if (!ask) return; - shopSource = ask.el ? { item: ask.item.id, el: ask.el } : null; - purchase(ask.item); - }); - el.unlockNo.addEventListener('click', closeUnlock); - el.unlockDialog.addEventListener('click', (e) => { - if (e.target === el.unlockDialog) closeUnlock(); - }); - el.unlockDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeUnlock(); - }); - - // ---- "you can afford something" button on the results screens - - // Shop items (not Wardrobe looks) you can afford right now - function affordableCount() { - return Progress.SHOP.filter((item) => !item.cosmetic && Progress.canBuy(progress, item.id).ok).length; - } - - // Things in the Wardrobe you own (or titles you've earned) but haven't looked at yet. - function wardrobeKeys() { - const keys = []; - for (const cat of Cosmetics.CATEGORIES) { - for (const opt of cat.options) { - if (opt.price && Progress.isUnlocked(progress, Cosmetics.itemFor(cat.id, opt.id))) keys.push(`${cat.id}:${opt.id}`); - } - } - for (const t of Cosmetics.TITLES) if (t.id !== 'none' && t.earned(progress)) keys.push(`title:${t.id}`); - return keys; - } - - const seenList = (kind) => (progress.seen = progress.seen || {})[kind] || (progress.seen[kind] = []); - const newWardrobe = () => wardrobeKeys().filter((k) => !seenList('wardrobe').includes(k)).length; - // Wardrobe looks you could buy right now (the Wardrobe's share of the shop) - const affordableLooks = () => Progress.SHOP.filter((i) => i.cosmetic && Progress.canBuy(progress, i.id).ok).length; - const newStickers = () => Object.keys(progress.stickers || {}).filter((id) => !seenList('stickers').includes(id)).length; - const spareStickers = () => Object.values(progress.stickers || {}).reduce((n, c) => n + Math.max(0, c - 1), 0); - - function markSeen(kind, keys) { - progress.seen[kind] = [...new Set([...seenList(kind), ...keys])]; - saveProgress(); - } - - // Shop / Wardrobe / Stickers buttons on a results screen ('results' or 'pd'), only when there's something new. - function refreshNudges(prefix) { - const shop = $(`${prefix}-shop-btn`); - const wardrobe = $(`${prefix}-wardrobe-btn`); - const stickers = $(`${prefix}-stickers-btn`); - const n = affordableCount(); - shop.hidden = n === 0; - if (n) shop.replaceChildren(icon('shopping-cart'), ` Shop · ${n} new ${n === 1 ? 'unlock' : 'unlocks'}`); - const w = newWardrobe(); - const looks = affordableLooks(); - wardrobe.hidden = !w && !looks; - const wParts = [w && `${w} new`, looks && `${looks} to unlock`].filter(Boolean); - if (!wardrobe.hidden) wardrobe.replaceChildren(icon('shirt'), ` Wardrobe · ${wParts.join(' · ')}`); - const fresh = newStickers(); - const spares = spareStickers(); - stickers.hidden = !fresh && !spares; - const parts = [fresh && `${fresh} new`, spares && `${spares} ${spares === 1 ? 'spare' : 'spares'}`].filter(Boolean); - if (!stickers.hidden) stickers.replaceChildren(icon('sticker'), ` Stickers · ${parts.join(' · ')}`); - } - - function refreshOpenNudges() { - if (!el.gameOver.hidden) refreshNudges('results'); - if (!el.practiceDone.hidden) refreshNudges('pd'); - } - - // ---- switch a purchase on straight away - - // Click a control (even inside a closed dialog) as if the player had tapped it. - function tap(selector) { - const target = document.querySelector(selector); - if (target && !target.dataset.unlock) target.click(); - } - - const setOption = (key, value) => () => tap(`[data-setting="${key}"] [data-value="${value}"]`); - - // Bought straight from the shop list: switch on the obvious choice. Items with - // more than one choice and no clear favourite (CPU speed, placing) are left alone. - const ACTIVATE = { - multiplayer: () => (tap('[data-mode="multi"]'), tap('#multi-kind [data-kind="local"]')), - players3: () => (tap('[data-mode="multi"]'), tap('#multi-kind [data-kind="local"]'), tap('#player-count [data-count="3"]')), - players4: () => (tap('[data-mode="multi"]'), tap('#multi-kind [data-kind="local"]'), tap('#player-count [data-count="4"]')), - practice: () => tap('[data-mode="practice"]'), - ...Object.fromEntries(Progress.DIFFICULTIES.map((d) => [Progress.practiceExpertId(d), () => (tap('[data-mode="practice"]'), tap('#practice-level [data-level="expert"]'))])), - ...Object.fromEntries( - Progress.DIFFICULTIES.flatMap((d) => [ - [Progress.gameExpertId(d), () => (tap('[data-mode="single"]'), tap('#game-level [data-level="expert"]'))], - ...[30, 10, 5].map((secs) => [Progress.gameTimerId(secs, d), () => (tap('[data-mode="single"]'), tap('#game-level [data-level="expert"]'), tap(`#game-timer [data-secs="${secs}"]`))]), - ]) - ), - ...Object.fromEntries( - Progress.DIFFICULTIES.flatMap((d) => [10, 20].map((n) => [Progress.practiceCountId(n, d), () => (tap('[data-mode="practice"]'), tap(`#practice-count [data-count="${n}"]`))])) - ), - ...Object.fromEntries( - Progress.DIFFICULTIES.flatMap((d) => - [30, 10, 5].map((secs) => [Progress.practiceTimerId(secs, d), () => (tap('[data-mode="practice"]'), tap('#practice-level [data-level="expert"]'), tap(`#practice-timer [data-secs="${secs}"]`))]) - ) - ), - ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`table${i + 1}`, () => (tap('[data-mode="practice"]'), updatePracticePreview())])), - board8: () => tap('#board-size [data-size="8"]'), - board10: () => tap('#board-size [data-size="10"]'), - board12: () => tap('#board-size [data-size="12"]'), - board16: () => tap('#board-size [data-size="16"]'), - board24: () => tap('#board-size [data-size="24"]'), - board20: () => tap('#board-size [data-size="20"]'), - boardCustom: () => tap('#board-size [data-size="custom"]'), - 'difficulty.medium': setOption('difficulty', 'medium'), - 'difficulty.hard': setOption('difficulty', 'hard'), - 'difficulty.tricky': setOption('difficulty', 'tricky'), - 'difficulty.master': setOption('difficulty', 'master'), - 'difficulty.legend': setOption('difficulty', 'legend'), - diceMode: setOption('diceMode', 'real'), - autoRoll: setOption('autoRoll', 'auto'), - fitRolls: setOption('fitRolls', 'always'), - answerTime30: setOption('answerTime', '30'), - answerTime10: setOption('answerTime', '10'), - answerTime5: setOption('answerTime', '5'), - cpuInstant: setOption('cpuSteps', 'instant'), - cpuEnd: setOption('cpuEnd', 'button'), - showProgress: setOption('showProgress', 'true'), - firstInCorner: setOption('firstInCorner', 'true'), - timesMastered: setOption('timesMastered', 'time'), - timesLearning: setOption('timesLearning', 'time'), - timesPractice: setOption('timesPractice', 'time'), - }; - - function activatePurchase(item) { - const source = shopSource; - shopSource = null; - if (item.cosmetic) chooseCosmetic(item.cosmetic[0], item.cosmetic[1]); // put it on - else if (source && source.item === item.id && source.el.isConnected) source.el.click(); // the exact thing tapped - else if (ACTIVATE[item.id]) ACTIVATE[item.id](); - applyTheme(false); // e.g. golden dice - } - el.shopDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeShop(); - }); - - // ---------------------------------------------------------------- achievements - - function renderAchievements() { - el.achievementsBody.innerHTML = ''; - const got = Progress.ACHIEVEMENTS.filter((a) => progress.achievements[a.id]).length; - el.achievementsBody.append(make('p', 'stats-summary', `${got} of ${Progress.ACHIEVEMENTS.length} earned`)); - let list = null; - let group = null; - for (const a of Progress.ACHIEVEMENTS) { - if (a.group === 'Fact speed') continue; // shown as a grid below - if (a.group !== group) { - group = a.group; - const earnedHere = Progress.ACHIEVEMENTS.filter((x) => x.group === group && progress.achievements[x.id]).length; - const total = Progress.ACHIEVEMENTS.filter((x) => x.group === group).length; - el.achievementsBody.append(make('h3', 'stats-heading', `${group} · ${earnedHere} of ${total}`)); - if (group === 'Times tables') { - el.achievementsBody.append(make('p', 'setting-help', `📗 Learner: every fact in the line is Learning or better. 🏅 Master: every fact is Mastered. Lines go up to your table size (${unlockedTableSize()} × ${unlockedTableSize()} now).`)); - } - list = make('div', 'achievement-list'); - el.achievementsBody.append(list); - } - const when = progress.achievements[a.id]; - const row = make('div', 'achievement' + (when ? ' earned' : '')); - const badge = make('span', 'achievement-icon', when ? a.icon : undefined); - if (!when) badge.append(icon('lock')); - row.append(badge); - const text = make('div', 'achievement-text'); - const secret = a.hidden && !when; - text.append(make('strong', null, secret ? '???' : a.name), make('span', null, secret ? 'A secret achievement. Keep exploring!' : a.desc)); - row.append(text); - row.append( - make('span', 'achievement-reward', when ? new Date(when).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : `+${a.reward}`) - ); - list.append(row); - } - el.achievementsBody.append(renderFactSpeed()); - } - - // Fact speed: one cell per fact with the best tier earned (10, 5 or 1 seconds). - function renderFactSpeed() { - const box = make('div', 'fact-speed'); - const size = unlockedTableSize(); - const all = Progress.ACHIEVEMENTS.filter((a) => a.group === 'Fact speed'); - const earned = all.filter((a) => progress.achievements[a.id]); - box.append(make('h3', 'stats-heading', `Fact speed · ${earned.length} of ${all.length}`)); - box.append(make('p', 'setting-help', `Master a fact and answer it fast. Each fact has three tiers: under 10, 5 and 1 seconds. Showing up to ${size} × ${size}.`)); - const counts = make('div', 'fact-legend'); - for (const t of Progress.FACT_SPEED_TIERS) { - const n = earned.filter((a) => a.tier === t.secs).length; - const item = make('span', 'fact-legend-item'); - item.append(make('span', `fact-cell speed-${t.secs}`, String(t.secs)), document.createTextNode(`Under ${t.secs} s: ${n}`)); - counts.append(item); - } - box.append(counts); - const grid = make('div', 'fact-grid'); - grid.style.setProperty('--n', size + 1); - grid.append(make('span', 'fact-head', '×')); - for (let c = 1; c <= size; c++) grid.append(make('span', 'fact-head', String(c))); - for (let r = 1; r <= size; r++) { - grid.append(make('span', 'fact-head', String(r))); - for (let c = 1; c <= size; c++) { - const best = Progress.FACT_SPEED_TIERS.slice().reverse().find((t) => progress.achievements[`fact_${r}x${c}_${t.secs}`]); - const cell = make('span', `fact-cell ${best ? `speed-${best.secs}` : 'unseen'}`, best ? String(best.secs) : ''); - cell.title = best ? `${r} × ${c}: under ${best.secs} s` : `${r} × ${c}: not earned yet`; - grid.append(cell); - } - } - box.append(grid); - return box; - } - - el.achievementsBtn.addEventListener('click', () => { - renderAchievements(); - el.achievementsDialog.hidden = false; - el.achievementsDialog.querySelector('.dialog').scrollTop = 0; - el.achievementsDone.focus(); - }); - el.achievementsDone.addEventListener('click', () => (el.achievementsDialog.hidden = true)); - el.achievementsDialog.addEventListener('click', (e) => { - if (e.target === el.achievementsDialog) el.achievementsDialog.hidden = true; - }); - el.achievementsDialog.addEventListener('keydown', (e) => { - if (e.key === 'Escape') el.achievementsDialog.hidden = true; - }); - - // ---------------------------------------------------------------- practice mode - - const PRACTICE_BASE_POINTS = 1; // per correct answer, before bonuses - // For "what changed": moving right is progress (so a new fact landing in - // "needs practice" shows as a step down, not an improvement). - const STATUS_ORDER = { practice: 0, unseen: 1, learning: 2, mastered: 3 }; - let practiceRoundSize = 5; - // 5 comes with Practice mode; 10 and 20 are unlocked for each difficulty - const countUnlock = (count) => Progress.practiceCountId(Number(count), settings.difficulty); - let practiceLevel = 'learn'; // 'expert' (shop unlock): no picture, no help button until 2 misses, ×2 bonus - const EXPERT_BONUS = 2; - const LEVEL_HELP = { - learn: 'Learn: see each fact as a rectangle and get help counting.', - expert: 'Expert: no picture and no help. First-try bonus points are doubled!', - }; - - function renderPracticeLevel() { - // Expert is unlocked for each difficulty - const expertId = Progress.practiceExpertId(settings.difficulty); - if (!Progress.isUnlocked(progress, expertId) && practiceLevel === 'expert') practiceLevel = 'learn'; - for (const btn of el.practiceLevel.querySelectorAll('button')) { - btn.classList.toggle('selected', btn.dataset.level === practiceLevel); - setLockBadge(btn, btn.dataset.level === 'expert' ? expertId : null); - } - el.practiceLevelHelp.textContent = LEVEL_HELP[practiceLevel]; - renderPracticeTimer(); - } - - // Expert practice's own timer (Off, then 30 s → 10 s → 5 s from the shop), - // unlocked separately for each difficulty - let practiceTimerSecs = 0; - const timerUnlock = (secs) => (Number(secs) ? Progress.practiceTimerId(Number(secs), settings.difficulty) : null); - function renderPracticeTimer() { - el.practiceTimerField.hidden = practiceLevel !== 'expert'; - if (!Progress.isUnlocked(progress, timerUnlock(practiceTimerSecs))) practiceTimerSecs = 0; - for (const btn of el.practiceTimer.querySelectorAll('button')) { - btn.classList.toggle('selected', Number(btn.dataset.secs) === practiceTimerSecs); - setLockBadge(btn, timerUnlock(btn.dataset.secs)); - } - // The timer bonus (Expert doubles it): all of it for an instant answer, none when time runs out - const top = (secs) => Progress.TIMER_BONUS_MAX[secs] * EXPERT_BONUS; - el.practiceTimerHelp.textContent = practiceTimerSecs - ? `⏱️ Answer fast for a timer bonus of up to +${top(practiceTimerSecs)} a question: the quicker, the more. If time runs out, it counts as missed.` - : `⏱️ A timer earns a bonus for quick answers: up to +${top(30)} a question with 30 s, +${top(10)} with 10 s, +${top(5)} with 5 s.`; - } - el.practiceTimer.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - practiceTimerSecs = Number(btn.dataset.secs); - renderPracticeTimer(); - }); - - el.practiceLevel.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - practiceLevel = btn.dataset.level; - renderPracticeLevel(); - renderPracticeCount(); // Expert doubles the finishing bonus - }); - const practiceTables = new Set(); // chosen times tables; empty = the whole table - let practice = null; - let prTimer = null; - - function playerFacts(name) { - return (progress.facts[Progress.playerKey(name)] || { facts: {} }).facts; - } - - // Master difficulty's dice: how often each fact comes up, from the player's - // record (like practice: "Needs practice" most, mastered hardly ever). - function weakFactWeight(player) { - const facts = playerFacts(player ? player.name : 'You'); - return (a, b) => { - const status = Progress.factStatus(facts[Progress.factKey(a, b)]); - let weight = Progress.PRACTICE_WEIGHTS[status]; - if ((a === 1 || b === 1) && status !== 'practice') weight /= 2; - return weight; - }; - } - - // Practice table size follows Difficulty: Easy 6, Medium 8, Hard 12 (and - // Tricky, Master and Legend, whose facts are within 12 or one teen × 2–9). - function practiceMax() { - return Math.min(Core.DIFFICULTY_SIDES[settings.difficulty] || 6, 12); - } - // The difficulty's table chips: ×1 … ×max, Tricky's hard tables, Legend's ×2 … ×9 - const practiceChips = () => Progress.practiceSet(settings.difficulty).tables.filter((n) => n <= 12); - - // Chips for the difficulty's tables; choices it doesn't have are dropped. - function renderPracticeTables() { - const chips = practiceChips(); - for (const n of [...practiceTables]) if (!chips.includes(n) || !tableUnlocked(n)) practiceTables.delete(n); - el.practiceTables.innerHTML = ''; - for (const n of chips) { - const btn = make('button', null, `×${n}`); - btn.type = 'button'; - btn.dataset.table = n; - btn.setAttribute('aria-pressed', String(practiceTables.has(n))); - btn.setAttribute('aria-label', `${n} times table`); - setLockBadge(btn, `table${n}`); - el.practiceTables.append(btn); - } - } - - el.practiceTables.addEventListener('click', (e) => { - const btn = e.target.closest('[data-table]'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - const n = Number(btn.dataset.table); - if (practiceTables.has(n)) practiceTables.delete(n); - else practiceTables.add(n); - updatePracticePreview(); - }); - - // "×3 and ×4 (11 facts): 2 need practice, 5 learning, 4 not seen yet, 0 mastered." - function tablesLabel(tables) { - const list = [...tables].sort((a, b) => a - b).map((n) => `×${n}`); - return list.length > 1 ? `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}` : list[0]; - } - - // The tables a round asks about: the ones picked, or else every unlocked one. - function practiceTableList() { - if (practiceTables.size) return [...practiceTables]; - return practiceChips().filter(tableUnlocked); - } - - function updatePracticePreview() { - if (el.practiceSetup.hidden) return; - renderPracticeLevel(); - renderPracticeCount(); - renderPracticeTables(); - const tables = practiceTableList(); - el.startBtn.disabled = !tables.length; - if (!tables.length) { - el.practicePreview.textContent = 'Unlock a times table to practice: tap ×1 to start.'; - return; - } - const facts = playerFacts(el.practiceName.value.trim() || 'You'); - const set = Progress.practiceSet(settings.difficulty); - const upTo = settings.difficulty === 'legend' ? '11–19' : `up to ${practiceMax()}`; - const counts = { practice: 0, learning: 0, unseen: 0, mastered: 0 }; - let total = 0; - for (const [a, b] of set.pairs) { - if (!(set.bSideOnly ? tables.includes(b) : tables.includes(a) || tables.includes(b))) continue; - counts[Progress.factStatus(facts[Progress.factKey(a, b)])]++; - total++; - } - const what = practiceTables.size ? `${tablesLabel(practiceTables)} ${upTo} (${total} facts)` : `All your unlocked tables (${tablesLabel(tables)}), ${upTo}`; - el.practicePreview.textContent = - `${what}: ${counts.practice} need practice, ${counts.learning} learning, ` + - `${counts.unseen} not seen yet, ${counts.mastered} mastered.`; - } - el.practiceName.addEventListener('input', updatePracticePreview); - el.practiceCount.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (!btn) return; - if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); - practiceRoundSize = Number(btn.dataset.count); - selectIn(el.practiceCount, btn); - renderPracticeCount(); - }); - - function renderPracticeCount() { - if (!Progress.isUnlocked(progress, countUnlock(practiceRoundSize))) practiceRoundSize = 5; - for (const btn of el.practiceCount.querySelectorAll('button')) { - btn.classList.toggle('selected', Number(btn.dataset.count) === practiceRoundSize); - setLockBadge(btn, countUnlock(btn.dataset.count)); - } - // The finishing bonus for the chosen length (doubled on Expert) - const expert = practiceLevel === 'expert'; - const bonus = Progress.practiceFinishBonus(practiceRoundSize, expert); - const next = practiceRoundSize === 5 ? Progress.practiceFinishBonus(10, expert) : 0; - el.practiceCountHelp.textContent = bonus - ? `🏁 Finish all ${practiceRoundSize} for +${bonus} bonus points${expert ? ' (doubled on Expert)' : ''}.` - : `🏁 Longer rounds earn a finishing bonus: +${next} for 10 questions, +${Progress.practiceFinishBonus(20, expert)} for 20.`; - } - - // Times tables are unlocked one by one (×1, then ×2, …) - const tableUnlocked = (n) => Progress.isUnlocked(progress, `table${n}`); - - function startPractice(name, count, tables = [], expert = false, timer = 0) { - stopPracticeTimer(); - cancelContinue(); - stopTimer(); - gameId++; // cancels anything still waiting from a game - game = null; - const max = practiceMax(); - const facts = playerFacts(name); - const queue = Progress.pickPracticeFacts(facts, Progress.practiceSet(settings.difficulty), count, Math.random, tables); - const before = {}; - for (const [a, b] of queue) before[Progress.factKey(a, b)] = Progress.factStatus(facts[Progress.factKey(a, b)]); - practice = { name, max, count, tables, expert, timer, startedAt: Date.now(), times: [], queue, index: 0, requeued: new Set(), before, firstTry: 0, answered: 0, streak: 0, bestStreak: 0, points: 0, earned: [], current: null }; - el.startScreen.hidden = true; - el.gameScreen.hidden = true; - el.practiceDone.hidden = true; - el.practiceScreen.hidden = false; - nextPracticeQuestion(); - } - - function nextPracticeQuestion() { - stopPracticeTimer(); - if (practice.index >= practice.queue.length) return finishPractice(); - const [a, b] = practice.queue[practice.index]; - // retry: a missed fact asked again at the end; it doesn't count towards the score - practice.current = { a, b, area: a * b, retry: practice.index >= practice.count, entry: '', attempts: 0, wrong: [], helped: false, counting: false, startedAt: performance.now(), pausedMs: 0, done: false }; - const focus = - (practice.tables.length ? ` · ${practice.tables.sort((x, y) => x - y).map((n) => `×${n}`).join(' ')}` : '') + - (practice.expert ? ' · 🧠 Expert' : ''); - el.prCounter.textContent = `Question ${practice.index + 1} of ${practice.queue.length}${focus}`; - showStreak(el.prStreak, practice.streak); - el.prBar.style.width = `${(practice.index / practice.queue.length) * 100}%`; - el.prQ.textContent = `${a} × ${b}`; - el.prAnswer.textContent = ''; - el.prAnswer.className = 'answer-box'; - el.prTrail.textContent = ''; - el.prFeedback.textContent = ''; - el.prFeedback.className = 'math-feedback'; - el.prHelp.disabled = false; - el.prHelp.hidden = practice.expert; // Expert: no help button (the picture still helps after 2 misses) - el.prKeypad.querySelectorAll('button').forEach((btn) => (btn.disabled = false)); - renderPracticeArray(a, b); - el.prArray.hidden = practice.expert; // Expert: no picture to count - startPracticeTimer(); - } - - // The fact as a rectangle: a rows of b squares, with a slot for each row's running total. - function renderPracticeArray(a, b) { - el.prArray.innerHTML = ''; - el.prArray.classList.remove('counting'); - el.prArray.style.setProperty('--cols', b); - for (let r = 0; r < a; r++) { - for (let c = 0; c < b; c++) { - const cell = make('span', 'cell'); - cell.dataset.row = r; - el.prArray.append(cell); - } - const total = make('span', 'row-total', String((r + 1) * b)); - total.dataset.row = r; - el.prArray.append(total); - } - } - - function typePractice(key) { - const q = practice && practice.current; - if (!q || q.done || el.practiceScreen.hidden) return; - if (key === 'check') return checkPractice(); - if (key === 'clear') q.entry = ''; - else if (key === 'back') q.entry = q.entry.slice(0, -1); - else if (q.entry.length < 3) q.entry = (q.entry === '0' ? '' : q.entry) + key; - el.prAnswer.textContent = q.entry; - el.prAnswer.className = 'answer-box'; - if (answerDecided(q.entry, q.area)) checkPractice(); // right, too big, or all digits typed - } - - function practiceLocked(on) { - el.prKeypad.querySelectorAll('button').forEach((btn) => (btn.disabled = on)); - el.prHelp.disabled = on; - } - - // A missed fact comes back once, later in the round. - function requeue(q) { - const key = Progress.factKey(q.a, q.b); - if (practice.requeued.has(key)) return; - practice.requeued.add(key); - practice.queue.push([q.a, q.b]); - } - - async function checkPractice() { - const q = practice.current; - if (!q || q.done || q.entry === '') return; - if (Number(q.entry) === q.area) { - q.done = true; - const timerLeft = prTimer && practice.timer ? { secs: practice.timer, left: prTimer.remaining / prTimer.total } : null; - stopPracticeTimer(); - practiceLocked(true); - const ms = performance.now() - q.startedAt - q.pausedMs; - const firstTry = q.attempts === 0; - practice.answered++; - if (firstTry && !q.retry) practice.firstTry++; - practice.times.push(Math.round(ms)); - practice.streak = firstTry ? practice.streak + 1 : 0; - practice.bestStreak = Math.max(practice.bestStreak, practice.streak); - const bonus = Progress.answerBonus({ firstTry, ms, streak: practice.streak, timer: timerLeft }); - const multiplier = practice.expert ? EXPERT_BONUS : 1; - const points = PRACTICE_BASE_POINTS + bonus.total * multiplier; - practice.points += points; - Progress.addPoints(progress, points); - for (const part of bonus.parts) { - const extra = practice.expert ? ` (×${EXPERT_BONUS} Expert)` : ''; - toast(`${BONUS_TOASTS[part.label.split(' ')[0]]} ${capitalize(part.label)}`, `+${part.points * multiplier} bonus points${extra}`); - } - Progress.recordAnswer(progress, practice.name, { a: q.a, b: q.b, firstTry, correct: true, wrongAnswers: q.wrong, ms }); - const learned = Progress.learnedAfterHelp(progress, q.a, q.b, firstTry); - practice.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'answer', correct: true, firstTry, ms, streak: practice.streak, a: q.a, b: q.b, facts: playerFacts(practice.name), size: unlockedTableSize(), learned }))); - saveProgress(); - el.prAnswer.className = 'answer-box right'; - el.prFeedback.className = 'math-feedback good'; - el.prFeedback.textContent = firstTry ? `Correct! +${points} points 🎉` : `You got it! +${points} point${points === 1 ? '' : 's'}`; - showStreak(el.prStreak, practice.streak); - await waitInGame(1100); - practice.index++; - nextPracticeQuestion(); - return; - } - q.attempts++; - q.wrong.push(Number(q.entry)); - q.entry = ''; - practice.streak = 0; - showStreak(el.prStreak, 0); - requeue(q); - el.prAnswer.className = 'answer-box'; - void el.prAnswer.offsetWidth; // restart the shake - el.prAnswer.className = 'answer-box wrong'; - el.prAnswer.textContent = ''; - el.prFeedback.className = 'math-feedback try'; - el.prFeedback.textContent = TRY_AGAIN[(q.attempts - 1) % TRY_AGAIN.length]; - // Learn: count it out after two misses. Expert: no help at all. - if (q.attempts >= 2 && !q.helped && !practice.expert) { - el.prFeedback.textContent = "Let's count them together!"; - practiceHelp(); - } - } - - // Light up the rectangle one row at a time with running totals. - async function practiceHelp() { - const q = practice && practice.current; - if (!q || q.done || q.helped || practice.expert) return; // Expert gets no help - q.helped = true; - Progress.noteHelp(progress, q.a, q.b); - practice.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'help' }))); - saveProgress(); - q.counting = true; - el.prHelp.disabled = true; - el.prArray.hidden = false; // Expert hides it until now - const started = performance.now(); - el.prArray.classList.add('counting'); - const totals = []; - for (let r = 0; r < q.a; r++) { - await waitInGame(COUNT_STEP); - if (practice.current !== q) return; - el.prArray.querySelectorAll(`.cell[data-row="${r}"]`).forEach((c) => c.classList.add('counted')); - el.prArray.querySelector(`.row-total[data-row="${r}"]`).classList.add('shown'); - totals.push((r + 1) * q.b); - el.prTrail.textContent = `${q.a} ${q.a === 1 ? 'row' : 'rows'} of ${q.b}: ${totals.join(', ')}`; - } - q.counting = false; - q.pausedMs += performance.now() - started; - if (!q.done) { - el.prFeedback.className = 'math-feedback'; - el.prFeedback.textContent = `So what is ${q.a} × ${q.b}?`; - } - } - - // Answer timer from Settings; paused while counting or while Settings is open. - // Only Expert practice is timed; Learn never shows a clock. - // "7s", or "4:32" for a minute or more - function timerText(ms) { - const secs = Math.ceil(ms / 1000); - return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`; - } - - function startPracticeTimer() { - const secs = practice.expert ? practice.timer : 0; // Expert practice's own timer - el.prTimer.hidden = !secs; - if (!secs) return; - const q = practice.current; - const total = Progress.timerLength(progress, secs) * 1000; - prTimer = { total, remaining: total, last: performance.now() }; - const draw = () => { - const left = Math.max(0, prTimer.remaining); - el.prTimerNum.textContent = timerText(left); - el.prTimerFill.style.width = `${(left / total) * 100}%`; - el.prTimer.classList.toggle('low', left <= 5000); - }; - draw(); - prTimer.interval = setInterval(() => { - if (!practice || practice.current !== q || q.done) return stopPracticeTimer(); - const now = performance.now(); - if (!q.counting && el.settingsDialog.hidden) prTimer.remaining -= now - prTimer.last; - prTimer.last = now; - draw(); - if (prTimer.remaining <= 0) practiceTimeout(); - }, 100); - } - - function stopPracticeTimer() { - if (prTimer) clearInterval(prTimer.interval); - prTimer = null; - } - - async function practiceTimeout() { - stopPracticeTimer(); - const q = practice.current; - if (!q || q.done) return; - q.done = true; - practiceLocked(true); - practice.streak = 0; - Progress.recordAnswer(progress, practice.name, { a: q.a, b: q.b, firstTry: false, correct: false, wrongAnswers: q.wrong, timeout: true }); - saveProgress(); - requeue(q); - el.prAnswer.textContent = q.area; - el.prAnswer.className = 'answer-box timeout'; - el.prFeedback.className = 'math-feedback try'; - el.prFeedback.textContent = `⏰ Time's up! ${q.a} × ${q.b} = ${q.area}. It'll come back later.`; - await waitInGame(2200); - practice.index++; - nextPracticeQuestion(); - } - - function finishPractice() { - stopPracticeTimer(); - el.prBar.style.width = '100%'; - const pr = practice; - progress.counters.practiceRounds = (progress.counters.practiceRounds || 0) + 1; - // Longer rounds earn a finishing bonus (doubled on Expert, like the other bonuses) - const finish = Progress.practiceFinishBonus(pr.count, pr.expert); - if (finish) { - pr.points += finish; - Progress.addPoints(progress, finish); - toast(`🏁 Finished all ${pr.count} questions!`, `+${finish} bonus points${pr.expert ? ` (×${EXPERT_BONUS} Expert)` : ''}`); - } - recordPracticeRound(pr); - pr.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'practice' }))); - saveProgress(); - - const retries = pr.queue.length - pr.count; // missed facts asked again - el.pdSub.textContent = - (pr.expert ? '🧠 Expert · ' : '') + - `${pr.firstTry} of ${pr.count} right first time` + - (retries ? ` · ${retries} ${retries === 1 ? 'fact' : 'facts'} retried` : '') + - (pr.bestStreak >= 3 ? ` · best streak ${pr.bestStreak} 🔥` : ''); - - // Which facts moved, e.g. "3 × 4: Needs practice → Learning" - el.pdBody.innerHTML = ''; - const facts = playerFacts(pr.name); - const changes = Object.entries(pr.before) - .map(([fact, was]) => ({ fact, was, now: Progress.factStatus(facts[fact]) })) - .filter((c) => c.was !== c.now) - .sort((x, y) => STATUS_ORDER[y.now] - STATUS_ORDER[y.was] - (STATUS_ORDER[x.now] - STATUS_ORDER[x.was])); - el.pdBody.append(make('h3', 'stats-heading', 'What changed')); - if (!changes.length) el.pdBody.append(make('p', 'practice-none', 'No changes this time. Keep practicing and your facts will move up!')); - for (const c of changes) { - const row = make('div', 'practice-change'); - const up = STATUS_ORDER[c.now] > STATUS_ORDER[c.was]; - row.append(make('span', null, c.fact), make('span', up ? 'up' : 'down', `${FACT_STATUS[c.was].label} → ${FACT_STATUS[c.now].label}`)); - el.pdBody.append(row); - } - el.pdBody.append(make('h3', 'stats-heading centered', 'Times table')); - el.pdBody.append(renderFactLegend()); - el.pdBody.append(renderFactGrid('', facts)); - - setPracticeDetailsOpen(false); - refreshNudges('pd'); - el.practiceDone.hidden = false; - el.practiceDone.querySelector('.dialog').scrollTop = 0; - el.pdAgain.focus(); - } - - function leavePractice() { - stopPracticeTimer(); - gameId++; - practice = null; - el.practiceDone.hidden = true; - el.practiceScreen.hidden = true; - el.startScreen.hidden = false; - updatePracticePreview(); - } - - el.prKeypad.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (btn) typePractice(btn.dataset.key); - }); - el.prHelp.addEventListener('click', practiceHelp); - el.pdAgain.addEventListener('click', () => startPractice(practice.name, practice.count, practice.tables, practice.expert, practice.timer)); - el.pdMenu.addEventListener('click', leavePractice); - // "What changed" and the times table sit behind Show details, like the game results. - function setPracticeDetailsOpen(open) { - el.pdBody.hidden = !open; - el.pdDetailsBtn.replaceChildren(icon('chart-column'), open ? ' Hide details' : ' Show details'); - el.pdDetailsBtn.setAttribute('aria-expanded', String(open)); - } - el.pdDetailsBtn.addEventListener('click', () => setPracticeDetailsOpen(el.pdBody.hidden)); - el.practiceMenuBtn.addEventListener('click', () => { - if (practice && practice.index < practice.queue.length && !confirm('Stop practicing and go back to the menu?')) return; - leavePractice(); - }); - el.practiceSettingsBtn.addEventListener('click', () => openSettings(el.practiceSettingsBtn)); - document.addEventListener('keydown', (e) => { - if (!practice || el.practiceScreen.hidden || !el.practiceDone.hidden) return; - if (!el.settingsDialog.hidden || !el.shopDialog.hidden) return; - if (e.target.closest('.overlay, input')) return; // e.g. Esc that just closed a dialog - if (/^[0-9]$/.test(e.key)) typePractice(e.key); - else if (e.key === 'Backspace') typePractice('back'); - else if (e.key === 'Escape') typePractice('clear'); - else if (e.key === 'Enter') typePractice('check'); - else return; - e.preventDefault(); - }); - - // ---------------------------------------------------------------- invites - - // Settings an invite can set from the "Make an invite" form (select id = invite-<key>). - const INVITE_FORM_SETTINGS = ['difficulty', 'placeMode', 'answerTime', 'diceMode', 'autoRoll', 'fitRolls', 'cpuSteps']; - const INVITE_LABELS = { - difficulty: { - easy: 'Easy (6-sided dice)', - medium: 'Medium (8-sided dice)', - hard: 'Hard (12-sided dice)', - tricky: 'Tricky (no easy facts)', - master: 'Master (your weakest facts)', - legend: 'Legend (teens × 2–9)', - }, - placeMode: { draw: 'You draw each rectangle', click: 'Click to place rectangles', auto: 'Rectangles are placed for you' }, - diceMode: { virtual: null, real: 'Roll real dice and tap what you got' }, - cpuSteps: { show: null, instant: 'CPU plays instantly' }, - autoRoll: { manual: null, auto: 'Dice roll by themselves' }, - fitRolls: { end: null, always: 'Every roll fits on the board', never: 'Original rules: a roll may not fit, and you pass' }, - }; - - // Players from the main-menu setup, in invite form. - function invitePlayersFromSetup() { - if (setup.mode === 'single') return [{ n: el.singleName.value.trim(), c: false }]; - saveNameInputs(); - return setup.names.slice(0, setup.count).map((name, i) => ({ n: setup.types[i] === 'cpu' ? '' : name.trim(), c: setup.types[i] === 'cpu' })); - } - - function invitePlayerNames(inv) { - let cpu = 0; - return inv.p.map((p, i) => (p.c ? `CPU ${++cpu}` : p.n || DEFAULT_NAMES[i])); - } - - function describeInvite(inv) { - const s = { ...DEFAULT_SETTINGS, ...inv.s }; - const lines = [ - inv.m === 'single' ? 'You vs the CPU' : `Players: ${invitePlayerNames(inv).join(', ')}`, - `Board: ${inv.b}×${inv.b}`, - INVITE_LABELS.difficulty[s.difficulty], - INVITE_LABELS.placeMode[s.placeMode], - ]; - if (s.answerTime !== '0') lines.push(`${s.answerTime} seconds to answer each question`); - for (const key of ['diceMode', 'autoRoll', 'fitRolls', 'cpuSteps']) if (INVITE_LABELS[key][s[key]]) lines.push(INVITE_LABELS[key][s[key]]); - if (inv.r !== undefined) lines.push('Same dice rolls as everyone else with this invite'); - return lines; - } - - function openInviteMaker() { - const names = invitePlayersFromSetup(); - el.invitePlayers.textContent = - setup.mode === 'single' - ? `Single player: ${names[0].n || 'You'} vs the CPU` - : `Multiplayer: ${invitePlayerNames({ p: names }).join(', ')}`; - el.inviteCodeError.hidden = true; - el.inviteResult.hidden = true; - el.inviteMake.hidden = false; - el.inviteMake.querySelector('.dialog').scrollTop = 0; - el.inviteCodeInput.focus(); - } - - function closeInviteMaker() { - el.inviteMake.hidden = true; - } - - function buildInvite() { - const board = el.inviteBoard.value === 'custom' ? clampCustom(el.inviteCustom.value) : Number(el.inviteBoard.value); - const s = {}; - for (const key of INVITE_FORM_SETTINGS) { - const value = $(`invite-${key}`).value; - if (value !== String(DEFAULT_SETTINGS[key])) s[key] = value; // only what differs, to keep links short - } - const inv = { v: Invite.VERSION, m: setup.mode, b: board, p: invitePlayersFromSetup(), s }; - if (el.inviteNote.value.trim()) inv.t = el.inviteNote.value.trim(); - if (el.inviteSeed.checked) inv.r = Invite.newSeed(); - return Invite.validate(inv); - } - - let madeInvite = null; - - el.inviteCreate.addEventListener('click', () => { - if (setup.mode === 'multi' && invitePlayersFromSetup().some((p) => !p.c && !p.n)) { - toast('✏️ Players need names', 'Type every player’s name on the main menu first.'); - return; - } - madeInvite = buildInvite(); - if (!madeInvite) return; - const token = Invite.encode(madeInvite); - el.inviteCode.value = token; - el.inviteLink.value = `${location.href.split('#')[0]}#invite=${token}`; - const local = location.protocol === 'file:'; - el.inviteLocalWarning.hidden = !local; - el.inviteQrWrap.hidden = local; // a file:// link can't be opened from another device - if (!local) drawQr(el.inviteQr, el.inviteLink.value); - el.inviteResult.hidden = false; - el.inviteResult.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); - }); - - // Black-on-white QR code, 8px per module with the standard 4-module quiet zone. - function drawQr(canvas, text) { - const qr = window.qrcode(0, 'M'); - qr.addData(text); - qr.make(); - const modules = qr.getModuleCount(); - const cell = 8; - const quiet = 4; - const size = (modules + quiet * 2) * cell; - canvas.width = canvas.height = size; - const g = canvas.getContext('2d'); - g.fillStyle = '#ffffff'; - g.fillRect(0, 0, size, size); - g.fillStyle = '#000000'; - for (let r = 0; r < modules; r++) { - for (let c = 0; c < modules; c++) { - if (qr.isDark(r, c)) g.fillRect((c + quiet) * cell, (r + quiet) * cell, cell, cell); - } - } - } - - el.inviteQrSave.addEventListener('click', () => { - const a = document.createElement('a'); - a.href = el.inviteQr.toDataURL('image/png'); - a.download = 'blockout-invite.png'; - a.click(); - }); - - // A printable hand-out: QR code, the note and what the game will be. - el.inviteQrPrint.addEventListener('click', () => { - if (!madeInvite) return; - const win = window.open('', '_blank'); - if (!win) return toast('⚠️ Could not open the print page', 'Allow pop-ups for this page, or use Save QR image.'); - const doc = win.document; - doc.title = 'Blockout invite'; - const style = doc.createElement('style'); - style.textContent = - 'body{font-family:system-ui,sans-serif;text-align:center;padding:32px;color:#2b2a33}' + - 'img{width:320px;height:320px;image-rendering:pixelated}' + - '.note{font-size:1.4rem;font-weight:800;margin:16px auto;max-width:480px}' + - 'ul{display:inline-block;text-align:left;line-height:1.6}'; - doc.head.append(style); - const add = (tag, text, cls) => { - const node = doc.createElement(tag); - if (text) node.textContent = text; - if (cls) node.className = cls; - doc.body.append(node); - return node; - }; - add('h1', 'Blockout'); - if (madeInvite.t) add('p', `“${madeInvite.t}”`, 'note'); - const img = add('img'); - img.src = el.inviteQr.toDataURL('image/png'); - img.alt = 'QR code for the Blockout invite'; - add('p', 'Scan with a phone or tablet camera to start the game.'); - const list = add('ul'); - for (const line of describeInvite(madeInvite)) { - const li = doc.createElement('li'); - li.textContent = line; - list.append(li); - } - img.onload = () => win.print(); - }); - - el.inviteBoard.addEventListener('change', () => { - el.inviteCustomField.hidden = el.inviteBoard.value !== 'custom'; - }); - - el.inviteMake.addEventListener('click', async (e) => { - if (e.target === el.inviteMake) return closeInviteMaker(); - const copy = e.target.closest('[data-copy]'); - if (!copy) return; - const field = $(copy.dataset.copy); - try { - await navigator.clipboard.writeText(field.value); - } catch (_) { - field.select(); - document.execCommand('copy'); // older browsers / file:// pages - } - copy.textContent = 'Copied!'; - setTimeout(() => (copy.textContent = 'Copy'), 1500); - }); - - el.inviteCodeOpen.addEventListener('click', () => { - const token = Invite.tokenFrom(el.inviteCodeInput.value); - const inv = token && Invite.decode(token); - el.inviteCodeError.hidden = Boolean(inv); - if (!inv) return; - closeInviteMaker(); - showInvite(inv); - }); - el.inviteCodeInput.addEventListener('keydown', (e) => { - if (e.key === 'Enter') el.inviteCodeOpen.click(); - }); - el.inviteTry.addEventListener('click', () => { - if (!madeInvite) return; - closeInviteMaker(); - showInvite(madeInvite); - }); - el.inviteBtn.addEventListener('click', openInviteMaker); - el.inviteDone.addEventListener('click', closeInviteMaker); - el.inviteMake.addEventListener('keydown', (e) => { - if (e.key === 'Escape') closeInviteMaker(); - }); - - // ---- a child opens an invite - - let openedInvite = null; - - function showInvite(inv) { - openedInvite = inv; - el.inviteOpenNote.hidden = !inv.t; - el.inviteOpenNote.textContent = inv.t ? `“${inv.t}”` : ''; - el.inviteOpenSummary.innerHTML = ''; - for (const line of describeInvite(inv)) el.inviteOpenSummary.append(make('li', null, line)); - el.inviteOpenNameField.hidden = inv.m !== 'single'; - el.inviteOpenName.value = inv.m === 'single' ? inv.p[0].n || el.singleName.value.trim() || '' : ''; - for (const d of document.querySelectorAll('.overlay')) if (d !== el.inviteOpen) d.hidden = true; - el.inviteOpen.hidden = false; - el.inviteStart.focus(); - } - - function clearInviteFromUrl() { - if (location.hash.includes('invite=')) history.replaceState(null, '', location.pathname + location.search); - } - - el.inviteStart.addEventListener('click', () => { - const inv = openedInvite; - if (!inv) return; - el.inviteOpen.hidden = true; - clearInviteFromUrl(); - const names = invitePlayerNames(inv); - const players = - inv.m === 'single' - ? [ - { name: el.inviteOpenName.value.trim() || 'You', cpu: false }, - { name: 'CPU', cpu: true }, - ] - : inv.p.map((p, i) => ({ name: names[i], cpu: p.c })); - startGame(players, inv.b, inv.m, inv); - }); - el.inviteCancel.addEventListener('click', () => { - el.inviteOpen.hidden = true; - clearInviteFromUrl(); - }); - - // Links look like index.html#invite=CODE - function checkUrlForInvite() { - const token = Invite.tokenFrom(location.hash); - if (!token) return; - const inv = Invite.decode(token); - if (inv) showInvite(inv); - else { - clearInviteFromUrl(); - toast('⚠️ That invite link is broken', 'Ask for a new one, or paste the code in 🔗 Invite.'); - } - } - window.addEventListener('hashchange', checkUrlForInvite); - - - // ---------------------------------------------------------------- classroom - // Everyone plays the same rolls on their own board; the teacher's screen - // (server/classroom.js) rolls, shows the leaderboard and ends the game. - - const Classroom = window.BlockoutClassroom; - const DIFFICULTY_SIDES_TEXT = { easy: '6-sided dice', medium: '8-sided dice', hard: '12-sided dice' }; - - function ordinal(n) { - const tens = n % 100; - const suffix = tens >= 11 && tens <= 13 ? 'th' : { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th'; - return `${n}${suffix}`; - } - - // Can this page reach the classroom server? Shown in the Classroom menu. - async function checkClassroom() { - const reason = await Classroom.check(); - el.classOffline.hidden = !reason; - el.classJoinOpen.disabled = el.classHostOpen.disabled = Boolean(reason); - if (reason) { - el.classOffline.innerHTML = - reason === 'file' - ? 'Classroom games run through the Blockout server. On the teacher’s computer run <code>node server/classroom.js</code>, then open the address it shows (like <code>http://localhost:8080</code>).' - : 'Can’t reach the classroom server. Run <code>node server/classroom.js</code> and open this page from the address it prints (like <code>http://localhost:8080</code>), not from another web server.'; - } - return !reason; - } - - // ---- students - - let classSession = null; // { code, id, key, name } - let classStop = null; // closes the live updates - let classSeen = false; // got at least one update since connecting - - // The first name used in the last class on this device, to fill in next time - const CLASS_NAME_KEY = 'blockout.className'; - function lastClassName() { - try { - return localStorage.getItem(CLASS_NAME_KEY) || ''; - } catch (e) { - return ''; - } - } - - // fromLink: opened from a join link (QR code or a link posted in the class chat) - async function openClassJoin(code = '', fromLink = false) { - el.classCodeInput.value = code || ''; - el.classNameInput.value = lastClassName(); - el.classJoinError.hidden = true; - el.classJoinForm.hidden = false; - el.classWait.hidden = true; - el.classJoin.hidden = false; - // With the code and your name filled in, joining is one tap - (!code ? el.classCodeInput : el.classNameInput.value ? el.classJoinBtn : el.classNameInput).focus(); - if (fromLink && code) { - try { - await Classroom.roomInfo(code); - } catch (err) { - if (err.code !== 'room') return; - el.classJoinError.textContent = 'This class has finished. Ask your teacher for a new link.'; - el.classJoinError.hidden = false; - el.classCodeInput.value = ''; - el.classCodeInput.focus(); - } - } - } - - el.classJoinOpen.addEventListener('click', () => openClassJoin()); - el.classJoinCancel.addEventListener('click', () => (el.classJoin.hidden = true)); - el.classCodeInput.addEventListener('input', () => { - el.classCodeInput.value = el.classCodeInput.value.toUpperCase().replace(/[^A-Z]/g, ''); - }); - - el.classJoinForm.addEventListener('submit', async (e) => { - e.preventDefault(); - const code = el.classCodeInput.value.trim().toUpperCase(); - const name = el.classNameInput.value.trim(); - const fail = (msg, input) => { - el.classJoinError.textContent = msg; - el.classJoinError.hidden = false; - if (input) input.focus(); - }; - if (code.length !== 4) return fail('The class code has 4 letters.', el.classCodeInput); - if (!name) return fail('Type your first name.', el.classNameInput); - el.classJoinBtn.disabled = true; - try { - const joined = await Classroom.join(code, name); - try { - localStorage.setItem(CLASS_NAME_KEY, joined.name); - } catch (e) {} - connectClass({ code, id: joined.id, key: joined.key, name: joined.name }); - showClassWaiting(null); - } catch (err) { - fail(err.message, err.code === 'room' ? el.classCodeInput : el.classNameInput); - } finally { - el.classJoinBtn.disabled = false; - } - }); - - function connectClass(session) { - if (classStop) classStop(); - classSession = session; - classSeen = false; - Classroom.saveSession(session); - classStop = Classroom.listenStudent(session, { - view: (v) => { - classSeen = true; - onClassView(v); - }, - gone: onClassGone, - }); - } - - function leaveClass() { - if (!classSession) return; - Classroom.leave(classSession).catch(() => {}); - if (classStop) classStop(); - classStop = null; - classSession = null; - Classroom.saveSession(null); - } - - el.classLeaveBtn.addEventListener('click', () => { - leaveClass(); - el.classJoin.hidden = true; - }); - - function onClassGone(reason) { - const wasIn = classSeen; - const wasHost = classSession && classSession.host; - classStop = null; - classSession = null; - Classroom.saveSession(null); - el.classJoin.hidden = true; - if (wasHost) { - // the teacher's own player on the projector page: the host screen handles the rest - if (game && game.mode === 'pair') game = null; - return; - } - if (game && (game.mode === 'class' || game.mode === 'pair') && game.phase !== 'over') toMenu(); - if (!wasIn) return; // an old class from last time: just forget it - if (reason === 'closed') toast('👋 Your teacher closed the class', 'Thanks for playing!'); - else if (reason === 'removed') toast('You’ve left the class', 'Join again with the code if that was a mistake.'); - else toast('⚠️ Lost the class', 'Join again with the code on the board.'); - } - - function showClassWaiting(view) { - el.classWaitName.textContent = classSession.name; - const t = view && view.tournament; - el.classWaitText.textContent = t - ? tournamentLine(t) - : classSession.teacher - ? 'You’re the teacher’s player. When there’s an odd number of students, you’ll play the odd one out here.' - : view && view.state === 'playing' - ? 'That game started without you. You’ll play in the next one!' - : view && view.state === 'ended' - ? 'That game just finished. Wait here for the next one!' - : 'Waiting for your teacher to start the game…'; - el.classJoinForm.hidden = true; - el.classWait.hidden = false; - el.classJoin.hidden = false; - } - - function onClassView(view) { - if (view.type === 'pairs' || view.type === 'tournament') return onPairView(view); - if (view.you.benched) return showClassWaiting(view); // joined after this game started - const mine = game && game.mode === 'class' && game.classroom.game === view.game; - if (view.state === 'playing') { - if (!mine) startClassGame(view); - else syncClassGame(view); - return; - } - if (view.state === 'ended' && mine) { - if (game.phase !== 'over') finishClassGame(view); - return; - } - // Lobby (or a game that finished before we got here): wait for the next one. - // Results stay on screen until the next game starts. - if (game && game.mode === 'class' && game.phase === 'over') return; - if (game && game.mode === 'class') toMenu(); - showClassWaiting(view); - } - - // The teacher's settings for everyone in the class (no shop locks); placing is - // the student's own choice unless the teacher picked one. - function classCfg(s) { - return { - answerTime: s.answerTime || 0, - placeMode: s.placeMode && s.placeMode !== 'choice' ? s.placeMode : settings.placeMode, - firstInCorner: false, - diceMode: 'virtual', - }; - } - - function startClassGame(view) { - el.classJoin.hidden = true; - for (const d of document.querySelectorAll('.overlay')) if (d !== el.gameOver) d.hidden = true; - const you = view.you; - const { size, difficulty } = view.settings; - startGame([{ name: you.name, statsName: el.singleName.value.trim() || 'You', cpu: false }], size, 'class', null, { - cfg: { ...classCfg(view.settings), difficulty, autoRoll: 'off', fitRolls: 'never' }, // the teacher rolls - classroom: { session: classSession, game: view.game, round: 0, reported: true, view }, - }); - // Rejoining mid-game: put your rectangles back and pick up your score. - const p = game.players[0]; - for (const r of you.rects) { - const rect = Core.place(game.board, r.x, r.y, r.w, r.h, 0); - Object.assign(rect, { a: r.a, b: r.b, solved: true }); - } - Object.assign(p, { squares: you.squares, bonus: you.bonus, score: you.score, streak: you.streak, answered: you.answered, firstTry: you.firstTry }); - if (you.rects.length) toast(`🏫 Back in class ${view.code}`, `You have ${you.score} points.`); - syncClassGame(view); - } - - // A new roll from the teacher. If you were still working on the last one, it's gone. - function syncClassGame(view) { - const c = game.classroom; - c.view = view; - if (view.round > c.round) { - if (!['wait', 'over'].includes(game.phase)) { - gameId++; // stop whatever the last roll was still doing - if (!c.reported) { - if (game.pending) Core.removeRect(game.board, game.pending.rect); - const [a, b] = game.dice || []; - if (a) currentPlayer().log.push({ timeout: true, a, b }); - currentPlayer().streak = 0; - toast('⏭️ Next roll!', 'Your teacher moved on before you finished that one.'); - } - } - resetTurn(); - c.round = view.round; - const [a, b] = view.roll; - if (view.you.done) { - // No room for this roll (the server checks), or you'd already answered it - c.reported = true; - game.dice = [a, b]; - renderDie(el.dieA, a); - renderDie(el.dieB, b); - game.phase = 'wait'; - if (view.you.result === 'pass') { - currentPlayer().log.push({ pass: true, a, b }); - setMsg(`No room for a ${a} × ${b} anywhere. You pass this time.`); - } else setMsg('Waiting for the next roll…'); - } else { - c.reported = false; - classTurn(a, b); - } - } - render(); - } - - async function classTurn(a, b) { - setMsg(''); - await rollDice([a, b]); - await startPlacing(a, b); - } - - // Tell the server how this roll went (once per roll). - function reportClass(result) { - const c = game.classroom; - if (!c || c.reported) return; - c.reported = true; - c.doneTurn = c.round; - Classroom.sendResult(c.session, { round: c.round, ...result }).catch((err) => { - if (err.code !== 'stale' && err.code !== 'done') toast('⚠️ Couldn’t send your answer', err.message); - }); - } - - - // ---- pairs: head to head on a shared board, taking turns - - function onPairView(view) { - const host = classSession.host; // the teacher playing from the projector screen - // (a tournament plays many games: each has its own match id) - const sameGame = game && game.mode === 'pair' && game.classroom.game === view.game; - const mine = sameGame && (!view.match || game.classroom.matchId === view.match.id); - if (view.type === 'tournament') tournamentUpdate(view, sameGame); - if (view.match && view.state !== 'lobby') { - if (!mine) { - if (view.match.over) return host ? updateMyGameButton() : showClassWaiting(view); // joined after it finished - startPairGame(view); - } else syncPairGame(view); - return; - } - if (sameGame && game.phase === 'over') return; // results stay up until the next game starts - if (game && game.mode === 'pair') { - if (host) game = null; - else toMenu(); - } - if (host) return updateMyGameButton(); - showClassWaiting(view); - } - - function startPairGame(view) { - const m = view.match; - const me = m.players.findIndex((p) => p.id === view.you.id); - if (!classSession.host) { - el.classJoin.hidden = true; - for (const d of document.querySelectorAll('.overlay')) if (d !== el.gameOver) d.hidden = true; - } - const defs = m.players.map((p, i) => ({ - name: p.name, - cpu: p.isCpu, - ...(i === me ? { statsName: el.singleName.value.trim() || 'You' } : {}), - })); - const { size, difficulty } = view.settings; - startGame(defs, size, 'pair', null, { - cfg: { ...classCfg(view.settings), difficulty, autoRoll: view.settings.autoRoll ? 'auto' : 'off' }, - classroom: { session: classSession, game: view.game, matchId: m.id, me, round: 0, reported: true, doneTurn: 0, synced: 0, passSeen: 0, view }, - }); - if (classSession.host && !hostOnGame) showHostView(); // the projector keeps showing the class - syncPairGame(view); - } - - const opponent = () => game.players[1 - game.classroom.me]; - - // Bring the shared board and the turn up to date with the server. - function syncPairGame(view) { - if (game.phase === 'over') return; // finished (and rewarded) once already - const c = game.classroom; - const m = view.match; - c.view = view; - m.players.forEach((sp, i) => Object.assign(game.players[i], { score: sp.score, squares: sp.squares, bonus: sp.bonus })); - let note = ''; - // New rectangles (yours are already on the board) - for (const r of m.rects.slice(c.synced)) { - if (game.board.rects.some((x) => x.x === r.x && x.y === r.y && x.w === r.w && x.h === r.h)) continue; - if (game.pending) { - // Can't happen in turn order, but never draw over your own rectangle - Core.removeRect(game.board, game.pending.rect); - game.pending = null; - } - const rect = Core.place(game.board, r.x, r.y, r.w, r.h, r.owner); - Object.assign(rect, { a: r.a, b: r.b, solved: true }); - game.lastRect = rect; - game.players[r.owner].log.push({ a: r.a, b: r.b, area: rect.area }); - if (r.owner !== c.me) note = `${game.players[r.owner].name} drew ${r.a} × ${r.b} = ${rect.area}.`; - } - c.synced = m.rects.length; - // Passes - if (m.lastPass && m.lastPass.turn > c.passSeen) { - c.passSeen = m.lastPass.turn; - const who = m.players.findIndex((p) => p.id === m.lastPass.by); - const [a, b] = m.lastPass.roll; - game.players[who].log.push({ pass: true, a, b }); - note = who === c.me ? `No room for a ${a} × ${b} anywhere. You pass.` : `${game.players[who].name} had no room for a ${a} × ${b}, so they pass.`; - } - game.current = m.current; - if (m.over) return finishPairGame(view); - const myTurn = m.current === c.me; - const newTurn = m.turn !== c.round; - if (newTurn) c.round = m.turn; - if (newTurn && myTurn && ['wait', 'roll'].includes(game.phase)) startPairTurn(note); - else if (!myTurn && game.phase === 'wait') { - if (m.roll) { - renderDie(el.dieA, m.roll[0]); - renderDie(el.dieB, m.roll[1]); - setMsg(`${opponent().name} rolled ${m.roll[0]} and ${m.roll[1]}…`); - } else if (newTurn || note) { - renderDie(el.dieA, null); - renderDie(el.dieB, null); - setMsg(`${note} ${opponent().name}'s turn…`.trim()); - } - } - render(); - updateMyGameButton(); - } - - function startPairTurn(note = '') { - resetTurn(); - game.classroom.reported = false; - game.phase = 'roll'; - setMsg(`${note} ${game.autoRoll ? 'Your turn! Rolling…' : 'Your turn: roll the dice!'}`.trim()); - render(); - if (classSession.host && !hostOnGame) toast('🎲 Your turn', `in your game with ${opponent().name}`); - if (game.autoRoll) waitInGame(AUTO_ROLL_MS).then(humanRoll); - } - - // After your turn (or at the start): your turn again, or wait for your partner. - function pairTurnOrWait() { - const c = game.classroom; - const m = c.view && c.view.match; - if (m && !m.over && m.current === c.me && m.turn === c.round && m.turn !== c.doneTurn) return startPairTurn(); - game.phase = 'wait'; - setMsg(m && m.current !== c.me ? `${opponent().name}'s turn…` : 'Waiting…'); - render(); - } - - async function pairRoll() { - const c = game.classroom; - game.phase = 'rolling'; - render(); - let res; - try { - res = await Classroom.roll(c.session); - } catch (err) { - if (game && game.classroom === c) { - game.phase = 'roll'; - render(); - } - if (err.code !== 'turn' && err.code !== 'stale') toast('⚠️ Couldn’t roll', err.message); - return; - } - if (!game || game.classroom !== c) return; - const [a, b] = await rollDice(res.roll); - if (res.passed) { - c.reported = true; - c.doneTurn = c.round; - game.phase = 'wait'; - setMsg(`No room for a ${a} × ${b} anywhere. You pass.`); - render(); - return; - } - await startPlacing(a, b); - } - - function finishPairGame(view) { - gameId++; - stopTimer(); - const c = game.classroom; - const m = view.match; - game.phase = 'over'; - game.dice = null; - game.pending = null; - el.math.hidden = true; - setMsg(''); - render(); - updateMyGameButton(); - const me = m.players[c.me]; - const opp = m.players[1 - c.me]; - const oppName = opp.isTeacher ? 'your teacher' : opp.name; - const Opp = oppName[0].toUpperCase() + oppName.slice(1); - el.resultTitle.textContent = - m.left === opp.id - ? `${Opp} left, so you win!` - : m.winner === me.id - ? `You beat ${oppName}! 🎉` - : m.winner === null - ? 'It’s a tie!' - : `${Opp} wins this time`; - const empty = Core.emptyCount(game.board); - el.resultSub.textContent = `${me.score} – ${opp.score} · ${empty === 0 ? 'the board is full' : `${empty} squares left empty`}`; - el.resultList.innerHTML = ''; - const top = Math.max(1, me.score, opp.score); - for (const [i, p] of [...m.players.entries()].sort((x, y) => y[1].score - x[1].score)) { - const li = make('li', i === c.me ? 'result-you' : ''); - li.style.setProperty('--c', game.players[i].color); - const bar = make('span', 'result-bar'); - const fill = make('span'); - fill.style.width = `${(p.score / top) * 100}%`; - bar.append(fill); - li.append(make('span', 'result-name', p.name), make('span', 'result-pts', `${p.score} pts`), bar); - el.resultList.append(li); - } - el.rewards.innerHTML = ''; - if (c.session.teacher) el.rewards.append(make('p', 'setting-help', 'Thanks for playing! Teacher games don’t count towards stats.')); - else if (view.type === 'tournament') { - // each tournament game earns its points; the tournament's own rewards come at the end - Progress.addPoints(progress, me.score); - saveProgress(); - showRewards([]); - } else renderPairRewards(view); - if (m.tiebreak) el.resultSub.textContent += m.tiebreak === 'coin' ? ' · a tie, settled by a coin flip' : ' · a tie, won on answers right first time'; - tournamentUpdate(view, true); - if (m.winner === me.id) celebrateWin(); - renderStats(); - setDetailsOpen(false); - if (c.session.host && !hostOnGame) { - toast('🏁 Your game is over', `${me.score} – ${opp.score} against ${opp.name}`); - return; // the projector keeps showing the class; the results wait in "My game" - } - el.gameOver.hidden = false; - el.gameOver.querySelector('.dialog').scrollTop = 0; - } - - function renderPairRewards(view) { - const c = game.classroom; - const m = view.match; - const me = m.players[c.me]; - const opp = m.players[1 - c.me]; - const won = m.winner === me.id; - Progress.addPoints(progress, me.score); - progress.counters.pairGames = (progress.counters.pairGames || 0) + 1; - if (won) progress.counters.pairWins = (progress.counters.pairWins || 0) + 1; - const p = game.players[c.me]; - const asked = p.answered + p.stats.timeouts; - const earned = Progress.awardAchievements(progress, { - event: 'pair', - won, - tie: m.winner === null, - vsTeacher: opp.isTeacher, - vsCpu: opp.isCpu, - pairGames: progress.counters.pairGames, - pairWins: progress.counters.pairWins || 0, - perfect: asked >= 5 && p.firstTry === asked, - }); - grantStickers(earned); // they show up behind the Stickers button - saveProgress(); - const history = loadHistory(); - history.push({ - kind: 'class', - type: 'pairs', - at: Date.now(), - duration: Date.now() - game.startedAt, - code: view.code, - size: view.settings.size, - difficulty: view.settings.difficulty, - name: me.name, - statsName: statsName(p), - opponent: opp.isTeacher ? 'Teacher' : opp.name, - result: m.winner === null ? 'tie' : won ? 'win' : 'loss', - score: me.score, - opponentScore: opp.score, - answered: p.answered, - firstTry: p.firstTry, - timeouts: p.stats.timeouts, - times: p.stats.times.map(Math.round), - missed: p.stats.missed, - }); - saveHistory(history); - showRewards(earned); - el.rewards.append(make('p', 'setting-help', 'Stay on this screen: your teacher can start another game.')); - } - - // ---- tournaments (students): where you stand between games, and the rewards at the end - - function tournamentLine(t) { - const record = `${t.wins}–${t.losses}`; - switch (t.status) { - case 'champion': - return `🏆 You won the tournament! (${record})`; - case 'finished': - return `${t.champion} won the tournament. You finished ${ordinal(t.place)} of ${t.of} (${record}).`; - case 'out': - return `You’re out of the tournament (${record}). Cheer on the others! 📣`; - case 'bye': - return 'You have a bye this round: you go straight through! 🎟️'; - case 'playing': - return 'Your game is on!'; - default: - return t.line ? `You’re number ${t.line} in line for the hill 👑 (${record})` : `Next round starting soon… (${record})`; - } - } - - let tournamentRewarded = null; // `${code}:${game}` once its rewards are given - - // Every update: keep the line under your last result up to date, and give the - // tournament's rewards once it's over. - function tournamentUpdate(view, sameGame) { - const t = view.tournament; - if (!t) return; - if (sameGame && game.phase === 'over') { - el.resultTournament.textContent = tournamentLine(t); - el.resultTournament.hidden = false; - } - if (!el.classWait.hidden && !el.classJoin.hidden) el.classWaitText.textContent = tournamentLine(t); - const key = `${view.code}:${view.game}`; - if (view.state !== 'ended' || classSession.teacher || tournamentRewarded === key) return; - tournamentRewarded = key; - progress.counters.tournaments = (progress.counters.tournaments || 0) + 1; - if (t.status === 'champion') progress.counters.tournamentWins = (progress.counters.tournamentWins || 0) + 1; - celebrate( - Progress.awardAchievements(progress, { - event: 'tournament', - place: t.place, - of: t.of, - champion: t.status === 'champion', - undefeated: t.losses === 0 && t.wins > 0, - upsets: t.upsets, - bestStreak: t.bestStreak, - tournaments: progress.counters.tournaments, - titles: progress.counters.tournamentWins || 0, - }) - ); - saveProgress(); - const history = loadHistory(); - history.push({ - kind: 'class', - type: 'tournament', - at: Date.now(), - duration: 0, - code: view.code, - size: view.settings.size, - difficulty: view.settings.difficulty, - format: view.settings.format, - name: classSession.name, - place: t.place, - of: t.of, - wins: t.wins, - losses: t.losses, - champion: t.champion, - }); - saveHistory(history); - if (t.status === 'champion') celebrateWin(); - } - - function finishClassGame(view) { - gameId++; - stopTimer(); - const you = view.you; - const p = game.players[0]; - game.classroom.view = view; - game.phase = 'over'; - game.dice = null; - game.pending = null; - el.math.hidden = true; - setMsg(''); - Object.assign(p, { score: you.score, squares: you.squares, bonus: you.bonus }); - render(); - const alone = view.of < 2; - el.resultTitle.textContent = alone - ? 'Game over!' - : you.rank === 1 - ? `${you.tied ? 'Tied for top' : 'Top'} of the class! 🏆` - : `You ${you.tied ? 'tied for' : 'came'} ${ordinal(you.rank)} of ${view.of}!`; - const asked = you.answered + you.timeouts; - el.resultSub.textContent = `${you.score} points · ${you.firstTry} of ${asked} right first time`; - el.resultList.innerHTML = ''; - const rows = [...view.top]; - if (!rows.some((r) => r.name === you.name)) rows.push({ gap: true }, { name: you.name, rank: you.rank, score: you.score }); - const top = Math.max(1, ...rows.filter((r) => !r.gap).map((r) => r.score)); - for (const r of rows) { - const li = make('li', r.gap ? 'result-gap' : r.name === you.name ? 'result-you' : ''); - if (r.gap) { - li.textContent = '⋯'; - el.resultList.append(li); - continue; - } - li.style.setProperty('--c', r.name === you.name ? p.color : 'var(--muted)'); - const bar = make('span', 'result-bar'); - const fill = make('span'); - fill.style.width = `${(r.score / top) * 100}%`; - bar.append(fill); - li.append(make('span', 'result-name', `${ordinal(r.rank)} ${r.name}`), make('span', 'result-pts', `${r.score} pts`), bar); - el.resultList.append(li); - } - renderClassRewards(view); - refreshNudges('results'); - if (you.rank === 1 && !alone) celebrateWin(); - renderStats(); - setDetailsOpen(false); - el.gameOver.hidden = false; - el.gameOver.querySelector('.dialog').scrollTop = 0; - } - - function renderClassRewards(view) { - const you = view.you; - const p = game.players[0]; - el.rewards.innerHTML = ''; - Progress.addPoints(progress, you.score); - progress.counters.classGames = (progress.counters.classGames || 0) + 1; - const asked = you.answered + you.timeouts; - const earned = Progress.awardAchievements(progress, { - event: 'class', - classGames: progress.counters.classGames, - rank: you.rank, - of: view.of, - perfect: asked >= 5 && you.firstTry === asked, - missed: you.missed, - rolls: view.round, - }); - grantStickers(earned); // they show up behind the Stickers button - saveProgress(); - recordClassGame(view, p); - showRewards(earned); - el.rewards.append(make('p', 'setting-help', 'Stay on this screen: your teacher can start another game.')); - } - - function recordClassGame(view, p) { - const you = view.you; - const history = loadHistory(); - history.push({ - kind: 'class', - at: Date.now(), - duration: Date.now() - game.startedAt, - code: view.code, - size: view.settings.size, - difficulty: view.settings.difficulty, - name: you.name, - statsName: statsName(p), - rank: you.rank, - of: view.of, - score: you.score, - answered: you.answered, - firstTry: you.firstTry, - timeouts: you.timeouts, - times: p.stats.times.map(Math.round), - missed: p.stats.missed, - }); - saveHistory(history); - } - - // ---- teacher - - let host = null; // { code, teacherKey, lan } - let hostStop = null; - let hostView = null; - - function segValue(group) { - return group.querySelector('.selected').dataset.value; - } - function setSeg(group, value) { - for (const b of group.querySelectorAll('button')) b.classList.toggle('selected', b.dataset.value === String(value)); - } - for (const group of [el.hostType, el.hostSize, el.hostDifficulty, el.hostRounds]) { - group.addEventListener('click', (e) => { - const b = e.target.closest('button'); - if (!b) return; - setSeg(group, b.dataset.value); - if (group === el.hostType) renderHostType(); - }); - } - - // Game type: the whole class on one leaderboard, or head to head in pairs. - const HOST_TYPE_HELP = { - class: 'Everyone plays the same rolls on their own board, and the whole class shares one leaderboard.', - pairs: 'Students are matched in twos and play head to head on a shared board, taking turns. With an odd number, the odd one out plays you or the CPU.', - tournament: 'Short head-to-head games in a knockout, round robin, Swiss or king-of-the-hill tournament, with the bracket on your screen. Choose the format in the lobby.', - }; - function renderHostType() { - const type = segValue(el.hostType); - for (const b of el.hostType.querySelectorAll('button')) b.setAttribute('aria-checked', String(b.dataset.value === type)); - el.hostTypeHelp.textContent = HOST_TYPE_HELP[type]; - el.hostRounds.closest('.field').hidden = type !== 'class'; // pairs and tournament games run by turns - if (type === 'tournament' && segValue(el.hostSize) === '12') setSeg(el.hostSize, 8); // many short games: a small board - } - - // Creating a class. Everything here (and more) can be changed in the lobby too. - function openHostSetup() { - renderHostType(); - el.hostError.hidden = true; - el.hostSetup.hidden = false; - el.hostCreate.focus(); - } - - el.classHostOpen.addEventListener('click', openHostSetup); - el.hostCancel.addEventListener('click', () => (el.hostSetup.hidden = true)); - el.hostCreate.addEventListener('click', async () => { - const chosen = { size: Number(segValue(el.hostSize)), difficulty: segValue(el.hostDifficulty), rounds: Number(segValue(el.hostRounds)) }; - el.hostCreate.disabled = true; - try { - const room = await Classroom.createRoom({ ...chosen, type: segValue(el.hostType) }); - connectHost({ code: room.code, teacherKey: room.teacherKey, lan: room.lan }); - el.hostSetup.hidden = true; - } catch (err) { - el.hostError.textContent = err.message; - el.hostError.hidden = false; - } finally { - el.hostCreate.disabled = false; - } - }); - - function connectHost(h) { - if (hostStop) hostStop(); - host = h; - hostView = null; - Classroom.saveHost(h); - el.startScreen.hidden = true; - el.gameScreen.hidden = true; - el.hostScreen.hidden = false; - el.hostCodeSmall.textContent = h.code; - hostStop = Classroom.listenTeacher(h, { view: renderHost, gone: hostGone }); - } - - function hostGone(reason) { - const had = Boolean(hostView); - if (classSession && classSession.host) { - if (classStop) classStop(); - classStop = null; - classSession = null; - Classroom.saveSession(null); - if (game && game.mode === 'pair') game = null; - } - hostOnGame = false; - el.gameScreen.hidden = true; - el.gameOver.hidden = true; - hostStop = null; - host = null; - hostView = null; - Classroom.saveHost(null); - el.hostScreen.hidden = true; - el.startScreen.hidden = false; - if (had && reason !== 'closed') toast('⚠️ Lost the class', 'The classroom server may have restarted.'); - } - - async function hostAction(action, extra) { - try { - await Classroom.teacher(host, action, extra); - } catch (err) { - toast('⚠️ ' + err.message); - } - } - - // The address to show on the board. localhost only works on this computer, - // so use the network address the server found instead. - function joinUrl(code) { - const local = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname); - const base = local && host.lan && host.lan.length ? host.lan[0] : `${location.origin}${location.pathname}`; - return { base, full: `${base}#class=${code}`, local: local && !(host.lan && host.lan.length) }; - } - - // ---- teacher: links to post in the class chat - - async function copyText(text) { - try { - await navigator.clipboard.writeText(text); - return true; - } catch (e) { - // Older browsers or no permission: copy from a hidden text box - const box = document.createElement('textarea'); - box.value = text; - box.setAttribute('readonly', ''); - box.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; - document.body.append(box); - box.select(); - let ok = false; - try { - ok = document.execCommand('copy'); - } catch (err) {} - box.remove(); - return ok; - } - } - - async function copyWithFeedback(btn, text) { - const label = btn.innerHTML; - const ok = await copyText(text); - btn.classList.add('copied'); - btn.textContent = ok ? '✓ Copied!' : 'Couldn’t copy'; - setTimeout(() => { - btn.classList.remove('copied'); - btn.innerHTML = label; - }, 1600); - } - - const joinMessage = (code) => `Join our Blockout class! Tap this link: ${joinUrl(code).full} (class code ${code})`; - - el.hostCopyLink.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyLink, joinUrl(hostView.code).full)); - el.hostCopyMsg.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyMsg, joinMessage(hostView.code))); - el.hostShareBtn.hidden = !navigator.share; - el.hostShareBtn.addEventListener('click', () => { - if (!hostView) return; - navigator.share({ title: 'Blockout class', text: joinMessage(hostView.code), url: joinUrl(hostView.code).full }).catch(() => {}); - }); - el.hostCopyTeacher.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyTeacher, `${joinUrl(hostView.code).base}#class=${hostView.code}&play=${hostView.playKey}`)); - - function renderHost(view) { - const first = !hostView; - hostView = view; - const s = view.settings; - el.hostLobby.hidden = view.state !== 'lobby'; - el.hostPlay.hidden = view.state !== 'playing'; - el.hostEnded.hidden = view.state !== 'ended'; - - if (view.state === 'lobby') { - const url = joinUrl(view.code); - el.hostCode.textContent = view.code; - el.hostUrl.textContent = url.base.replace(/^https?:\/\//, '').replace(/\/$/, ''); - el.hostLocalHelp.hidden = !url.local; - if (first || el.hostQr.dataset.url !== url.full) { - drawQr(el.hostQr, url.full); - el.hostQr.dataset.url = url.full; - } - const pairs = view.type === 'pairs'; - renderHostSettings(view); - el.hostPairs.hidden = !pairs; - const n = view.players.length; - el.hostStartBtn.disabled = !n; - el.hostStartBtn.textContent = n ? `Start game (${n} ${n === 1 ? 'player' : 'players'})` : 'Waiting for players to join…'; - if (view.type === 'tournament') { - el.hostStartBtn.disabled = n < 2; - el.hostStartBtn.textContent = n >= 2 ? `Start the tournament (${n} players)` : 'A tournament needs at least 2 players…'; - } - if (pairs) renderHostPairsLobby(view); - } else if (view.state === 'playing' && view.type === 'tournament') { - const t = view.tournament; - renderHostMatches(view, el.hostMatches); - renderTournament(t, el.hostTournament); - const kothPlayed = t.hills.reduce((n, h) => n + h.played, 0); - const kothTotal = t.hills.reduce((n, h) => n + h.limit, 0); - el.hostRound.textContent = t.format === 'koth' ? `King of the hill · ${kothPlayed} of ${kothTotal} games` : `${TOURNAMENT_NAMES[t.format]} · ${t.label}`; - el.hostNextBtn.replaceChildren(icon('chevron-right'), t.format === 'koth' ? ' Next games' : ' Next round'); - el.hostNextBtn.disabled = !t.roundReady; - el.hostAuto.checked = view.autoNext; - } else if (view.state === 'playing' && view.type === 'pairs') { - renderHostMatches(view, el.hostMatches); - const done = view.matches.filter((m) => m.over).length; - el.hostRound.textContent = `Pairs · ${done} of ${view.matches.length} ${view.matches.length === 1 ? 'game' : 'games'} finished`; - } else if (view.state === 'playing') { - const [a, b] = view.roll; - const sides = Core.DIFFICULTY_SIDES[s.difficulty]; - el.hostRound.textContent = `Roll ${view.round} of ${s.rounds}`; - renderDie(el.hostDieA, a, sides); - renderDie(el.hostDieB, b, sides); - el.hostRollText.textContent = `Draw a ${a} by ${b} rectangle`; - el.hostDoneFill.style.width = `${view.here ? (view.done / view.here) * 100 : 0}%`; - el.hostDoneText.textContent = `${view.done} of ${view.here} done`; - el.hostNextBtn.replaceChildren(icon(view.round >= s.rounds ? 'flag' : 'dices'), view.round >= s.rounds ? ' Finish game' : ' Next roll'); - el.hostAuto.checked = view.autoNext; - } else { - renderHostResults(view); - } - // whole class: dice and pacing; pairs: one card per pair; tournament: the bracket too - const pairsPlaying = view.type !== 'class'; - const tourney = view.type === 'tournament'; - for (const node of [el.hostDice, el.hostRollText, el.hostProgress]) node.hidden = pairsPlaying; - el.hostNextBtn.hidden = el.hostAutoLabel.hidden = view.type === 'pairs' || (tourney && view.tournament && view.tournament.format === 'koth'); - el.hostAutoLabel.lastChild.textContent = tourney ? ' Start the next round by itself' : ' Roll again by itself when everyone’s done'; - el.hostMatches.hidden = !pairsPlaying; - el.hostTournament.hidden = !tourney || view.state !== 'playing'; - el.hostPodium.hidden = view.type === 'pairs'; - el.hostMatchResults.hidden = view.type !== 'pairs'; - el.hostTournamentFinal.hidden = !tourney || view.state !== 'ended'; - renderHostRoster(view); - updateMyGameButton(); - } - - // ---- teacher: game settings in the lobby, for the whole class and free of shop locks - - // [value, label, detail]; `only` limits a setting to one game type - const HOST_SETTINGS = [ - { key: 'size', label: 'Board size', options: [6, 8, 10, 12, 16, 20, 24].map((n) => [n, `${n}×${n}`]) }, - { key: 'difficulty', label: 'Difficulty', options: [['easy', 'Easy', '6-sided dice'], ['medium', 'Medium', '8-sided dice'], ['hard', 'Hard', '12-sided dice']] }, - { - key: 'format', - label: 'Tournament format', - only: ['tournament'], - wide: true, - options: [ - ['knockout', 'Knockout', 'lose once and you’re out'], - ['double', 'Double knockout', 'out after 2 losses'], - ['roundrobin', 'Round robin', 'everyone plays everyone'], - ['swiss', 'Swiss', 'set rounds, no knockouts'], - ['koth', 'King of the hill', 'winner stays on'], - ], - }, - { key: 'swissRounds', label: 'Swiss rounds', only: ['tournament'], when: (s) => s.format === 'swiss', options: [2, 3, 4, 5].map((n) => [n, String(n)]) }, - { key: 'kothMatches', label: 'Games per hill', only: ['tournament'], when: (s) => s.format === 'koth', options: [6, 10, 15].map((n) => [n, String(n)]) }, - { key: 'matchTurns', label: 'Game length', only: ['pairs', 'tournament'], options: [[0, 'Full board'], [12, '6 turns each'], [8, '4 turns each']] }, - { key: 'rounds', label: 'Rolls in a game', only: ['class'], options: [[10, '10'], [15, '15'], [20, '20']] }, - { key: 'placeMode', label: 'Placing rectangles', options: [['choice', 'Their choice'], ['draw', 'Draw'], ['click', 'Click'], ['auto', 'Auto', 'fastest']] }, - { key: 'answerTime', label: 'Time to answer', options: [[0, 'No timer'], [30, '30 s'], [20, '20 s'], [10, '10 s']] }, - { key: 'autoRoll', label: 'Rolling', only: ['pairs', 'tournament'], options: [[false, 'Tap to roll'], [true, 'Auto roll']] }, - { key: 'fitRolls', label: 'Only rolls that fit', only: ['pairs', 'tournament'], options: [['end', 'Near the end'], ['always', 'Always'], ['never', 'Never']] }, - { key: 'cpuSpeed', label: 'CPU speed', only: ['pairs'], options: [['slow', 'Slow'], ['normal', 'Normal'], ['fast', 'Fast']] }, - ]; - // "Make it quick": everything that saves time - const QUICK_SETTINGS = { placeMode: 'auto', answerTime: 20, autoRoll: true, fitRolls: 'always', cpuSpeed: 'fast', matchTurns: 12 }; - - function renderHostSettings(view) { - const grid = el.hostSettingsGrid; - if (!grid.children.length) { - for (const def of HOST_SETTINGS) { - const field = make('div', 'field' + (def.wide ? ' wide' : '')); - field.append(make('span', null, def.label)); - const seg = make('div', 'segmented'); - seg.dataset.key = def.key; - for (const [value, label, detail] of def.options) { - const b = make('button', null, label); - b.type = 'button'; - b.dataset.value = JSON.stringify(value); - if (detail) b.append(' ', make('small', null, detail)); - seg.append(b); - } - field.append(seg); - grid.append(field); - } - } - HOST_SETTINGS.forEach((def, i) => { - grid.children[i].hidden = (def.only && !def.only.includes(view.type)) || (def.when && !def.when(view.settings)); - }); - for (const seg of grid.querySelectorAll('.segmented')) { - const current = JSON.stringify(view.settings[seg.dataset.key]); - for (const b of seg.querySelectorAll('button')) b.classList.toggle('selected', b.dataset.value === current); - } - const quick = Object.entries(QUICK_SETTINGS).every(([k, v]) => view.settings[k] === v || (k === 'cpuSpeed' && view.type !== 'pairs') || (['autoRoll', 'fitRolls', 'cpuSpeed', 'matchTurns'].includes(k) && view.type === 'class')); - el.hostQuick.disabled = quick; - } - - el.hostSettingsGrid.addEventListener('click', (e) => { - const b = e.target.closest('.segmented button'); - if (!b) return; - const key = b.closest('.segmented').dataset.key; - hostAction('settings', { settings: { [key]: JSON.parse(b.dataset.value) } }); - }); - el.hostQuick.addEventListener('click', () => hostAction('settings', { settings: QUICK_SETTINGS })); - - // ---- teacher: tournaments on the projector - - const TOURNAMENT_NAMES = { knockout: 'Knockout', double: 'Double knockout', roundrobin: 'Round robin', swiss: 'Swiss', koth: 'King of the hill' }; - - // Knockouts: the bracket, a column per round. Other formats: the standings - // table (and, for king of the hill, each hill's king and line). - function renderTournament(t, box) { - box.innerHTML = ''; - if (t.format === 'koth') { - const hills = make('div', 'koth-hills'); - for (const h of t.hills) { - const card = make('div', 'koth-hill'); - card.append(make('div', 'koth-title', t.hills.length > 1 ? `Hill ${h.id}` : 'The hill')); - card.append(make('div', 'koth-king', h.king ? `👑 ${h.king}` : '👑 …')); - if (h.streak > 1) card.append(make('div', 'koth-streak', `🔥 ${h.streak} wins in a row`)); - card.append(make('div', 'koth-queue', h.queue.length ? `Next: ${h.queue.join(', ')}` : '')); - card.append(make('div', 'koth-count', `${h.played} of ${h.limit} games`)); - hills.append(card); - } - box.append(hills); - } - if (t.format === 'knockout' || t.format === 'double') { - const cols = make('div', 'bracket'); - for (const r of t.rounds) { - const col = make('div', 'bracket-round'); - col.append(make('div', 'bracket-label', r.label)); - for (const m of r.matches) { - const game = make('div', 'bracket-match'); - for (const name of [m.a, m.b]) game.append(make('div', 'bracket-name' + (name === m.winner ? ' winner' : ' loser'), name)); - col.append(game); - } - for (const name of r.byes) col.append(make('div', 'bracket-bye', `${name}: bye`)); - cols.append(col); - } - box.append(cols); - } - if (t.format !== 'knockout') { - const table = make('table', 'standings'); - const head = table.createTHead().insertRow(); - for (const h of ['', 'Name', 'W', 'L', t.format === 'koth' ? 'Best streak' : 'Points']) head.append(make('th', null, h)); - const body = table.createTBody(); - for (const x of t.standings) { - const tr = body.insertRow(); - if (x.out) tr.className = 'out'; - tr.append(make('td', null, ordinal(x.place)), make('td', null, x.name), make('td', null, String(x.wins)), make('td', null, String(x.losses)), make('td', null, String(t.format === 'koth' ? x.bestStreak : x.points))); - } - box.append(table); - } - } - - function renderTournamentPodium(t) { - el.hostPodium.innerHTML = ''; - const medals = ['🥇', '🥈', '🥉']; - for (const x of t.standings.filter((s) => s.place <= 3).slice(0, 4)) { - const li = make('li', `podium-${x.place}`); - li.append(make('span', 'podium-medal', medals[x.place - 1]), make('span', 'podium-name', x.name), make('span', 'podium-score', `${x.wins}–${x.losses}`)); - el.hostPodium.append(li); - } - } - - function renderTournamentRoster(view) { - const t = view.tournament; - el.hostRosterTitle.textContent = t.over ? 'Final standings' : 'Standings'; - el.hostRoster.innerHTML = ''; - el.hostRoster.classList.add('ranked'); - const byId = new Map(view.players.map((p) => [p.id, p])); - const playingNow = new Set(view.matches.filter((m) => !m.over).flatMap((m) => m.players.map((p) => p.id))); - for (const x of t.standings) { - const p = byId.get(x.id); - const li = make('li', (p && !p.connected ? 'offline ' : '') + (x.out ? 'knocked-out' : '')); - li.append(make('span', 'host-rank', ordinal(x.place)), make('span', 'host-name', x.name), make('span', 'host-score', `${x.wins}–${x.losses}`)); - li.append(make('span', 'host-status', !p ? '👋' : t.over ? (x.place === 1 ? '🏆' : '') : !p.connected ? '📴' : playingNow.has(x.id) ? '🎲' : x.out ? '' : '⏳')); - el.hostRoster.append(li); - } - for (const p of view.waiting || []) { - const li = make('li', 'waiting'); - li.append(make('span', 'host-name', p.name), make('span', 'host-waiting', '⏳ next tournament')); - el.hostRoster.append(li); - } - } - - // ---- teacher: pairs - - const MATCHING_HELP = { - shuffle: 'New pairs are drawn when the game starts. Nobody gets last game’s partner if that can be helped.', - keep: 'Same pairs as last game. Anyone new is paired up at the end.', - arrange: 'Tap two names to swap them between pairs.', - }; - let swapPick = null; // arranging pairs: the first name tapped - - function renderHostPairsLobby(view) { - // Only "on this screen" plays from this page; otherwise let go of the teacher's - // player here (another device or the CPU plays instead). - if (view.pairOptions.odd !== 'screen' && classSession && classSession.host) { - if (classStop) classStop(); - classStop = null; - classSession = null; - Classroom.saveSession(null); - if (game && game.mode === 'pair') game = null; - } - setSeg(el.hostMatching, view.pairOptions.matching); - setSeg(el.hostOdd, view.pairOptions.odd); - const n = view.players.length; - const odd = view.odd; - const who = { screen: 'you, here on this screen', device: 'you, on your other device', cpu: 'the CPU' }[view.pairOptions.odd]; - el.hostPairsHelp.textContent = `${MATCHING_HELP[view.pairOptions.matching]} ${n ? `${n} ${n === 1 ? 'student' : 'students'}: ` : ''}${odd.needed ? `the odd one out plays ${who}.` : n ? 'everyone has a partner.' : ''}`; - // Pairs preview (shuffle mode draws new ones at the start) - el.hostPairList.hidden = view.pairOptions.matching === 'shuffle' || !n; - el.hostPairList.innerHTML = ''; - const arrange = view.pairOptions.matching === 'arrange'; - if (swapPick && !view.players.some((p) => p.id === swapPick)) swapPick = null; - const nameChip = (p) => { - if (!arrange) return make('span', 'pair-name', p.name); - const b = make('button', 'pair-name' + (swapPick === p.id ? ' picked' : ''), p.name); - b.type = 'button'; - b.addEventListener('click', () => { - if (!swapPick) swapPick = p.id; - else if (swapPick === p.id) swapPick = null; - else { - hostAction('swap', { a: swapPick, b: p.id }); - swapPick = null; - } - renderHostPairsLobby(hostView); - }); - return b; - }; - for (const [a, b] of view.pairs) { - const li = make('li'); - li.append(nameChip(a), make('span', 'pair-vs', 'vs')); - li.append(b ? nameChip(b) : make('span', 'pair-name other', view.pairOptions.odd === 'cpu' ? 'CPU' : 'You')); - el.hostPairList.append(li); - } - // Playing the odd one out from another device: its link and whether it has joined - const device = view.pairOptions.odd === 'device'; - el.hostTeacherDevice.hidden = !device; - if (device) { - const url = `${joinUrl(view.code).base}#class=${view.code}&play=${view.playKey}`; - el.hostTeacherUrl.textContent = url.replace(/^https?:\/\//, ''); - if (el.hostTeacherQr.dataset.url !== url) { - drawQr(el.hostTeacherQr, url); - el.hostTeacherQr.dataset.url = url; - } - const joined = view.teacher && view.teacher.connected; - el.hostTeacherStatus.textContent = joined ? '✓ Your other device has joined.' : 'Not joined yet.'; - el.hostTeacherStatus.classList.toggle('ok', Boolean(joined)); - } - if (odd.needed && !odd.ready) { - el.hostStartBtn.disabled = true; - el.hostStartBtn.textContent = 'Waiting for your other device to join…'; - } else if (n) { - const games = Math.ceil(n / 2); - el.hostStartBtn.textContent = `Start ${games} ${games === 1 ? 'game' : 'games'} (${n} ${n === 1 ? 'student' : 'students'})`; - } - } - - for (const [group, key] of [ - [el.hostMatching, 'matching'], - [el.hostOdd, 'odd'], - ]) { - group.addEventListener('click', (e) => { - const b = e.target.closest('button'); - if (b) hostAction('pairOptions', { options: { [key]: b.dataset.value } }); - }); - } - - // One card per pair: both names and scores, whose turn it is, and how it ended. - function renderHostMatches(view, list) { - list.innerHTML = ''; - for (const m of view.matches) { - const li = make('li', 'host-match' + (m.over ? ' over' : '')); - m.players.forEach((p, i) => { - const row = make('div', 'match-row' + (m.over && m.winner === p.id ? ' winner' : '')); - const turn = !m.over && m.current === i; - row.append(make('span', 'match-turn', turn ? '🎲' : m.over && m.winner === p.id ? '🏆' : ''), make('span', 'match-name', p.name), make('span', 'match-score', String(p.score))); - li.append(row); - }); - const left = m.left && m.players.find((p) => p.id === m.left); - const status = m.over ? (left ? `${left.name} left` : m.winner ? 'Finished' : 'Tie!') : `Board ${Math.round(m.filled * 100)}% full`; - li.append(make('div', 'match-status', status)); - list.append(li); - } - } - - // ---- teacher: playing the odd one out on this screen - - let hostOnGame = false; // showing the teacher's own game instead of the class - - function showHostView() { - hostOnGame = false; - el.gameScreen.hidden = true; - el.gameOver.hidden = true; - el.hostScreen.hidden = !host; - if (!host) el.startScreen.hidden = false; - updateMyGameButton(); - } - - function showMyGame() { - if (!game || game.mode !== 'pair') return; - hostOnGame = true; - el.hostScreen.hidden = true; - el.gameScreen.hidden = false; - resizeBoard(); - render(); - if (game.phase === 'over') { - el.gameOver.hidden = false; - el.gameOver.querySelector('.dialog').scrollTop = 0; - } - } - - function updateMyGameButton() { - const btn = el.hostMyGameBtn; - const mine = classSession && classSession.host && game && game.mode === 'pair'; - btn.hidden = !mine || !hostView || hostView.state === 'lobby'; - if (btn.hidden) return; - const opp = opponent(); - const yourTurn = game.phase !== 'over' && game.current === game.classroom.me; - btn.textContent = game.phase === 'over' ? `Your game with ${opp.name} is over: see the result` : yourTurn ? `🎲 Your turn! Play your game with ${opp.name}` : `Your game with ${opp.name}`; - btn.classList.toggle('your-turn', yourTurn); - } - el.hostMyGameBtn.addEventListener('click', showMyGame); - - function renderHostRoster(view) { - if (view.type === 'tournament' && view.tournament) return renderTournamentRoster(view); - const n = view.players.length; - el.hostRosterTitle.textContent = view.state === 'lobby' ? `${n} joined` : view.state === 'playing' ? 'Leaderboard' : 'Final scores'; - el.hostRoster.innerHTML = ''; - el.hostRoster.classList.toggle('ranked', view.state !== 'lobby'); - if (!n) el.hostRoster.append(make('li', 'host-empty', 'Nobody yet. Names show up here as students join.')); - for (const p of view.players) { - const li = make('li', p.connected ? '' : 'offline'); - if (view.state !== 'lobby') li.append(make('span', 'host-rank', ordinal(p.rank))); - li.append(make('span', 'host-name', p.name)); - if (view.state === 'lobby') { - const remove = make('button', 'host-remove', '×'); - remove.type = 'button'; - remove.title = `Remove ${p.name}`; - remove.setAttribute('aria-label', `Remove ${p.name}`); - remove.addEventListener('click', () => confirm(`Remove ${p.name} from the class?`) && hostAction('remove', { id: p.id })); - li.append(remove); - } else { - li.append(make('span', 'host-score', `${p.score}`)); - const match = view.type === 'pairs' && view.matches.find((m) => m.players.some((x) => x.id === p.id)); - const pairStatus = match ? (match.over ? '🏁' : match.players[match.current].id === p.id ? '🎲' : '') : ''; - const status = !p.connected ? '📴' : view.state !== 'playing' ? '' : view.type === 'pairs' ? pairStatus : p.done ? '✓' : '…'; - li.append(make('span', 'host-status', status)); - li.title = !p.connected ? 'Not connected' : view.type === 'pairs' ? '' : p.done ? 'Done with this roll' : 'Still working'; - } - el.hostRoster.append(li); - } - // Joined after the game started: they play the next one - for (const p of view.waiting || []) { - const li = make('li', 'waiting' + (p.connected ? '' : ' offline')); - li.append(make('span', 'host-name', p.name), make('span', 'host-waiting', '⏳ next game')); - li.title = 'Joined after this game started'; - el.hostRoster.append(li); - } - } - - function renderHostResults(view) { - if (view.type === 'pairs') renderHostMatches(view, el.hostMatchResults); - if (view.type === 'tournament' && view.tournament) { - renderTournament(view.tournament, el.hostTournamentFinal); - renderTournamentPodium(view.tournament); - renderHostReport(view); - return; - } - el.hostPodium.innerHTML = ''; - const medals = ['🥇', '🥈', '🥉']; - for (const p of view.players.filter((x) => x.rank <= 3).slice(0, 5)) { - const li = make('li', `podium-${p.rank}`); - li.append(make('span', 'podium-medal', medals[p.rank - 1]), make('span', 'podium-name', p.name), make('span', 'podium-score', `${p.score} pts`)); - el.hostPodium.append(li); - } - renderHostReport(view); - } - - function renderHostReport(view) { - const r = view.report; - el.hostReport.innerHTML = ''; - const tile = (value, label) => { - const t = make('div', 'tile'); - t.append(make('span', 'tile-value', value), make('span', 'tile-label', label)); - return t; - }; - el.hostReport.append( - tile(r.asked ? `${Math.round((r.firstTry / r.asked) * 100)}%` : '—', 'right first time'), - tile(r.avgMs ? `${(r.avgMs / 1000).toFixed(1)} s` : '—', 'average answer'), - tile(String(r.asked), 'facts answered') - ); - el.hostHardest.innerHTML = ''; - if (!r.hardest.length) el.hostHardest.append(make('li', 'host-empty', 'Nothing missed. What a class! 🌟')); - for (const f of r.hardest) { - const li = make('li'); - li.append(make('span', 'host-fact', f.fact), make('span', 'host-missed', `missed ${f.missed} of ${f.asked}`)); - el.hostHardest.append(li); - } - } - - el.hostStartBtn.addEventListener('click', async () => { - const v = hostView; - if (v && v.type === 'pairs' && v.odd.needed && v.odd.how === 'screen' && !(classSession && classSession.host)) { - // The odd one out plays the teacher, here: join as a player first - try { - const me = await Classroom.joinAsTeacher(host); - connectClass({ code: host.code, id: me.id, key: me.key, name: me.name, teacher: true, host: true }); - } catch (err) { - return toast('⚠️ ' + err.message); - } - } - hostAction('start'); - }); - el.hostNextBtn.addEventListener('click', () => hostAction('next')); - el.hostEndBtn.addEventListener('click', () => confirm('End the game now? Scores so far are final.') && hostAction('end')); - el.hostAuto.addEventListener('change', () => hostAction('autoNext', { on: el.hostAuto.checked })); - el.hostAgainBtn.addEventListener('click', () => hostAction('lobby')); - async function closeClass() { - if (!confirm('Close the class? Everyone goes back to their menu.')) return; - const h = host; - if (hostStop) hostStop(); - await Classroom.teacher(h, 'close').catch(() => {}); - hostGone('closed'); - } - el.hostCloseBtn.addEventListener('click', closeClass); - el.hostClose2Btn.addEventListener('click', closeClass); - // Space rolls again on the teacher's screen - document.addEventListener('keydown', (e) => { - if (el.hostScreen.hidden || !hostView || e.target.closest('input, button, .overlay')) return; - if ((e.key === ' ' || e.key === 'Enter') && hostView.state === 'playing' && hostView.type === 'class') { - e.preventDefault(); - hostAction('next'); - } - }); - - // ---- after a reload: pick up where this browser left off - - if (Classroom.served()) { - const savedHost = Classroom.host(); - const savedSession = Classroom.session(); - const link = Classroom.linkFromHash(); - if (link) { - history.replaceState(null, '', location.pathname + location.search); - tap('[data-mode="multi"]'); - tap('#multi-kind [data-kind="classroom"]'); - if (savedSession && savedSession.code === link.code) connectClass(savedSession); - else if (link.play) { - // the teacher's other device, for playing an odd one out - Classroom.joinWithPlayKey(link.code, link.play) - .then((me) => { - connectClass({ code: link.code, id: me.id, key: me.key, name: me.name, teacher: true }); - showClassWaiting(null); - }) - .catch((err) => toast('⚠️ ' + err.message)); - } else openClassJoin(link.code, true); - } else { - if (savedHost) connectHost(savedHost); - if (savedSession && (!savedHost || savedSession.host)) connectClass(savedSession); - } - } - - // Board wins started being counted when winning a board revealed the next one: - // give credit for wins already in the game history (once). - if (!progress.boardWinsFromHistory) { - for (const g of loadHistory()) { - if (!g.kind && g.vsComputer && g.players.some((p) => !p.cpu && p.result === 'win')) Progress.recordBoardWin(progress, g.size); - } - progress.boardWinsFromHistory = true; - saveProgress(); - syncBoardLocks(); - } - - // ---------------------------------------------------------------- cheat codes - // Press ~ anywhere (outside a text box) to open the cheat codes box. - - // code (lower case) -> { name, run() }. None yet. - // Codes live in Progress.CHEAT_CODES. The menu lists the ones that are on by - // what they do (never the code itself), each with a Turn off button. - const cheatsEl = { box: $('cheats'), form: $('cheats-form'), input: $('cheats-input'), msg: $('cheats-msg'), close: $('cheats-close'), active: $('cheats-active') }; - const inClassGame = () => game && (game.mode === 'class' || game.mode === 'pair'); - - // Unlocks changed: bring every menu, list and lock badge up to date. - function refreshUnlocks() { - enforceLocks(); - saveSettings(); - saveProgress(); - refreshAvailableFilters(); - if (!el.shopDialog.hidden) renderShop(); - if (!el.wardrobeDialog.hidden) renderWardrobe(); - syncSettingsUI(); - syncBoardLocks(); - syncModeLocks(); - updatePracticePreview(); - renderGameLevel(); - renderBoardHint(); - refreshOpenNudges(); - updateMenuBadges(); - } - - function renderActiveCheats() { - const codes = Progress.activeCheats(progress); - cheatsEl.active.hidden = !codes.length; - cheatsEl.active.replaceChildren(); - if (!codes.length) return; - cheatsEl.active.append(make('h3', null, 'Active')); - for (const code of codes) { - const row = make('div', 'cheat-row'); - const off = make('button', 'btn btn-small', 'Turn off'); - off.type = 'button'; - off.addEventListener('click', () => { - Progress.cheatOff(progress, code); - refreshUnlocks(); - renderActiveCheats(); - cheatsEl.msg.className = 'cheats-msg'; - cheatsEl.msg.textContent = 'Cheat turned off.'; - }); - row.append(make('span', null, `✨ ${Progress.CHEAT_CODES[code].name}`), off); - cheatsEl.active.append(row); - } - } - - function openCheats() { - cheatsEl.input.value = ''; - cheatsEl.input.classList.remove('wrong'); - cheatsEl.msg.className = 'cheats-msg'; - cheatsEl.msg.textContent = inClassGame() ? 'Cheats are switched off in class games. Nice try! 😄' : ''; - renderActiveCheats(); - cheatsEl.box.hidden = false; - cheatsEl.input.focus(); - } - const closeCheats = () => (cheatsEl.box.hidden = true); - - document.addEventListener('keydown', (e) => { - if (e.key !== '~' && e.key !== '`') return; - if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select')) return; - if ([...document.querySelectorAll('.overlay')].some((o) => !o.hidden)) return; // not on top of another dialog - e.preventDefault(); - openCheats(); - }); - cheatsEl.close.addEventListener('click', closeCheats); - cheatsEl.box.addEventListener('click', (e) => e.target === cheatsEl.box && closeCheats()); - cheatsEl.box.addEventListener('keydown', (e) => e.key === 'Escape' && closeCheats()); - cheatsEl.form.addEventListener('submit', (e) => { - e.preventDefault(); - const code = cheatsEl.input.value.trim().toLowerCase(); - if (!code || inClassGame()) return; - cheatsEl.input.classList.remove('wrong'); - cheatsEl.input.value = ''; - if (Progress.CHEAT_CODES[code]) { - const on = Progress.cheatOn(progress, code); - if (on) { - confettiFullScreen(3); - celebrate(Progress.awardAchievements(progress, { event: 'cheat', code })); // Cheater, plus this code's secret - refreshUnlocks(); - } - renderActiveCheats(); - cheatsEl.msg.className = 'cheats-msg good'; - cheatsEl.msg.textContent = on ? `✨ ${Progress.CHEAT_CODES[code].name}` : 'That one is already on.'; - return; - } - void cheatsEl.input.offsetWidth; // restart the shake - cheatsEl.input.classList.add('wrong'); - cheatsEl.msg.className = 'cheats-msg'; - cheatsEl.msg.textContent = 'Nothing happened…'; - }); - - // ---------------------------------------------------------------- daily check-in - - renderWallet(); - { - const visit = Progress.checkIn(progress); - if (visit) { - const streak = visit.streak > 1 ? ` · ${visit.streak} days in a row 🔥` : ''; - toast(`☀️ Daily check-in: +${visit.bonus} points`, `Welcome back!${streak}`); - celebrate(Progress.awardAchievements(progress, { event: 'checkin', streak: visit.streak })); - saveProgress(); - } - } - checkUrlForInvite(); - - // ---------------------------------------------------------------- input - - el.rollBtn.addEventListener('click', humanRoll); - el.useDiceBtn.addEventListener('click', useRealDice); - el.continueBtn.addEventListener('click', pressContinue); - el.detailsBtn.addEventListener('click', () => setDetailsOpen(el.stats.hidden)); - - function setDetailsOpen(open) { - el.stats.hidden = !open; - el.detailsBtn.replaceChildren(icon('chart-column'), open ? ' Hide details' : ' Show details'); - el.detailsBtn.setAttribute('aria-expanded', String(open)); - } - // One button per face; 8- and 12-sided dice get numbered faces. - function buildDicePicks(sides) { - el.diePicks.forEach((row, i) => { - row.innerHTML = ''; - for (let v = 1; v <= sides; v++) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'die mini'; - btn.dataset.value = v; - row.append(btn); - renderDie(btn, v, sides); - btn.setAttribute('aria-label', `Die ${i + 1}: ${v}`); - } - }); - } - - el.diePicks.forEach((row, i) => { - row.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (btn) pickDie(i, Number(btn.dataset.value)); - }); - }); - el.rotateBtn.addEventListener('click', rotate); - el.helpBtn.addEventListener('click', helpCount); - el.keypad.addEventListener('click', (e) => { - const btn = e.target.closest('button'); - if (btn) typeKey(btn.dataset.key); - }); - - function rotate() { - if (!game || game.phase !== 'place' || game.placeMode !== 'click' || Core.mustUseCorner(game.board)) return; - game.rotated = !game.rotated; - render(); - } - - document.addEventListener('keydown', (e) => { - if (!game || el.gameScreen.hidden || !el.gameOver.hidden) return; - if (!el.settingsDialog.hidden || !el.shopDialog.hidden) return; // a dialog is on top of the game - if (e.target.closest('.overlay, input')) return; // e.g. Esc that just closed a dialog - if (continueResolve && (e.key === 'Enter' || e.key === ' ')) { - e.preventDefault(); - pressContinue(); - } else if (game.phase === 'answer') { - if (/^[0-9]$/.test(e.key)) typeKey(e.key); - else if (e.key === 'Backspace') typeKey('back'); - else if (e.key === 'Escape') typeKey('clear'); - else if (e.key === 'Enter') typeKey('check'); - else return; - e.preventDefault(); - } else if (game.phase === 'roll' && game.diceMode === 'real' && !currentPlayer().cpu) { - if (/^[1-9]$/.test(e.key) && Number(e.key) <= game.sides) typeDie(Number(e.key)); - else if (e.key === 'Backspace') clearLastDie(); - else if (e.key === 'Enter') useRealDice(); - else return; - e.preventDefault(); - } else if ((e.key === ' ' || e.key === 'Enter') && game.phase === 'roll') { - e.preventDefault(); - humanRoll(); - } else if (e.key === 'r' || e.key === 'R') { - rotate(); - } - }); - - // Cell under the pointer; null when outside the board unless clamp is set. - function cellFromEvent(e, clamp = false) { - const rect = el.canvas.getBoundingClientRect(); - const cell = rect.width / game.size; - let c = Math.floor((e.clientX - rect.left) / cell); - let r = Math.floor((e.clientY - rect.top) / cell); - if (clamp) { - c = Math.max(0, Math.min(c, game.size - 1)); - r = Math.max(0, Math.min(r, game.size - 1)); - } else if (c < 0 || r < 0 || c >= game.size || r >= game.size) { - return null; - } - return { c, r }; - } - - // Top-left corner of the rectangle being previewed, centred on the pointer - // and clamped inside the board. - function previewOrigin(hover, w, h) { - if (Core.mustUseCorner(game.board)) return { x: 0, y: 0 }; - const clamp = (v, max) => Math.max(0, Math.min(v, max)); - return { - x: clamp(hover.c - Math.floor((w - 1) / 2), game.size - w), - y: clamp(hover.r - Math.floor((h - 1) / 2), game.size - h), - }; - } - - function humanPreview() { - if (!game || game.phase !== 'place' || game.placeMode !== 'click' || !game.hover) return null; - const [w, h] = currentDims(); - const { x, y } = previewOrigin(game.hover, w, h); - return { x, y, w, h, valid: Core.isValidPlacement(game.board, x, y, w, h) }; - } - - // ---- draw mode: drag out the rectangle corner to corner - - function dragRect() { - const { c0, r0, c1, r1 } = game.drag; - return { x: Math.min(c0, c1), y: Math.min(r0, r1), w: Math.abs(c1 - c0) + 1, h: Math.abs(r1 - r0) + 1 }; - } - - // How the rectangle being drawn compares to the roll. - function judgeDrawn({ x, y, w, h }) { - const [a, b] = game.dice; - const rightSize = (w === a && h === b) || (w === b && h === a); - const fits = Core.fits(game.board, x, y, w, h); - const corner = !Core.mustUseCorner(game.board) || (x === 0 && y === 0); - return { rightSize, fits, corner, ok: rightSize && fits && corner }; - } - - function finishDrag() { - const r = dragRect(); - game.drag = null; - const [a, b] = game.dice; - const verdict = judgeDrawn(r); - if (verdict.ok) { - startAnswer(placeRect(r.x, r.y, r.w, r.h)); - return; - } - let msg; - if (!verdict.rightSize) { - msg = `That's ${r.w} by ${r.h}, but you rolled ${a} and ${b}. Try again!`; - } else if (!verdict.fits) { - msg = 'Right size, but it bumps into another rectangle. Try a different spot!'; - } else { - msg = 'Right size! But the first rectangle has to start in the top-left corner.'; - } - setMsg(msg); - el.turnMsg.classList.add('oops'); - render(); - } - - el.canvas.addEventListener('pointerdown', (e) => { - if (!game || game.phase !== 'place' || game.placeMode !== 'draw') return; - const cell = cellFromEvent(e); - if (!cell) return; - e.preventDefault(); - el.canvas.setPointerCapture(e.pointerId); - game.drag = { c0: cell.c, r0: cell.r, c1: cell.c, r1: cell.r }; - setMsg(placeInstructions()); - render(); - }); - - el.canvas.addEventListener('pointerup', (e) => { - if (!game || !game.drag || game.phase !== 'place') return; - const cell = cellFromEvent(e, true); - game.drag.c1 = cell.c; - game.drag.r1 = cell.r; - finishDrag(); - }); - - el.canvas.addEventListener('pointercancel', () => { - if (!game || !game.drag) return; - game.drag = null; - render(); - }); - - el.canvas.addEventListener('pointermove', (e) => { - if (!game || game.phase !== 'place') return; - if (game.drag) { - const cell = cellFromEvent(e, true); - if (cell.c === game.drag.c1 && cell.r === game.drag.r1) return; - game.drag.c1 = cell.c; - game.drag.r1 = cell.r; - render(); - return; - } - if (e.pointerType === 'touch') return; - game.hover = cellFromEvent(e); - render(); - }); - - el.canvas.addEventListener('pointerleave', (e) => { - if (!game || e.pointerType === 'touch') return; - game.hover = null; - render(); - }); - - // ---- click mode - // Mouse: click places. Touch: first tap previews, tapping inside the preview places. - el.canvas.addEventListener('pointerdown', (e) => { - if (!game || game.phase !== 'place' || game.placeMode !== 'click') return; - const cell = cellFromEvent(e); - if (!cell) return; - if (e.pointerType === 'touch') { - const prev = humanPreview(); - const inside = - prev && cell.c >= prev.x && cell.c < prev.x + prev.w && cell.r >= prev.y && cell.r < prev.y + prev.h; - if (!inside || !prev.valid) { - game.hover = cell; - render(); - return; - } - } else { - game.hover = cell; - } - const p = humanPreview(); - if (p && p.valid) startAnswer(placeRect(p.x, p.y, p.w, p.h)); - else render(); - }); - - el.menuBtn.addEventListener('click', () => { - if (classSession && classSession.host) return showHostView(); // teacher: back to the class - const inClass = game && (game.mode === 'class' || game.mode === 'pair'); - const question = inClass ? 'Leave the class game? Your score so far won’t count.' : 'Leave this game and go back to the menu?'; - if (game && game.phase !== 'over' && !confirm(question)) return; - if (inClass) leaveClass(); - toMenu(); - }); - el.toMenuBtn.addEventListener('click', () => { - if (classSession && classSession.host) { - el.gameOver.hidden = true; - return showHostView(); - } - if (game && (game.mode === 'class' || game.mode === 'pair')) leaveClass(); - toMenu(); - }); - el.againBtn.addEventListener('click', () => { - startGame( - game.players.map(({ name, cpu }) => ({ name, cpu })), - game.size, - game.mode, - game.invite - ); - }); - - function toMenu() { - cancelContinue(); - stopTimer(); - gameId++; - game = null; - el.gameOver.hidden = true; - el.gameScreen.hidden = true; - el.startScreen.hidden = false; - } - - // ---------------------------------------------------------------- rendering - - function setMsg(text) { - el.turnMsg.textContent = text; - el.turnMsg.classList.remove('oops'); - } - - // Pips for a normal d6; 8- and 12-sided dice show the number. - // Pips for a normal d6 (dots, or the Wardrobe's hearts/stars/…); 8- and - // 12-sided dice show the number. `skin` defaults to the Wardrobe's dice. - function renderDie(die, value, sides = game ? game.sides : 6, skin = settings.cosmetics.dice) { - die.innerHTML = ''; - for (const c of [...die.classList]) if (c.startsWith('skin-')) die.classList.remove(c); - die.classList.add(`skin-${skin}`); - die.classList.toggle('blank', !value); - die.classList.toggle('numbered', Boolean(value) && sides > 6); - die.setAttribute('aria-label', value ? `Die showing ${value}` : 'Die not rolled'); - if (!value) return; - if (sides > 6) { - const num = document.createElement('span'); - num.className = 'die-num'; - num.textContent = value; - die.append(num); - return; - } - const on = PIPS[value]; - const glyph = look('pips').glyph; // e.g. '❤️'; plain dots when undefined - for (let i = 0; i < 9; i++) { - const cell = document.createElement('span'); - if (on.includes(i)) { - cell.className = glyph ? 'pip pip-glyph' : 'pip'; - if (glyph) cell.textContent = glyph; - } - die.append(cell); - } - } - - function render() { - if (!game) return; - const p = currentPlayer(); - el.turnPanel.style.setProperty('--turn-color', p.color); - el.turnLabel.textContent = - game.phase === 'over' - ? 'Game over' - : game.mode === 'class' - ? game.classroom.round - ? `Roll ${game.classroom.round} of ${game.classroom.view.settings.rounds}` - : 'Class game' - : game.mode === 'pair' - ? game.current === game.classroom.me - ? 'Your turn' - : `${p.name}'s turn` - : game.mode === 'single' && !p.cpu - ? 'Your turn' - : `${p.name}'s turn`; - el.rollBtn.disabled = game.phase !== 'roll'; - const realDice = game.diceMode === 'real'; - el.rollBtn.hidden = p.cpu || realDice || game.mode === 'class' || !['roll', 'rolling'].includes(game.phase); - el.diceEntry.hidden = p.cpu || !realDice || game.phase !== 'roll'; - const dims = currentDims(); - el.rotateBtn.hidden = !( - game.phase === 'place' && - game.placeMode === 'click' && - dims && - dims[0] !== dims[1] && - !Core.mustUseCorner(game.board) - ); - el.progress.hidden = !game.showProgress; - if (game.showProgress) renderProgress(); - el.canvas.style.cursor = game.phase === 'place' ? 'crosshair' : 'default'; - renderScores(); - drawBoard(); - } - - // "Board: 24 of 400 filled", with a bar split into each player's colour. - function renderProgress() { - const total = game.size * game.size; - const filled = total - Core.emptyCount(game.board); - el.progressText.innerHTML = - `<span class="long">Board: ${filled} of ${total} filled</span>` + - `<span class="short">${filled} / ${total} filled</span>`; - el.progressBar.setAttribute('aria-valuemin', '0'); - el.progressBar.setAttribute('aria-valuemax', String(total)); - el.progressBar.setAttribute('aria-valuenow', String(filled)); - const perPlayer = game.players.map(() => 0); - for (const rect of game.board.rects) perPlayer[rect.player] += rect.area; - // Reuse the segments so their widths animate as the board fills. - if (el.progressBar.children.length !== game.players.length) { - el.progressBar.innerHTML = ''; - for (let i = 0; i < game.players.length; i++) el.progressBar.append(document.createElement('span')); - } - game.players.forEach((p, i) => { - const seg = el.progressBar.children[i]; - seg.style.width = `${(perPlayer[i] / total) * 100}%`; - seg.style.background = p.color; - }); - } - - function renderScores() { - el.scores.innerHTML = ''; - game.players.forEach((p, i) => { - const card = document.createElement('div'); - card.className = 'score-card' + (i === game.current && game.phase !== 'over' ? ' active' : ''); - card.style.setProperty('--c', p.color); - - const head = document.createElement('div'); - head.className = 'score-head'; - const name = document.createElement('span'); - name.className = 'score-name'; - name.textContent = p.avatar ? `${p.avatar} ${p.name}` : p.name; - if (p.title) { - name.append(make('span', 'player-title', p.title)); - } - const pts = document.createElement('span'); - pts.className = 'score-pts'; - pts.textContent = p.score; - head.append(name, pts); - card.append(head); - if (game.mode === 'class' && game.classroom.view && game.classroom.view.of > 1) { - const { you, of } = game.classroom.view; - card.append(make('div', 'score-rank', `🏫 ${you.tied ? 'Tied for ' : ''}${ordinal(you.rank)} of ${of} in the class`)); - } - - if (p.log.length) { - const log = document.createElement('ul'); - log.className = 'score-log'; - for (const entry of p.log) { - const li = document.createElement('li'); - if (entry.pass) { - li.className = 'pass'; - li.textContent = `${entry.a}×${entry.b} pass`; - } else if (entry.timeout) { - li.className = 'timeout'; - li.textContent = `${entry.a}×${entry.b} ⏰`; - } else { - li.textContent = `${entry.a}×${entry.b}=${entry.area}${entry.bonus ? ` +${entry.bonus}` : ''}`; - if (entry.star) li.className = 'star'; - } - log.append(li); - } - card.append(log); - } - el.scores.append(card); - }); - } - - let cssSize = 0; - - function resizeBoard() { - if (!game) return; - const wrapStyle = getComputedStyle(el.boardWrap); - const pad = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight); - const available = el.boardWrap.clientWidth - pad; - const narrow = window.innerWidth <= 820; - // Leave room for the header, and on phones for the dice panel below the board. - const maxByHeight = window.innerHeight - (narrow ? 300 : 110); - cssSize = Math.max(200, Math.floor(Math.min(available, maxByHeight, 760))); - const dpr = window.devicePixelRatio || 1; - el.canvas.style.width = el.canvas.style.height = `${cssSize}px`; - el.canvas.width = el.canvas.height = Math.round(cssSize * dpr); - ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - drawBoard(); - } - window.addEventListener('resize', resizeBoard); - - // Board colours come from the CSS theme, so the canvas follows light/dark mode. - let pal = {}; - function readPalette() { - const css = getComputedStyle(document.documentElement); - const v = (name) => css.getPropertyValue(name).trim(); - pal = { - bg: v('--board-bg'), - grid: v('--board-grid'), - hint: v('--board-hint'), - edge: v('--board-edge'), - fade: v('--board-fade'), - wrong: v('--board-wrong'), - bad: v('--bad'), - tagBg: v('--invert-bg'), - tagInk: v('--invert-ink'), - }; - Object.assign(pal, BOARD_THEMES[settings.cosmetics.board] || {}); // Wardrobe board - } - - // Wardrobe boards: colours plus a little decoration. Graph paper follows the theme. - const BOARD_THEMES = { - graph: null, - notebook: { bg: '#fdfdf6', grid: '#b9d3ea', edge: '#2b2a33', hint: '#8a8f99', fade: 'rgba(253, 253, 246, 0.65)', decor: 'notebook' }, - chalkboard: { bg: '#2f4f3a', grid: 'rgba(255, 255, 255, 0.16)', edge: '#1b3024', hint: '#d7e8dc', fade: 'rgba(47, 79, 58, 0.7)', wrong: '#c9d6cc', decor: 'chalk' }, - beach: { bg: '#f4e1b0', grid: 'rgba(150, 110, 50, 0.25)', edge: '#b58a4a', hint: '#8a6a3a', fade: 'rgba(244, 225, 176, 0.65)', decor: 'sand' }, - space: { bg: '#10143a', grid: 'rgba(255, 255, 255, 0.12)', edge: '#3a3f7a', hint: '#aab0e0', fade: 'rgba(16, 20, 58, 0.7)', wrong: '#aab0e0', decor: 'stars' }, - snow: { bg: '#eef6ff', grid: '#c8dcf0', edge: '#7a9cc0', hint: '#6d86a4', fade: 'rgba(238, 246, 255, 0.65)', decor: 'flakes' }, - }; - - // Repeatable pseudo-random numbers so decorations don't jump around on every redraw. - function decorRandom(seed) { - let a = seed; - return () => { - a = (a * 1103515245 + 12345) % 2147483648; - return a / 2147483648; - }; - } - - function drawBoardDecor(kind, cell) { - const r = decorRandom(7); - ctx.save(); - if (kind === 'notebook') { - ctx.strokeStyle = 'rgba(230, 110, 110, 0.7)'; - ctx.lineWidth = 2; - ctx.beginPath(); - ctx.moveTo(cell * 0.5, 0); - ctx.lineTo(cell * 0.5, cssSize); - ctx.stroke(); - } else if (kind === 'stars' || kind === 'flakes' || kind === 'sand' || kind === 'chalk') { - const count = kind === 'sand' ? 260 : kind === 'chalk' ? 40 : 90; - for (let i = 0; i < count; i++) { - const x = r() * cssSize; - const y = r() * cssSize; - const size = kind === 'chalk' ? 6 + r() * 14 : 0.6 + r() * (kind === 'flakes' ? 2.2 : 1.6); - ctx.fillStyle = - kind === 'stars' ? `rgba(255, 255, 255, ${0.35 + r() * 0.6})` : - kind === 'flakes' ? 'rgba(160, 190, 225, 0.6)' : - kind === 'sand' ? 'rgba(160, 120, 60, 0.35)' : 'rgba(255, 255, 255, 0.05)'; - ctx.beginPath(); - ctx.arc(x, y, size, 0, Math.PI * 2); - ctx.fill(); - } - } - ctx.restore(); - } - - // Wardrobe rectangle patterns: light marks over your own rectangles. - const patternCache = {}; - function rectPattern(kind) { - if (patternCache[kind]) return patternCache[kind]; - const tile = document.createElement('canvas'); - tile.width = tile.height = 16; - const g = tile.getContext('2d'); - g.strokeStyle = g.fillStyle = 'rgba(255, 255, 255, 0.35)'; - g.lineWidth = 3; - if (kind === 'stripes') { - g.beginPath(); - g.moveTo(-4, 20); - g.lineTo(20, -4); - g.moveTo(-4, 4); - g.lineTo(4, -4); - g.moveTo(12, 20); - g.lineTo(20, 12); - g.stroke(); - } else if (kind === 'dots') { - g.beginPath(); - g.arc(4, 4, 2.5, 0, Math.PI * 2); - g.arc(12, 12, 2.5, 0, Math.PI * 2); - g.fill(); - } else if (kind === 'bricks') { - g.lineWidth = 1.5; - g.strokeRect(0, 0.75, 16, 7.5); - g.beginPath(); - g.moveTo(8, 8); - g.lineTo(8, 16); - g.stroke(); - } else if (kind === 'checker') { - g.fillRect(0, 0, 8, 8); - g.fillRect(8, 8, 8, 8); - } else if (kind === 'wood') { - g.lineWidth = 1.5; - g.beginPath(); - g.moveTo(0, 4); - g.bezierCurveTo(5, 2, 11, 6, 16, 4); - g.moveTo(0, 11); - g.bezierCurveTo(5, 13, 11, 9, 16, 11); - g.stroke(); - } - return (patternCache[kind] = ctx.createPattern(tile, 'repeat')); - } - - // ---- placing effects: redraw the board for a moment after a rectangle lands - const PLACE_FX_MS = 650; - let boardAnimUntil = 0; - function animateBoard(ms) { - const already = boardAnimUntil > performance.now(); - boardAnimUntil = Math.max(boardAnimUntil, performance.now() + ms); - if (already) return; - const step = () => { - drawBoard(); - if (performance.now() < boardAnimUntil) requestAnimationFrame(step); - }; - requestAnimationFrame(step); - } - - const easeOutBack = (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2); - - function drawBoard() { - if (!game || !cssSize) return; - readPalette(); - const n = game.size; - const cell = cssSize / n; - ctx.clearRect(0, 0, cssSize, cssSize); - ctx.fillStyle = pal.bg; - ctx.fillRect(0, 0, cssSize, cssSize); - if (pal.decor) drawBoardDecor(pal.decor, cell); - - // grid - ctx.strokeStyle = pal.grid; - ctx.lineWidth = 1; - ctx.beginPath(); - for (let i = 0; i <= n; i++) { - const v = Math.round(i * cell) + 0.5; - ctx.moveTo(v, 0); - ctx.lineTo(v, cssSize); - ctx.moveTo(0, v); - ctx.lineTo(cssSize, v); - } - ctx.stroke(); - - if (Core.mustUseCorner(game.board)) { - ctx.fillStyle = pal.hint; - ctx.font = `700 ${Math.max(8, cell * 0.2)}px system-ui, sans-serif`; - ctx.textAlign = 'left'; - ctx.textBaseline = 'top'; - ['Start', 'here'].forEach((t, i) => ctx.fillText(t, 4, 4 + i * cell * 0.24)); - } - - for (const rect of game.board.rects) { - drawRect(rect, game.players[rect.player].color, cell, rect === game.lastRect); - } - - const preview = game.cpuPreview ? { ...game.cpuPreview, valid: true } : humanPreview(); - if (preview) drawPreview(preview, currentPlayer().color, cell); - if (game.phase === 'place' && game.placeMode === 'draw') { - if (game.drag) drawDragging(dragRect(), currentPlayer().color, cell); - else if (game.hover) drawHoverCell(game.hover, currentPlayer().color, cell); - } - - ctx.strokeStyle = pal.edge; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, cssSize - 2, cssSize - 2); - } - - function drawRect(rect, color, cell, highlight) { - const x = rect.x * cell; - const y = rect.y * cell; - const w = rect.w * cell; - const h = rect.h * cell; - const t = rect.fx ? Math.min(1, (performance.now() - rect.placedAt) / PLACE_FX_MS) : 1; - if (rect.fx === 'pop' && t < 1) return drawPopIn(rect, color, cell, t); - const owner = game.players[rect.player]; - ctx.save(); - if (rect.fx === 'glow' && t < 1) { - ctx.shadowColor = color; - ctx.shadowBlur = 36 * (1 - t); - } - ctx.fillStyle = color; - ctx.globalAlpha = 0.82; - ctx.fillRect(x, y, w, h); - ctx.restore(); - if (!owner.cpu && settings.cosmetics.pattern !== 'none') { - ctx.fillStyle = rectPattern(settings.cosmetics.pattern); - ctx.fillRect(x, y, w, h); - } - ctx.strokeStyle = shade(color, -0.35); - ctx.lineWidth = highlight ? 3.5 : 2; - ctx.strokeRect(x + 1, y + 1, w - 2, h - 2); - if (rect.fx === 'ripple' && t < 1) { - const grow = t * cell * 0.9; - ctx.save(); - ctx.globalAlpha = 1 - t; - ctx.strokeStyle = color; - ctx.lineWidth = 3; - ctx.strokeRect(x - grow, y - grow, w + grow * 2, h + grow * 2); - ctx.restore(); - } - if (game.counting && game.counting.rect === rect) drawCounting(game.counting, color, cell); - else drawLabel(rect, x, y, w, h, cell); - } - - // Pop-in: the squares spring up one after another. - function drawPopIn(rect, color, cell, t) { - const total = rect.w * rect.h; - ctx.fillStyle = color; - for (let k = 0; k < total; k++) { - const local = Math.max(0, Math.min(1, (t - (k / total) * 0.55) / 0.45)); - if (!local) continue; - const s = easeOutBack(local); - const cx = (rect.x + (k % rect.w) + 0.5) * cell; - const cy = (rect.y + Math.floor(k / rect.w) + 0.5) * cell; - const half = (cell / 2) * s; - ctx.globalAlpha = 0.82; - ctx.fillRect(cx - half, cy - half, half * 2, half * 2); - } - ctx.globalAlpha = 1; - } - - // Skip counting: rows (or columns) already counted stay bright and show the - // running total; the rest are faded out. - function drawCounting({ rect, byRows, shown }, color, cell) { - const stripes = byRows ? rect.h : rect.w; - const group = byRows ? rect.w : rect.h; - for (let i = 0; i < stripes; i++) { - const sx = (rect.x + (byRows ? 0 : i)) * cell; - const sy = (rect.y + (byRows ? i : 0)) * cell; - const sw = (byRows ? rect.w : 1) * cell; - const sh = (byRows ? 1 : rect.h) * cell; - if (i >= shown) { - ctx.fillStyle = pal.fade; - ctx.fillRect(sx, sy, sw, sh); - continue; - } - ctx.strokeStyle = shade(color, -0.45); - ctx.lineWidth = 2; - ctx.strokeRect(sx + 2, sy + 2, sw - 4, sh - 4); - const size = Math.max(10, Math.min(cell * 0.55, 24)); - ctx.font = `900 ${size}px system-ui, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.lineWidth = 4; - ctx.strokeStyle = shade(color, -0.5); - ctx.fillStyle = '#ffffff'; - const text = String((i + 1) * group); - ctx.strokeText(text, sx + sw / 2, sy + sh / 2); - ctx.fillText(text, sx + sw / 2, sy + sh / 2); - } - } - - function drawLabel(rect, x, y, w, h, cell) { - const size = Math.max(9, Math.min(cell * 0.5, 22)); - ctx.font = `800 ${size}px system-ui, sans-serif`; - ctx.fillStyle = '#ffffff'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const cx = x + w / 2; - const cy = y + h / 2; - if (!rect.solved) { - ctx.font = `900 ${Math.max(12, Math.min(cell * 0.9, 36))}px system-ui, sans-serif`; - ctx.fillText('?', cx, cy); - return; - } - const full = `${rect.a}×${rect.b}=${rect.area}`; - if (ctx.measureText(full).width <= w - 8) { - ctx.fillText(full, cx, cy); - } else if (h > w && ctx.measureText(full).width <= h - 8) { - ctx.save(); - ctx.translate(cx, cy); - ctx.rotate(-Math.PI / 2); - ctx.fillText(full, 0, 0); - ctx.restore(); - } else if (ctx.measureText(String(rect.area)).width <= w - 4) { - ctx.fillText(String(rect.area), cx, cy); - } - } - - // The rectangle a human is dragging out, labelled with its current size. - // Player colour when it matches the roll and fits, grey when the size is - // wrong, red when it overlaps. - function drawDragging(r, color, cell) { - const verdict = judgeDrawn(r); - const tint = !verdict.fits ? pal.bad : verdict.ok ? color : pal.wrong; - const x = r.x * cell; - const y = r.y * cell; - const w = r.w * cell; - const h = r.h * cell; - ctx.fillStyle = tint; - ctx.globalAlpha = verdict.ok ? 0.55 : 0.3; - ctx.fillRect(x, y, w, h); - ctx.globalAlpha = 1; - ctx.strokeStyle = shade(tint, -0.3); - ctx.lineWidth = 2.5; - ctx.strokeRect(x + 1.5, y + 1.5, w - 3, h - 3); - - const label = `${r.w} × ${r.h}${verdict.ok ? ' ✓' : ''}`; - const size = Math.max(12, Math.min(cell * 0.6, 24)); - ctx.font = `900 ${size}px system-ui, sans-serif`; - const tw = ctx.measureText(label).width + 12; - const th = size + 8; - // Tag just above the rectangle (or inside the top if there's no room). - const tx = Math.max(2, Math.min(x + w / 2 - tw / 2, cssSize - tw - 2)); - const ty = y - th - 4 >= 0 ? y - th - 4 : y + 4; - ctx.fillStyle = pal.tagBg; - ctx.beginPath(); - if (ctx.roundRect) ctx.roundRect(tx, ty, tw, th, 6); - else ctx.rect(tx, ty, tw, th); - ctx.fill(); - ctx.fillStyle = pal.tagInk; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(label, tx + tw / 2, ty + th / 2 + 1); - } - - function drawHoverCell({ c, r }, color, cell) { - ctx.strokeStyle = color; - ctx.lineWidth = 2.5; - ctx.strokeRect(c * cell + 2, r * cell + 2, cell - 4, cell - 4); - } - - function drawPreview(p, color, cell) { - const x = p.x * cell; - const y = p.y * cell; - const w = p.w * cell; - const h = p.h * cell; - ctx.fillStyle = p.valid ? color : pal.bad; - ctx.globalAlpha = p.valid ? 0.35 : 0.22; - ctx.fillRect(x, y, w, h); - ctx.globalAlpha = 1; - ctx.setLineDash([6, 4]); - ctx.strokeStyle = p.valid ? shade(color, -0.3) : pal.bad; - ctx.lineWidth = 2.5; - ctx.strokeRect(x + 1.5, y + 1.5, w - 3, h - 3); - ctx.setLineDash([]); - if (!p.valid) { - ctx.fillStyle = pal.bad; - ctx.font = `800 ${Math.max(10, Math.min(cell * 0.5, 20))}px system-ui, sans-serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText('✕', x + w / 2, y + h / 2); - } - } - - // Darken (negative amount) or lighten a #rrggbb colour. - function shade(hex, amount) { - const n = parseInt(hex.slice(1), 16); - const mix = (v) => Math.round(amount < 0 ? v * (1 + amount) : v + (255 - v) * amount); - const r = mix((n >> 16) & 255); - const g = mix((n >> 8) & 255); - const b = mix(n & 255); - return `rgb(${r}, ${g}, ${b})`; - } -})(); diff --git a/js/invite.js b/js/invite.js deleted file mode 100644 index 4a1c745..0000000 --- a/js/invite.js +++ /dev/null @@ -1,106 +0,0 @@ -// Blockout invites: a teacher or parent packs a game setup (players, board, -// difficulty, settings, a note and a dice seed) into a link or code. -// Loaded as a plain script in the browser (window.BlockoutInvite) and via require() in tests. -(function (root) { - 'use strict'; - - const VERSION = 1; - const NOTE_MAX = 140; - const NAME_MAX = 14; - - // Every setting an invite may set, with its allowed values. - const SETTING_VALUES = { - difficulty: ['easy', 'medium', 'hard', 'tricky', 'master', 'legend'], - placeMode: ['draw', 'click', 'auto'], - diceMode: ['virtual', 'real'], - autoRoll: ['manual', 'auto'], - fitRolls: ['end', 'always', 'never'], - answerTime: ['0', '30', '10', '5', '20'], // 20 s is no longer offered but old invites still open - firstInCorner: [false, true], - cpuSpeed: ['slow', 'normal', 'fast'], - cpuSteps: ['show', 'instant'], - cpuEnd: ['auto', 'button'], - showProgress: [false, true], - }; - - // Invite shape (short keys keep links short): - // { v: 1, m: 'single'|'multi', b: boardSize, p: [{ n: name, c: isCpu }], - // s: { setting: value }, t: note, r: dice seed } - // Returns a clean copy, or null if anything is missing or out of range. - function validate(raw) { - if (!raw || typeof raw !== 'object' || raw.v !== VERSION) return null; - if (raw.m !== 'single' && raw.m !== 'multi') return null; - if (!Number.isInteger(raw.b) || raw.b < 6 || raw.b > 30) return null; - if (raw.s && raw.s.difficulty === 'legend' && raw.b < 11) return null; // Legend's teens need room - if (!Array.isArray(raw.p)) return null; - const count = raw.p.length; - if (raw.m === 'single' ? count !== 1 : count < 2 || count > 4) return null; - const players = []; - for (const p of raw.p) { - if (!p || typeof p !== 'object') return null; - const cpu = raw.m === 'multi' && p.c === true; - const name = typeof p.n === 'string' ? p.n.trim().slice(0, NAME_MAX) : ''; - players.push({ n: name, c: cpu }); - } - const s = {}; - for (const [key, value] of Object.entries(raw.s || {})) { - if (!SETTING_VALUES[key] || !SETTING_VALUES[key].includes(value)) return null; - s[key] = value; - } - const clean = { v: VERSION, m: raw.m, b: raw.b, p: players, s }; - if (typeof raw.t === 'string' && raw.t.trim()) clean.t = raw.t.trim().slice(0, NOTE_MAX); - if (raw.r !== undefined) { - if (!Number.isInteger(raw.r) || raw.r < 0 || raw.r > 0xffffffff) return null; - clean.r = raw.r; - } - return clean; - } - - // base64url of the UTF-8 JSON, so names and notes can use any language. - function encode(invite) { - const bytes = new TextEncoder().encode(JSON.stringify(invite)); - let binary = ''; - for (const b of bytes) binary += String.fromCharCode(b); - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); - } - - function decode(token) { - try { - const b64 = token.trim().replace(/-/g, '+').replace(/_/g, '/'); - const binary = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)); - const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)); - return validate(JSON.parse(new TextDecoder().decode(bytes))); - } catch (_) { - return null; - } - } - - // Accepts a bare code, a "#invite=CODE" fragment or a full link. - function tokenFrom(text) { - const match = String(text).match(/[#&?]invite=([A-Za-z0-9_-]+)/); - if (match) return match[1]; - const bare = String(text).trim(); - return /^[A-Za-z0-9_-]+$/.test(bare) ? bare : null; - } - - // Small, fast seeded generator (mulberry32): same seed, same dice. - function seededRandom(seed) { - let a = seed >>> 0; - return function () { - a = (a + 0x6d2b79f5) >>> 0; - let t = a; - t = Math.imul(t ^ (t >>> 15), t | 1); - t ^= t + Math.imul(t ^ (t >>> 7), t | 61); - return ((t ^ (t >>> 14)) >>> 0) / 4294967296; - }; - } - - function newSeed() { - return Math.floor(Math.random() * 0xffffffff); - } - - const api = { VERSION, SETTING_VALUES, validate, encode, decode, tokenFrom, seededRandom, newSeed }; - - if (typeof module !== 'undefined' && module.exports) module.exports = api; - else root.BlockoutInvite = api; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/progress.js b/js/progress.js deleted file mode 100644 index 9728a02..0000000 --- a/js/progress.js +++ /dev/null @@ -1,766 +0,0 @@ -// Blockout progress: the shared points wallet, shop unlocks, achievements, -// daily check-ins, answer bonuses and lifetime multiplication-fact stats. -// Pure functions over a plain state object; the page saves it to localStorage. -// Loaded as a plain script in the browser (window.BlockoutProgress) and via require() in tests. -(function (root) { - 'use strict'; - - const Core = typeof module !== 'undefined' && module.exports ? require('./core.js') : root.BlockoutCore; - - function createProgress() { - return { - version: 1, - wallet: 0, // points available to spend - earned: 0, // all points ever earned - unlocks: [], // shop item ids - achievements: {}, // id -> time earned - checkIn: { last: null, streak: 0 }, - facts: {}, // player key -> { name, facts: { '3 × 4': FactStats } }; 3 × 4 and 4 × 3 are separate - factsOrdered: true, - counters: { games: 0, classGames: 0, pairGames: 0, pairWins: 0, tournaments: 0, tournamentWins: 0 }, - boardsV2: true, // saves from before 6×6 became the free board don't have this - practiceV2: true, // …or from before practice lengths and times tables were unlocked separately - practiceTimersV1: true, // …or from before Expert practice had its own timers - placeAutoV1: true, // …or from before Auto placing was a separate unlock - gameExpertV1: true, // …or from before single player had Learn / Expert - stickers: {}, // sticker id -> how many - boardWins: {}, // board size -> wins against the CPU on it - helpedFacts: {}, // '6 × 7' -> true after "Help me count" on it, until it's answered right first time - }; - } - - // Fill in anything missing from an older or partial saved state. - // ---------------------------------------------------------------- cheat codes - // Switched on from the ~ menu and off again from its list. state.cheats keeps - // what each one changed, so turning it off undoes exactly that. - const CHEAT_CODES = { - iddqd: { name: 'Everything unlocked (shop and Wardrobe)' }, - fivemoreminutesmom: { name: 'Every timer is 5 minutes', timerSecs: 300 }, - }; - - function cheatOn(state, code) { - if (!CHEAT_CODES[code] || (state.cheats && state.cheats[code])) return false; - state.cheats = state.cheats || {}; - if (code === 'iddqd') { - const granted = SHOP.map((item) => item.id).filter((id) => !state.unlocks.includes(id)); - state.unlocks.push(...granted); - state.cheats.iddqd = { granted }; - } else { - state.cheats[code] = {}; // just a switch, read where it applies (see timerLength) - } - return true; - } - - // Takes back only what the cheat gave: anything bought before stays. - function cheatOff(state, code) { - const on = state.cheats && state.cheats[code]; - if (!on) return false; - if (code === 'iddqd') { - const granted = new Set(on.granted); - state.unlocks = state.unlocks.filter((id) => !granted.has(id)); - } - delete state.cheats[code]; - return true; - } - - const activeCheats = (state) => Object.keys(state.cheats || {}).filter((code) => CHEAT_CODES[code]); - - // How long an answer timer really runs: its own length, or 5 minutes with - // FIVEMOREMINUTESMOM on. No timer stays no timer. - const timerLength = (state, secs) => (secs && state.cheats && state.cheats.fivemoreminutesmom ? CHEAT_CODES.fivemoreminutesmom.timerSecs : secs); - - function normalize(saved) { - const fresh = createProgress(); - if (!saved || typeof saved !== 'object') return fresh; - const state = { - ...fresh, - ...saved, - checkIn: { ...fresh.checkIn, ...saved.checkIn }, - counters: { ...fresh.counters, ...saved.counters }, - unlocks: Array.isArray(saved.unlocks) ? saved.unlocks : [], - achievements: saved.achievements || {}, - facts: saved.facts || {}, - stickers: saved.stickers || {}, - boardWins: saved.boardWins || {}, - helpedFacts: saved.helpedFacts || {}, - cheats: saved.cheats || {}, - seen: { wardrobe: [], stickers: [], ...(saved.seen || {}) }, // for "new" badges - factsOrdered: true, - }; - if (!saved.factsOrdered) splitOldFacts(state.facts); - // Practice used to come with 10 and 20 questions and every times table: - // players who already bought it keep all of that. - if (!saved.practiceV2) { - if (state.unlocks.includes('practice')) { - for (const id of ['practice10', 'practice20', ...Array.from({ length: 12 }, (_, i) => `table${i + 1}`)]) if (!state.unlocks.includes(id)) state.unlocks.push(id); - } - state.practiceV2 = true; - } - // Game timers moved from Settings to single player's Expert level: whoever - // owned them keeps the same lengths there (on Easy), with Expert. - if (!saved.gameExpertV1) { - const owned = [30, 10, 5].filter((secs) => state.unlocks.includes(`answerTime${secs}`)); - if (owned.length) { - for (const id of ['gameExpert', ...owned.map((secs) => `gameTimer${secs}`)]) if (!state.unlocks.includes(id)) state.unlocks.push(id); - } - state.gameExpertV1 = true; - } - // "placeMode" used to unlock both Click and Auto: owners keep Auto too. - if (!saved.placeAutoV1) { - if (state.unlocks.includes('placeMode') && !state.unlocks.includes('placeAuto')) state.unlocks.push('placeAuto'); - state.placeAutoV1 = true; - } - // Expert practice used the game's answer timer; it has its own timers now. - // Anyone who had both keeps the same lengths. - if (!saved.practiceTimersV1) { - if (state.unlocks.includes('practiceExpert')) { - for (const secs of [30, 10, 5]) { - if (state.unlocks.includes(`answerTime${secs}`) && !state.unlocks.includes(`practiceTimer${secs}`)) state.unlocks.push(`practiceTimer${secs}`); - } - } - state.practiceTimersV1 = true; - } - // 12×12 used to be the free board. Anyone who played before it moved to the - // shop keeps it (a one-time change for older saves). - if (!saved.boardsV2) { - const played = (state.counters.games || 0) > 0 || Object.keys(state.facts).length > 0; - if (played && !state.unlocks.includes('board12')) state.unlocks.push('board12'); - state.boardsV2 = true; - } - // The old all-in-one timer unlock (10/20/30 s) becomes the 30 s and 10 s unlocks. - if (state.unlocks.includes('answerTime')) { - state.unlocks = state.unlocks.filter((id) => id !== 'answerTime'); - for (const id of ['answerTime30', 'answerTime10']) if (!state.unlocks.includes(id)) state.unlocks.push(id); - } - return state; - } - - // Older saves merged 3 × 4 with 4 × 3 (stored as the smaller number first). - // We can't tell which order was asked, so each old fact starts both orders. - function splitOldFacts(facts) { - for (const player of Object.values(facts)) { - for (const [key, f] of Object.entries(player.facts || {})) { - const [a, b] = key.split('×').map((n) => Number(n.trim())); - const flipped = factKey(b, a); - if (a !== b && !player.facts[flipped]) player.facts[flipped] = { ...f, said: [...(f.said || [])] }; - } - } - } - - function addPoints(state, points) { - state.wallet += points; - state.earned += points; - } - - // ---------------------------------------------------------------- answer bonuses - - const BONUS = { firstTry: 2, quick: 3, quickMs: 5000, streakFrom: 3, streakMax: 5 }; - - // Finishing a game against the CPU on a bigger board earns extra, growing - // faster than the board (bigger boards take longer). Custom sizes get the - // bonus of the biggest standard board they reach. - const BOARD_FINISH_BONUS = { 6: 0, 8: 5, 10: 10, 12: 20, 16: 40, 20: 70, 24: 100 }; - function boardFinishBonus(size) { - let bonus = 0; - for (const [n, b] of Object.entries(BOARD_FINISH_BONUS)) if (size >= Number(n)) bonus = b; - return bonus; - } - - // Finishing a longer practice round earns extra, more than in proportion. - const PRACTICE_FINISH_BONUS = { 5: 0, 10: 15, 20: 40 }; - const practiceFinishBonus = (count, expert = false) => (PRACTICE_FINISH_BONUS[count] || 0) * (expert ? 2 : 1); - - // Answering with a timer on: the shorter the timer, the bigger the top bonus. - // You get it all for an instant answer and nothing when time runs out, on a - // square-root curve (√ of the share of time left), so a quick answer on a - // short timer pays best and a slower one still earns something. - const TIMER_BONUS_MAX = { 30: 2, 10: 4, 5: 6 }; - function timerBonus(secs, left) { - const top = TIMER_BONUS_MAX[secs] || 0; - if (!top || !(left > 0)) return 0; - return Math.round(top * Math.sqrt(Math.min(1, left))); - } - - // Extra points for a correct answer, on top of the rectangle's squares. - // Only first-try answers earn bonuses; a streak counts first-try answers in a row. - // timer: { secs, left } when a timer was on (left = share of the time left, 0–1). - function answerBonus({ firstTry, ms, streak, timer = null }) { - const parts = []; - if (firstTry) { - parts.push({ label: 'first try', points: BONUS.firstTry }); - if (ms <= BONUS.quickMs) parts.push({ label: 'speedy', points: BONUS.quick }); - if (streak >= BONUS.streakFrom) { - parts.push({ label: `streak ${streak}`, points: Math.min(streak - BONUS.streakFrom + 1, BONUS.streakMax) }); - } - const beat = timer ? timerBonus(timer.secs, timer.left) : 0; - if (beat) parts.push({ label: `timer ${timer.secs} s`, points: beat }); - } - return { total: parts.reduce((sum, p) => sum + p.points, 0), parts }; - } - - // ---------------------------------------------------------------- shop - - const DIFFICULTIES = Core.DIFFICULTIES; // easy, medium, hard, tricky, master, legend - const TIMER_DIFFICULTY_NAMES = { easy: 'Easy', medium: 'Medium', hard: 'Hard', tricky: 'Tricky', master: 'Master', legend: 'Legend' }; - // Prices for per-difficulty chains: Easy, Medium and Hard as before, then a - // step up for each difficulty after that. - const stepPrice = (list, di, step) => (di < list.length ? list[di] : list[list.length - 1] + step * (di - list.length + 1)); - // Expert practice, per difficulty (Easy keeps the original id) - const practiceExpertId = (difficulty = 'easy') => (difficulty === 'easy' ? 'practiceExpert' : `practiceExpert.${difficulty}`); - - // Single player, like Practice: Expert (no "Help me count", ×2 bonus points) - // and its answer timers are unlocked for each difficulty. - const gameExpertId = (difficulty = 'easy') => (difficulty === 'easy' ? 'gameExpert' : `gameExpert.${difficulty}`); - const gameTimerId = (secs, difficulty = 'easy') => (difficulty === 'easy' ? `gameTimer${secs}` : `gameTimer${secs}.${difficulty}`); - const GAME_CHAINS = DIFFICULTIES.flatMap((d, di) => [ - { - id: gameExpertId(d), - group: 'Single player', - name: `Expert games (${TIMER_DIFFICULTY_NAMES[d]}): no help counting, ×2 bonus points`, - price: stepPrice([150, 200, 250], di, 50), - ...(d === 'easy' ? {} : { requires: `difficulty.${d}` }), - perk: 'Bonus points are doubled, and you can race an answer timer.', - }, - ...[30, 10, 5].map((secs, i) => ({ - id: gameTimerId(secs, d), - group: 'Single player', - name: `Expert game timer: ${secs} seconds (${TIMER_DIFFICULTY_NAMES[d]})`, - price: stepPrice([[30, 50, 80], [50, 80, 110], [70, 100, 140]].map((row) => row[i]), di, 30), - requires: i ? gameTimerId([30, 10, 5][i - 1], d) : gameExpertId(d), - perk: `${secs === 5 ? 'Only for the quickest' : 'Race the clock'} on ${TIMER_DIFFICULTY_NAMES[d]}: a timer bonus of up to +${TIMER_BONUS_MAX[secs] * 2} each (Expert doubles it).`, - })), - ]); - - // Practice round lengths: 5 comes with Practice mode on every difficulty; 10 - // and 20 are unlocked separately for each one. Easy keeps the original ids. - const practiceCountId = (count, difficulty = 'easy') => (count === 5 ? null : difficulty === 'easy' ? `practice${count}` : `practice${count}.${difficulty}`); - const COUNT_PRICES = { easy: [40, 80], medium: [60, 110], hard: [80, 140] }; - const PRACTICE_COUNT_CHAINS = DIFFICULTIES.flatMap((d, di) => - [10, 20].map((count, i) => ({ - id: practiceCountId(count, d), - group: 'Practice', - name: `Practice: ${count} questions (${TIMER_DIFFICULTY_NAMES[d]})`, - price: stepPrice(['easy', 'medium', 'hard'].map((x) => COUNT_PRICES[x][i]), di, 30), - requires: i ? practiceCountId(10, d) : d === 'easy' ? 'practice' : `difficulty.${d}`, - perk: `Finish all ${count} for +${practiceFinishBonus(count)} bonus points (+${practiceFinishBonus(count, true)} on Expert).`, - })) - ); - - // Expert practice timer unlocks, one chain per difficulty. Easy keeps the - // original ids (practiceTimer30, …); the others add the difficulty. - const practiceTimerId = (secs, difficulty = 'easy') => (difficulty === 'easy' ? `practiceTimer${secs}` : `practiceTimer${secs}.${difficulty}`); - const TIMER_PRICES = { easy: [40, 70, 100], medium: [60, 100, 140], hard: [80, 130, 180] }; - const PRACTICE_TIMER_CHAINS = DIFFICULTIES.flatMap((d, di) => - [30, 10, 5].map((secs, i) => ({ - id: practiceTimerId(secs, d), - group: 'Practice', - name: `Expert practice timer: ${secs} seconds (${TIMER_DIFFICULTY_NAMES[d]})`, - price: stepPrice(['easy', 'medium', 'hard'].map((x) => TIMER_PRICES[x][i]), di, 30), - // the chain starts with Expert practice (and, above Easy, that difficulty) - requires: i ? practiceTimerId([30, 10, 5][i - 1], d) : practiceExpertId(d), // starts with that difficulty's Expert - perk: `${secs === 5 ? 'Only for the quickest' : 'Race the clock'} on ${TIMER_DIFFICULTY_NAMES[d]}: a timer bonus of up to +${TIMER_BONUS_MAX[secs] * 2} each (Expert doubles it).`, - })) - ); - - const SHOP = [ - { id: 'multiplayer', group: 'Modes', name: 'Multiplayer (2 players)', price: 150 }, - { id: 'players3', group: 'Modes', name: 'Multiplayer: 3 players', price: 100, requires: 'multiplayer' }, - { id: 'players4', group: 'Modes', name: 'Multiplayer: 4 players', price: 100, requires: 'players3' }, - { id: 'practice', group: 'Modes', name: 'Practice mode (5 questions)', price: 100 }, - // Skill-tree chains: each one needs the one before it (`requires`); the UI only - // shows the next step, the rest are locked placeholders. - // Boards: you also have to win a game on the board before (`winOn`) to see the next one. - { id: 'board8', group: 'Boards', name: 'Board 8×8', price: 50, winOn: 6, perk: `Finish a game against the CPU on it for +${boardFinishBonus(8)} bonus points.` }, - { id: 'board10', group: 'Boards', name: 'Board 10×10', price: 80, requires: 'board8', winOn: 8, perk: `Finish a game against the CPU on it for +${boardFinishBonus(10)} bonus points.` }, - { id: 'board12', group: 'Boards', name: 'Board 12×12', price: 120, requires: 'board10', winOn: 10, perk: `Finish a game against the CPU on it for +${boardFinishBonus(12)} bonus points.` }, - { id: 'board16', group: 'Boards', name: 'Medium board (16×16)', price: 150, requires: 'board12', winOn: 12, perk: `Finish a game against the CPU on it for +${boardFinishBonus(16)} bonus points.` }, - { id: 'board20', group: 'Boards', name: 'Large board (20×20)', price: 300, requires: 'board16', winOn: 16, perk: `Finish a game against the CPU on it for +${boardFinishBonus(20)} bonus points.` }, - { id: 'board24', group: 'Boards', name: 'Huge board (24×24)', price: 450, requires: 'board20', winOn: 20, perk: `Finish a game against the CPU on it for +${boardFinishBonus(24)} bonus points.` }, - { id: 'boardCustom', group: 'Boards', name: 'Custom board size', price: 400, requires: 'board24', winOn: 24, perk: `Any size from 6 to 30. Its finishing bonus matches the biggest standard board it reaches (+${boardFinishBonus(24)} from 24×24 up).` }, - // Difficulties: you also have to master most of the times table the one before - // asks (`masterUpTo`, see tableReady) to see the next one. - { id: 'difficulty.medium', group: 'Difficulty', name: 'Medium: 8-sided dice', price: 250, masterUpTo: 6 }, - { id: 'difficulty.hard', group: 'Difficulty', name: 'Hard: 12-sided dice', price: 500, requires: 'difficulty.medium', masterUpTo: 8 }, - // After Hard: harder facts rather than bigger numbers (see Core.diceFaces) - { id: 'difficulty.tricky', group: 'Difficulty', name: 'Tricky: no easy facts (no ×1, ×2, ×5, ×10, ×11)', price: 700, requires: 'difficulty.hard', mastery: { size: 12, share: 0.75 } }, - { id: 'difficulty.master', group: 'Difficulty', name: 'Master: the dice go after your weakest facts', price: 900, requires: 'difficulty.tricky', mastery: { faces: Core.TRICKY_FACES, share: 0.75 } }, - { id: 'difficulty.legend', group: 'Difficulty', name: 'Legend: two-digit × one-digit (like 14 × 7)', price: 1200, requires: 'difficulty.master', mastery: { size: 12, share: 0.9 } }, - // Placing rectangles: Click first, then Auto (drawing is free) - { id: 'placeMode', group: 'Settings', name: 'Placing: click to place', price: 40 }, - { id: 'placeAuto', group: 'Settings', name: 'Placing: auto (placed for you)', price: 60, requires: 'placeMode' }, - { id: 'diceMode', group: 'Settings', name: 'Real dice', price: 60 }, - { id: 'autoRoll', group: 'Settings', name: 'Auto roll', price: 40 }, - { id: 'fitRolls', group: 'Settings', name: '"No room" options: Always fit & Original rules', price: 40 }, - // (Home multiplayer; single player has its own timers under Expert) - { id: 'answerTime30', group: 'Settings', name: 'Home multiplayer answer timer: 30 seconds', price: 30, perk: `Answer quickly for a timer bonus of up to +${TIMER_BONUS_MAX[30]} each.` }, - { id: 'answerTime10', group: 'Settings', name: 'Home multiplayer answer timer: 10 seconds', price: 50, requires: 'answerTime30', perk: `Answer quickly for a timer bonus of up to +${TIMER_BONUS_MAX[10]} each.` }, - { id: 'answerTime5', group: 'Settings', name: 'Home multiplayer answer timer: 5 seconds', price: 80, requires: 'answerTime10', perk: `Answer quickly for a timer bonus of up to +${TIMER_BONUS_MAX[5]} each.` }, - { id: 'cpuSpeed', group: 'Settings', name: 'Slow & fast CPU explanations', price: 40 }, - { id: 'cpuEnd', group: 'Settings', name: 'Press a button after CPU turns', price: 40 }, - { id: 'cpuInstant', group: 'Settings', name: 'Instant CPU turns', price: 80 }, - { id: 'showProgress', group: 'Settings', name: 'Board progress bar', price: 30 }, - { id: 'firstInCorner', group: 'Settings', name: 'Corner rule', price: 30 }, - // perk: what the unlock gives you, shown when you buy it - ...GAME_CHAINS, - // Longer practice rounds: a chain per difficulty (10, then 20). See practiceCountId. - ...PRACTICE_COUNT_CHAINS, - // Expert practice, unlocked for each difficulty. See practiceExpertId. - ...DIFFICULTIES.map((d, i) => ({ - id: practiceExpertId(d), - group: 'Practice', - name: `Expert practice (${TIMER_DIFFICULTY_NAMES[d]}): no picture, no help, ×2 bonus points`, - price: stepPrice([200, 250, 300], i, 50), - requires: d === 'easy' ? 'practice' : `difficulty.${d}`, - perk: 'Bonus points are doubled, including the bonus for finishing 10 or 20 questions.', - })), - // Expert practice timers: a chain per difficulty (30 s, then 10 s, then 5 s), - // since being quick on ×6 facts isn't being quick on ×12 ones. See practiceTimerId. - ...PRACTICE_TIMER_CHAINS, - // Times tables to practice, one at a time: ×1 first, then ×2, … - ...Array.from({ length: 12 }, (_, i) => ({ - id: `table${i + 1}`, - group: 'Times tables', - name: `Practice the ×${i + 1} table`, - price: 10 + 5 * i, - requires: i ? `table${i}` : 'practice', - })), - { id: 'timesMastered', group: 'Stats', name: 'Best times on Mastered cells', price: 50 }, - { id: 'timesLearning', group: 'Stats', name: 'Best times on Learning cells', price: 50 }, - { id: 'timesPractice', group: 'Stats', name: 'Best times on Needs practice cells', price: 50 }, - { id: 'goldenDice', group: 'Extras', name: 'Golden dice ✨', price: 10000 }, - ]; - - function shopItem(id) { - return SHOP.find((item) => item.id === id) || null; - } - - // Free things (no id) are always unlocked. - function isUnlocked(state, id) { - return !id || state.unlocks.includes(id); - } - - function canBuy(state, id, now = new Date()) { - const item = shopItem(id); - if (!item) return { ok: false, reason: 'unknown' }; - if (isUnlocked(state, id)) return { ok: false, reason: 'owned' }; - if (item.available && !item.available(now)) return { ok: false, reason: 'season', when: item.seasonLabel }; - if (item.requires && !isUnlocked(state, item.requires)) return { ok: false, reason: 'requires', requires: item.requires }; - if (item.winOn && !hasWonOn(state, item.winOn)) return { ok: false, reason: 'win', size: item.winOn }; - if (item.masterUpTo || item.mastery) { - const ready = tableReady(state, item.mastery || item.masterUpTo); - if (!ready.ok) return { ok: false, reason: 'master', ...ready }; - } - if (state.wallet < item.price) return { ok: false, reason: 'points', missing: item.price - state.wallet }; - return { ok: true }; - } - - // The first thing to unlock on the way to `id`: itself if its requirement is - // met, otherwise the earliest locked step before it. - function nextInChain(state, id) { - let item = shopItem(id); - while (item && item.requires && !isUnlocked(state, item.requires)) item = shopItem(item.requires); - return item ? item.id : id; - } - - // Wins against the CPU, per board size: winning on a board reveals the next one. - const hasWonOn = (state, size) => (state.boardWins[size] || 0) > 0; - function recordBoardWin(state, size) { - state.boardWins[size] = (state.boardWins[size] || 0) + 1; - } - - // Knowing a set of facts (the ones a difficulty's dice ask): at least a share - // of them Mastered and none "Needs practice". `spec` is a size (every fact up to - // size × size, MASTER_SHARE of them) or { size | faces, share }. Counts the - // player who plays on this device ("You"). - const MASTER_SHARE = 0.75; - function tableReady(state, spec, name = 'You') { - const { size = null, faces = null, share = MASTER_SHARE } = typeof spec === 'number' ? { size: spec } : spec; - const list = faces || Array.from({ length: size }, (_, i) => i + 1); - const facts = (state.facts[playerKey(name)] || { facts: {} }).facts; - let mastered = 0; - let practice = 0; - for (const a of list) { - for (const b of list) { - const status = factStatus(facts[factKey(a, b)]); - if (status === 'mastered') mastered++; - if (status === 'practice') practice++; - } - } - const total = list.length * list.length; - const need = Math.ceil(total * share); - return { ok: mastered >= need && practice === 0, size: size || Math.max(...list), ...(faces ? { faces } : {}), mastered, need, total, practice }; - } - - // Locked and not next in line (or not earned yet, by a win or by knowing the - // times table): shown as a locked placeholder with no name or price. - function isHidden(state, id) { - if (!id || isUnlocked(state, id)) return false; - if (nextInChain(state, id) !== id) return true; - const item = shopItem(id); - if (!item) return false; - if (item.winOn && !hasWonOn(state, item.winOn)) return true; - return Boolean((item.masterUpTo || item.mastery) && !tableReady(state, item.mastery || item.masterUpTo).ok); - } - - function buy(state, id, now = new Date()) { - const check = canBuy(state, id, now); - if (!check.ok) return check; - state.wallet -= shopItem(id).price; - state.unlocks.push(id); - return { ok: true }; - } - - // ---------------------------------------------------------------- daily check-in - - function dayKey(date) { - const pad = (n) => String(n).padStart(2, '0'); - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; - } - - // First visit of the day earns a bonus that grows with the streak of days in a row. - // Returns null if already checked in today. - function checkIn(state, now = new Date()) { - const today = dayKey(now); - if (state.checkIn.last === today) return null; - const yesterday = dayKey(new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)); - const streak = state.checkIn.last === yesterday ? state.checkIn.streak + 1 : 1; - const bonus = 20 + 5 * Math.min(streak - 1, 6); - state.checkIn = { last: today, streak }; - addPoints(state, bonus); - return { bonus, streak }; - } - - // ---------------------------------------------------------------- lifetime fact stats - - // Order matters: knowing 5 × 6 doesn't mean knowing 6 × 5, so they're separate facts. - function factKey(a, b) { - return `${a} × ${b}`; - } - - function playerKey(name) { - return name.trim().toLowerCase(); - } - - // One question answered (or timed out) by a human player. - function recordAnswer(state, name, { a, b, firstTry, correct, wrongAnswers = [], timeout = false, ms = null }) { - const key = playerKey(name); - const player = state.facts[key] || (state.facts[key] = { name, facts: {} }); - player.name = name; - const id = factKey(a, b); - const f = - player.facts[id] || - (player.facts[id] = { asked: 0, firstTry: 0, right: 0, wrong: 0, timeouts: 0, said: [], totalMs: 0, timed: 0 }); - f.asked++; - if (firstTry) f.firstTry++; - if (correct) f.right++; - if (timeout) f.timeouts++; - f.wrong += wrongAnswers.length; - f.said = [...f.said, ...wrongAnswers].slice(-10); - if (correct && ms !== null) { - f.totalMs += ms; - f.timed++; - f.bestMs = f.bestMs === undefined ? ms : Math.min(f.bestMs, ms); - } - } - - // Fastest correct answer for a fact; older saves only have the average. - function factBestMs(f) { - if (!f || !f.timed) return null; - return f.bestMs !== undefined ? f.bestMs : f.totalMs / f.timed; - } - - // Is every fact in the n line at least `level` ('learning' or 'mastered')? - // The line is both orders: n × 1 … n × size and 1 × n … size × n. - function lineReached(facts, n, size, level) { - const ok = level === 'mastered' ? ['mastered'] : ['learning', 'mastered']; - for (let m = 1; m <= size; m++) { - if (!ok.includes(factStatus(facts[factKey(n, m)]))) return false; - if (!ok.includes(factStatus(facts[factKey(m, n)]))) return false; - } - return true; - } - - // 'mastered': mostly right first time over a few tries; 'practice': missed - // more often than not; 'learning': in between. - function factStatus(f) { - if (!f || !f.asked) return 'unseen'; - const rate = f.firstTry / f.asked; - if (f.asked >= 3 && rate >= 0.8) return 'mastered'; - if (f.wrong + f.timeouts > 0 && rate < 0.6) return 'practice'; - return 'learning'; - } - - // ---------------------------------------------------------------- practice mode - - // How likely each kind of fact is to be picked for practice. - const PRACTICE_WEIGHTS = { practice: 8, learning: 4, unseen: 3, mastered: 0.5 }; - - // Pick `count` facts from 1..max for one player, favouring the ones they - // need most. `tables` (e.g. [3, 4]) limits it to those times tables; empty - // means the whole table. 3 × 4 and 4 × 3 are separate facts, each picked on - // its own record. Each fact appears once (unless there are fewer facts than - // questions): [a, b] exactly as it should be asked. - // The facts a difficulty practices, and its table chips. Legend's chips are the - // one-digit side (14 × 7 is in the ×7 table). - function practiceSet(difficulty) { - const { a: A, b: B } = Core.diceFaces(difficulty); - const pairs = []; - for (const a of A) for (const b of B) pairs.push([a, b]); - return { pairs, tables: difficulty === 'legend' ? B : A, bSideOnly: difficulty === 'legend' }; - } - - // `max` is a size (every fact up to max × max) or a difficulty's practiceSet. - function pickPracticeFacts(facts, max, count, rng = Math.random, tables = []) { - const set = typeof max === 'number' ? null : max; - const allPairs = set ? set.pairs : []; - if (!set) for (let a = 1; a <= max; a++) for (let b = 1; b <= max; b++) allPairs.push([a, b]); - const allowed = set ? set.tables : Array.from({ length: max }, (_, i) => i + 1); - const only = new Set(tables.filter((n) => allowed.includes(n))); - const pool = []; - for (const [a, b] of allPairs) { - { - if (only.size && !(set && set.bSideOnly ? only.has(b) : only.has(a) || only.has(b))) continue; - const status = factStatus(facts[factKey(a, b)]); - let weight = PRACTICE_WEIGHTS[status]; - if ((a === 1 || b === 1) && status !== 'practice') weight /= 2; // ×1 facts are easy wins - pool.push({ a, b, weight }); - } - } - const picked = []; - while (picked.length < count && pool.length) { - let r = rng() * pool.reduce((sum, f) => sum + f.weight, 0); - let i = 0; - while (i < pool.length - 1 && (r -= pool[i].weight) > 0) i++; - picked.push(pool.splice(i, 1)[0]); - } - for (let i = 0; picked.length < count; i++) picked.push(picked[i]); // tiny tables: repeat - return picked.map(({ a, b }) => [a, b]); - } - - // ---------------------------------------------------------------- achievements - - // check(ctx) runs on events: 'answer', 'game', 'checkin', 'buy', 'practice', 'class', 'pair', 'tournament', 'cheat'. - // Answer events also carry the player's facts and their table size (6, 8 or 12). - const GENERAL_ACHIEVEMENTS = [ - { id: 'first_game', icon: '🎲', name: 'First game', desc: 'Finish your first game.', reward: 10, check: (c) => c.event === 'game' }, - { id: 'first_win', icon: '🏆', name: 'Winner!', desc: 'Beat the CPU in single player.', reward: 20, check: (c) => c.event === 'game' && c.wonVsCpu }, - { id: 'full_house', icon: '🧱', name: 'Full house', desc: 'Finish a game with the board completely full.', reward: 15, check: (c) => c.event === 'game' && c.full }, - { id: 'perfect', icon: '⭐', name: 'Perfect game', desc: 'Get every answer right first time in a game (at least 5).', reward: 40, check: (c) => c.event === 'game' && c.perfect }, - { id: 'hard_win', icon: '💪', name: 'Tough cookie', desc: 'Win a game on Hard.', reward: 60, check: (c) => c.event === 'game' && c.humanWon && c.difficulty === 'hard' }, - { id: 'games5', icon: '🌱', name: 'Getting started', desc: 'Finish 5 games.', reward: 15, check: (c) => c.event === 'game' && c.games >= 5 }, - { id: 'games10', icon: '📅', name: 'Regular', desc: 'Finish 10 games.', reward: 30, check: (c) => c.event === 'game' && c.games >= 10 }, - { id: 'games25', icon: '🎯', name: 'Dedicated', desc: 'Finish 25 games.', reward: 60, check: (c) => c.event === 'game' && c.games >= 25 }, - { id: 'games50', icon: '🎖️', name: 'Blockout pro', desc: 'Finish 50 games.', reward: 100, check: (c) => c.event === 'game' && c.games >= 50 }, - { id: 'games100', icon: '💯', name: 'Century', desc: 'Finish 100 games.', reward: 200, check: (c) => c.event === 'game' && c.games >= 100 }, - { id: 'games250', icon: '🏅', name: 'Blockout master', desc: 'Finish 250 games.', reward: 400, check: (c) => c.event === 'game' && c.games >= 250 }, - { id: 'games500', icon: '🌟', name: 'Blockout legend', desc: 'Finish 500 games.', reward: 800, check: (c) => c.event === 'game' && c.games >= 500 }, - { id: 'streak5', icon: '🔥', name: 'Hot streak', desc: 'Get 5 answers in a row right first time.', reward: 20, check: (c) => c.event === 'answer' && c.streak >= 5 }, - { id: 'streak10', icon: '🚀', name: 'On fire', desc: 'Get 10 answers in a row right first time.', reward: 40, check: (c) => c.event === 'answer' && c.streak >= 10 }, - { id: 'speedy', icon: '⚡', name: 'Speedy', desc: 'Answer correctly in under 3 seconds.', reward: 15, check: (c) => c.event === 'answer' && c.correct && c.ms < 3000 }, - { id: 'big_block', icon: '🟧', name: 'Big block', desc: 'Score a rectangle of 30 squares or more.', reward: 15, check: (c) => c.event === 'answer' && c.correct && c.a * c.b >= 30 }, - { id: 'twelve', icon: '🧠', name: 'Twelve times', desc: 'Answer a 12 times fact correctly.', reward: 20, check: (c) => c.event === 'answer' && c.correct && (c.a === 12 || c.b === 12) }, - { id: 'checkin3', icon: '☀️', name: 'Three days in a row', desc: 'Play three days in a row.', reward: 20, check: (c) => c.event === 'checkin' && c.streak >= 3 }, - { id: 'checkin7', icon: '🗓️', name: 'Week streak', desc: 'Play seven days in a row.', reward: 50, check: (c) => c.event === 'checkin' && c.streak >= 7 }, - { id: 'practice', icon: '🎯', name: 'Practice makes perfect', desc: 'Finish a practice round.', reward: 10, check: (c) => c.event === 'practice' }, - // "Help me count": finding it, learning from it, and managing without it - { id: 'help_first', icon: '🤝', name: 'Counting buddy', desc: 'Use “Help me count” for the first time.', reward: 5, check: (c) => c.event === 'help' }, - { id: 'help_learned', icon: '💡', name: 'Got it now!', desc: 'Get help counting a fact, then answer it right first time later on.', reward: 20, check: (c) => c.event === 'answer' && c.learned }, - { id: 'help_none', icon: '🦸', name: 'Standing on my own', desc: 'Finish a game with 5 or more answers and no help counting.', reward: 15, check: (c) => c.event === 'game' && c.noHelp }, - { id: 'shopper', icon: '🛍️', name: 'Shopper', desc: 'Buy your first unlock in the shop.', reward: 10, check: (c) => c.event === 'buy' }, - ].map((a) => ({ ...a, group: 'General' })); - - // Classroom games (everyone plays the same rolls on their own board). The end of - // a class game sends { event: 'class', classGames, rank, of, perfect, missed, rolls }. - const BIG_CLASS = 4; // placing only counts against at least this many players - const CLASS_ACHIEVEMENTS = [ - { id: 'class_first', icon: '🏫', name: 'Class act', desc: 'Finish a classroom game.', reward: 15, check: (c) => c.event === 'class' }, - { id: 'class_podium', icon: '🥉', name: 'On the podium', desc: `Finish in the top 3 of a classroom game with at least ${BIG_CLASS} players.`, reward: 30, check: (c) => c.event === 'class' && c.of >= BIG_CLASS && c.rank <= 3 }, - { id: 'class_top', icon: '🥇', name: 'Top of the class', desc: `Come first in a classroom game with at least ${BIG_CLASS} players.`, reward: 60, check: (c) => c.event === 'class' && c.of >= BIG_CLASS && c.rank === 1 }, - { id: 'class_perfect', icon: '🌟', name: 'Gold star', desc: 'Get every answer right first time in a classroom game (at least 5).', reward: 40, check: (c) => c.event === 'class' && c.perfect }, - { id: 'class_every', icon: '✋', name: 'Every roll', desc: 'Keep up with every roll in a classroom game of 10 rolls or more.', reward: 20, check: (c) => c.event === 'class' && c.rolls >= 10 && c.missed === 0 }, - { id: 'class5', icon: '📚', name: 'Class regular', desc: 'Finish 5 classroom games.', reward: 30, check: (c) => c.event === 'class' && c.classGames >= 5 }, - { id: 'class25', icon: '🎓', name: 'Class veteran', desc: 'Finish 25 classroom games.', reward: 100, check: (c) => c.event === 'class' && c.classGames >= 25 }, - // Pairs games send { event: 'pair', won, tie, vsTeacher, vsCpu, pairGames, pairWins, perfect } - { id: 'pair_first', icon: '🤝', name: 'Partners', desc: 'Finish a pairs game in class.', reward: 15, check: (c) => c.event === 'pair' }, - { id: 'pair_win', icon: '⚔️', name: 'Head to head', desc: 'Win a pairs game in class.', reward: 25, check: (c) => c.event === 'pair' && c.won }, - { id: 'pair_teacher', icon: '🍎', name: 'Beat the teacher!', desc: 'Win a pairs game against your teacher.', reward: 50, check: (c) => c.event === 'pair' && c.won && c.vsTeacher }, - { id: 'pair_perfect', icon: '💫', name: 'Flawless duel', desc: 'Get every answer right first time in a pairs game (at least 5).', reward: 40, check: (c) => c.event === 'pair' && c.perfect }, - { id: 'pair_wins5', icon: '🏅', name: 'Duel champion', desc: 'Win 5 pairs games in class.', reward: 60, check: (c) => c.event === 'pair' && c.pairWins >= 5 }, - // Tournaments send { event: 'tournament', place, of, champion, undefeated, upsets, bestStreak, tournaments, titles } - { id: 'tour_first', icon: '🎪', name: 'Tournament player', desc: 'Finish a class tournament.', reward: 15, check: (c) => c.event === 'tournament' }, - { id: 'tour_final', icon: '🥈', name: 'Finalist', desc: 'Finish in the top 2 of a tournament with at least 4 players.', reward: 40, check: (c) => c.event === 'tournament' && c.of >= 4 && c.place <= 2 }, - { id: 'tour_champion', icon: '🏆', name: 'Champion', desc: 'Win a class tournament with at least 4 players.', reward: 80, check: (c) => c.event === 'tournament' && c.of >= 4 && c.champion }, - { id: 'tour_undefeated', icon: '🛡️', name: 'Undefeated', desc: 'Win every game you play in a tournament (at least 2).', reward: 60, check: (c) => c.event === 'tournament' && c.undefeated && c.of >= 3 }, - { id: 'tour_giant', icon: '🪨', name: 'Giant slayer', desc: 'In a tournament, beat someone with a better record than you.', reward: 30, check: (c) => c.event === 'tournament' && c.upsets > 0 }, - { id: 'tour_king', icon: '👑', name: 'King of the hill', desc: 'Win 3 tournament games in a row.', reward: 30, check: (c) => c.event === 'tournament' && c.bestStreak >= 3 }, - { id: 'tour_titles3', icon: '🎖️', name: 'Triple crown', desc: 'Win 3 class tournaments.', reward: 150, check: (c) => c.event === 'tournament' && c.titles >= 3 }, - ].map((a) => ({ ...a, group: 'Classroom' })); - - // One pair per times table: every fact in the line at least Learning, then all Mastered. - const TABLE_ACHIEVEMENTS = []; - for (let n = 1; n <= 12; n++) { - const inReach = (c) => c.event === 'answer' && c.facts && n <= c.size; - TABLE_ACHIEVEMENTS.push( - { - id: `table${n}_learn`, - icon: '📗', - name: `×${n} learner`, - desc: `Every ${n} times fact up to your table size is Learning or better.`, - reward: 15, - group: 'Times tables', - check: (c) => inReach(c) && lineReached(c.facts, n, c.size, 'learning'), - }, - { - id: `table${n}_master`, - icon: '🏅', - name: `×${n} master`, - desc: `Every ${n} times fact up to your table size is Mastered.`, - reward: 30, - group: 'Times tables', - check: (c) => inReach(c) && lineReached(c.facts, n, c.size, 'mastered'), - } - ); - } - - // Best score in one game (squares + bonus), by any human player. Several tiers. - const SCORE_TIERS = [ - { score: 50, icon: '🥉', name: 'Bronze score', reward: 10 }, - { score: 100, icon: '🥈', name: 'Silver score', reward: 20 }, - { score: 150, icon: '🥇', name: 'Gold score', reward: 30 }, - { score: 250, icon: '💎', name: 'Diamond score', reward: 50 }, - { score: 400, icon: '👑', name: 'Champion score', reward: 100 }, - ]; - const SCORE_ACHIEVEMENTS = SCORE_TIERS.map((t) => ({ - id: `score${t.score}`, - icon: t.icon, - name: t.name, - desc: `Score ${t.score} points or more in one game.`, - reward: t.reward, - group: 'Scores', - check: (c) => c.event === 'game' && c.bestScore >= t.score, - })); - - // Fact speed: for every fact 1 × 1 … 12 × 12 (each order), three tiers once it's - // Mastered: best time under 10 s, 5 s and 1 s. 432 achievements, shown as a grid. - // `minor`: celebrated with a toast only, no full-screen confetti. - const FACT_SPEED_TIERS = [ - { ms: 10000, secs: 10, icon: '🥉', reward: 2 }, - { ms: 5000, secs: 5, icon: '🥈', reward: 5 }, - { ms: 1000, secs: 1, icon: '🥇', reward: 10 }, - ]; - const FACT_SPEED_ACHIEVEMENTS = []; - for (let a = 1; a <= 12; a++) { - for (let b = 1; b <= 12; b++) { - for (const t of FACT_SPEED_TIERS) { - FACT_SPEED_ACHIEVEMENTS.push({ - id: `fact_${a}x${b}_${t.secs}`, - icon: t.icon, - name: `${a} × ${b} under ${t.secs} s`, - desc: `Master ${a} × ${b} with a best time under ${t.secs} seconds.`, - reward: t.reward, - group: 'Fact speed', - minor: true, - fact: [a, b], - tier: t.secs, - check: (c) => { - if (c.event !== 'answer' || c.a !== a || c.b !== b || !c.facts) return false; - const f = c.facts[factKey(a, b)]; - return factStatus(f) === 'mastered' && factBestMs(f) !== null && factBestMs(f) < t.ms; - }, - }); - } - } - } - - // Secret achievements: shown as "???" until earned. One for the first cheat - // code, and one for each code. - const SECRET_ACHIEVEMENTS = [ - { id: 'cheater', icon: '🕹️', name: 'Cheater', desc: 'Found a secret cheat code. Shh!', reward: 1, check: (c) => c.event === 'cheat' }, - { id: 'cheat_iddqd', icon: '😈', name: 'id Software', desc: 'IDDQD: the god mode code from Doom (id Software, 1993).', reward: 1, check: (c) => c.event === 'cheat' && c.code === 'iddqd' }, - { id: 'cheat_fivemoreminutesmom', icon: '🛏️', name: 'ETA 5 MINUTES TO BEDTIME BOYS', desc: 'Every timer is 5 minutes. Just five more minutes, Mom!', reward: 1, check: (c) => c.event === 'cheat' && c.code === 'fivemoreminutesmom' }, - ].map((a) => ({ ...a, group: 'Secrets', hidden: true })); - - const ACHIEVEMENTS = [...GENERAL_ACHIEVEMENTS, ...CLASS_ACHIEVEMENTS, ...SCORE_ACHIEVEMENTS, ...TABLE_ACHIEVEMENTS, ...FACT_SPEED_ACHIEVEMENTS, ...SECRET_ACHIEVEMENTS]; - - // Award every achievement this event completes; returns the new ones. - function awardAchievements(state, ctx) { - const earned = []; - for (const a of ACHIEVEMENTS) { - if (state.achievements[a.id] || !a.check(ctx)) continue; - state.achievements[a.id] = Date.now(); - addPoints(state, a.reward); - earned.push(a); - } - return earned; - } - - // "Help me count" was used on a × b. - function noteHelp(state, a, b) { - state.helpedFacts[factKey(a, b)] = true; - } - - // Answering a × b: was it helped before, and now right first time? (Clears it.) - function learnedAfterHelp(state, a, b, firstTry) { - const key = factKey(a, b); - if (!firstTry || !state.helpedFacts[key]) return false; - delete state.helpedFacts[key]; - return true; - } - - const api = { - createProgress, - normalize, - addPoints, - BONUS, - answerBonus, - TIMER_BONUS_MAX, - timerBonus, - practiceTimerId, - practiceCountId, - practiceExpertId, - practiceSet, - CHEAT_CODES, - cheatOn, - cheatOff, - activeCheats, - timerLength, - DIFFICULTIES, - DIFFICULTY_NAMES: TIMER_DIFFICULTY_NAMES, - gameExpertId, - gameTimerId, - PRACTICE_FINISH_BONUS, - BOARD_FINISH_BONUS, - boardFinishBonus, - practiceFinishBonus, - SHOP, - shopItem, - isUnlocked, - canBuy, - nextInChain, - isHidden, - hasWonOn, - recordBoardWin, - tableReady, - noteHelp, - learnedAfterHelp, - MASTER_SHARE, - buy, - dayKey, - checkIn, - factKey, - playerKey, - recordAnswer, - factStatus, - factBestMs, - lineReached, - PRACTICE_WEIGHTS, - pickPracticeFacts, - SCORE_TIERS, - FACT_SPEED_TIERS, - ACHIEVEMENTS, - awardAchievements, - }; - - if (typeof module !== 'undefined' && module.exports) module.exports = api; - else root.BlockoutProgress = api; -})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/vendor/qrcode.js b/js/vendor/qrcode.js deleted file mode 100644 index 76889b5..0000000 --- a/js/vendor/qrcode.js +++ /dev/null @@ -1,2297 +0,0 @@ -//--------------------------------------------------------------------- -// -// QR Code Generator for JavaScript -// -// Copyright (c) 2009 Kazuhiko Arase -// -// URL: http://www.d-project.com/ -// -// Licensed under the MIT license: -// http://www.opensource.org/licenses/mit-license.php -// -// The word 'QR Code' is registered trademark of -// DENSO WAVE INCORPORATED -// http://www.denso-wave.com/qrcode/faqpatent-e.html -// -//--------------------------------------------------------------------- - -var qrcode = function() { - - //--------------------------------------------------------------------- - // qrcode - //--------------------------------------------------------------------- - - /** - * qrcode - * @param typeNumber 1 to 40 - * @param errorCorrectionLevel 'L','M','Q','H' - */ - var qrcode = function(typeNumber, errorCorrectionLevel) { - - var PAD0 = 0xEC; - var PAD1 = 0x11; - - var _typeNumber = typeNumber; - var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel]; - var _modules = null; - var _moduleCount = 0; - var _dataCache = null; - var _dataList = []; - - var _this = {}; - - var makeImpl = function(test, maskPattern) { - - _moduleCount = _typeNumber * 4 + 17; - _modules = function(moduleCount) { - var modules = new Array(moduleCount); - for (var row = 0; row < moduleCount; row += 1) { - modules[row] = new Array(moduleCount); - for (var col = 0; col < moduleCount; col += 1) { - modules[row][col] = null; - } - } - return modules; - }(_moduleCount); - - setupPositionProbePattern(0, 0); - setupPositionProbePattern(_moduleCount - 7, 0); - setupPositionProbePattern(0, _moduleCount - 7); - setupPositionAdjustPattern(); - setupTimingPattern(); - setupTypeInfo(test, maskPattern); - - if (_typeNumber >= 7) { - setupTypeNumber(test); - } - - if (_dataCache == null) { - _dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList); - } - - mapData(_dataCache, maskPattern); - }; - - var setupPositionProbePattern = function(row, col) { - - for (var r = -1; r <= 7; r += 1) { - - if (row + r <= -1 || _moduleCount <= row + r) continue; - - for (var c = -1; c <= 7; c += 1) { - - if (col + c <= -1 || _moduleCount <= col + c) continue; - - if ( (0 <= r && r <= 6 && (c == 0 || c == 6) ) - || (0 <= c && c <= 6 && (r == 0 || r == 6) ) - || (2 <= r && r <= 4 && 2 <= c && c <= 4) ) { - _modules[row + r][col + c] = true; - } else { - _modules[row + r][col + c] = false; - } - } - } - }; - - var getBestMaskPattern = function() { - - var minLostPoint = 0; - var pattern = 0; - - for (var i = 0; i < 8; i += 1) { - - makeImpl(true, i); - - var lostPoint = QRUtil.getLostPoint(_this); - - if (i == 0 || minLostPoint > lostPoint) { - minLostPoint = lostPoint; - pattern = i; - } - } - - return pattern; - }; - - var setupTimingPattern = function() { - - for (var r = 8; r < _moduleCount - 8; r += 1) { - if (_modules[r][6] != null) { - continue; - } - _modules[r][6] = (r % 2 == 0); - } - - for (var c = 8; c < _moduleCount - 8; c += 1) { - if (_modules[6][c] != null) { - continue; - } - _modules[6][c] = (c % 2 == 0); - } - }; - - var setupPositionAdjustPattern = function() { - - var pos = QRUtil.getPatternPosition(_typeNumber); - - for (var i = 0; i < pos.length; i += 1) { - - for (var j = 0; j < pos.length; j += 1) { - - var row = pos[i]; - var col = pos[j]; - - if (_modules[row][col] != null) { - continue; - } - - for (var r = -2; r <= 2; r += 1) { - - for (var c = -2; c <= 2; c += 1) { - - if (r == -2 || r == 2 || c == -2 || c == 2 - || (r == 0 && c == 0) ) { - _modules[row + r][col + c] = true; - } else { - _modules[row + r][col + c] = false; - } - } - } - } - } - }; - - var setupTypeNumber = function(test) { - - var bits = QRUtil.getBCHTypeNumber(_typeNumber); - - for (var i = 0; i < 18; i += 1) { - var mod = (!test && ( (bits >> i) & 1) == 1); - _modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod; - } - - for (var i = 0; i < 18; i += 1) { - var mod = (!test && ( (bits >> i) & 1) == 1); - _modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod; - } - }; - - var setupTypeInfo = function(test, maskPattern) { - - var data = (_errorCorrectionLevel << 3) | maskPattern; - var bits = QRUtil.getBCHTypeInfo(data); - - // vertical - for (var i = 0; i < 15; i += 1) { - - var mod = (!test && ( (bits >> i) & 1) == 1); - - if (i < 6) { - _modules[i][8] = mod; - } else if (i < 8) { - _modules[i + 1][8] = mod; - } else { - _modules[_moduleCount - 15 + i][8] = mod; - } - } - - // horizontal - for (var i = 0; i < 15; i += 1) { - - var mod = (!test && ( (bits >> i) & 1) == 1); - - if (i < 8) { - _modules[8][_moduleCount - i - 1] = mod; - } else if (i < 9) { - _modules[8][15 - i - 1 + 1] = mod; - } else { - _modules[8][15 - i - 1] = mod; - } - } - - // fixed module - _modules[_moduleCount - 8][8] = (!test); - }; - - var mapData = function(data, maskPattern) { - - var inc = -1; - var row = _moduleCount - 1; - var bitIndex = 7; - var byteIndex = 0; - var maskFunc = QRUtil.getMaskFunction(maskPattern); - - for (var col = _moduleCount - 1; col > 0; col -= 2) { - - if (col == 6) col -= 1; - - while (true) { - - for (var c = 0; c < 2; c += 1) { - - if (_modules[row][col - c] == null) { - - var dark = false; - - if (byteIndex < data.length) { - dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1); - } - - var mask = maskFunc(row, col - c); - - if (mask) { - dark = !dark; - } - - _modules[row][col - c] = dark; - bitIndex -= 1; - - if (bitIndex == -1) { - byteIndex += 1; - bitIndex = 7; - } - } - } - - row += inc; - - if (row < 0 || _moduleCount <= row) { - row -= inc; - inc = -inc; - break; - } - } - } - }; - - var createBytes = function(buffer, rsBlocks) { - - var offset = 0; - - var maxDcCount = 0; - var maxEcCount = 0; - - var dcdata = new Array(rsBlocks.length); - var ecdata = new Array(rsBlocks.length); - - for (var r = 0; r < rsBlocks.length; r += 1) { - - var dcCount = rsBlocks[r].dataCount; - var ecCount = rsBlocks[r].totalCount - dcCount; - - maxDcCount = Math.max(maxDcCount, dcCount); - maxEcCount = Math.max(maxEcCount, ecCount); - - dcdata[r] = new Array(dcCount); - - for (var i = 0; i < dcdata[r].length; i += 1) { - dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset]; - } - offset += dcCount; - - var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount); - var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1); - - var modPoly = rawPoly.mod(rsPoly); - ecdata[r] = new Array(rsPoly.getLength() - 1); - for (var i = 0; i < ecdata[r].length; i += 1) { - var modIndex = i + modPoly.getLength() - ecdata[r].length; - ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0; - } - } - - var totalCodeCount = 0; - for (var i = 0; i < rsBlocks.length; i += 1) { - totalCodeCount += rsBlocks[i].totalCount; - } - - var data = new Array(totalCodeCount); - var index = 0; - - for (var i = 0; i < maxDcCount; i += 1) { - for (var r = 0; r < rsBlocks.length; r += 1) { - if (i < dcdata[r].length) { - data[index] = dcdata[r][i]; - index += 1; - } - } - } - - for (var i = 0; i < maxEcCount; i += 1) { - for (var r = 0; r < rsBlocks.length; r += 1) { - if (i < ecdata[r].length) { - data[index] = ecdata[r][i]; - index += 1; - } - } - } - - return data; - }; - - var createData = function(typeNumber, errorCorrectionLevel, dataList) { - - var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel); - - var buffer = qrBitBuffer(); - - for (var i = 0; i < dataList.length; i += 1) { - var data = dataList[i]; - buffer.put(data.getMode(), 4); - buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); - data.write(buffer); - } - - // calc num max data. - var totalDataCount = 0; - for (var i = 0; i < rsBlocks.length; i += 1) { - totalDataCount += rsBlocks[i].dataCount; - } - - if (buffer.getLengthInBits() > totalDataCount * 8) { - throw 'code length overflow. (' - + buffer.getLengthInBits() - + '>' - + totalDataCount * 8 - + ')'; - } - - // end code - if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) { - buffer.put(0, 4); - } - - // padding - while (buffer.getLengthInBits() % 8 != 0) { - buffer.putBit(false); - } - - // padding - while (true) { - - if (buffer.getLengthInBits() >= totalDataCount * 8) { - break; - } - buffer.put(PAD0, 8); - - if (buffer.getLengthInBits() >= totalDataCount * 8) { - break; - } - buffer.put(PAD1, 8); - } - - return createBytes(buffer, rsBlocks); - }; - - _this.addData = function(data, mode) { - - mode = mode || 'Byte'; - - var newData = null; - - switch(mode) { - case 'Numeric' : - newData = qrNumber(data); - break; - case 'Alphanumeric' : - newData = qrAlphaNum(data); - break; - case 'Byte' : - newData = qr8BitByte(data); - break; - case 'Kanji' : - newData = qrKanji(data); - break; - default : - throw 'mode:' + mode; - } - - _dataList.push(newData); - _dataCache = null; - }; - - _this.isDark = function(row, col) { - if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) { - throw row + ',' + col; - } - return _modules[row][col]; - }; - - _this.getModuleCount = function() { - return _moduleCount; - }; - - _this.make = function() { - if (_typeNumber < 1) { - var typeNumber = 1; - - for (; typeNumber < 40; typeNumber++) { - var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel); - var buffer = qrBitBuffer(); - - for (var i = 0; i < _dataList.length; i++) { - var data = _dataList[i]; - buffer.put(data.getMode(), 4); - buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) ); - data.write(buffer); - } - - var totalDataCount = 0; - for (var i = 0; i < rsBlocks.length; i++) { - totalDataCount += rsBlocks[i].dataCount; - } - - if (buffer.getLengthInBits() <= totalDataCount * 8) { - break; - } - } - - _typeNumber = typeNumber; - } - - makeImpl(false, getBestMaskPattern() ); - }; - - _this.createTableTag = function(cellSize, margin) { - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - var qrHtml = ''; - - qrHtml += '<table style="'; - qrHtml += ' border-width: 0px; border-style: none;'; - qrHtml += ' border-collapse: collapse;'; - qrHtml += ' padding: 0px; margin: ' + margin + 'px;'; - qrHtml += '">'; - qrHtml += '<tbody>'; - - for (var r = 0; r < _this.getModuleCount(); r += 1) { - - qrHtml += '<tr>'; - - for (var c = 0; c < _this.getModuleCount(); c += 1) { - qrHtml += '<td style="'; - qrHtml += ' border-width: 0px; border-style: none;'; - qrHtml += ' border-collapse: collapse;'; - qrHtml += ' padding: 0px; margin: 0px;'; - qrHtml += ' width: ' + cellSize + 'px;'; - qrHtml += ' height: ' + cellSize + 'px;'; - qrHtml += ' background-color: '; - qrHtml += _this.isDark(r, c)? '#000000' : '#ffffff'; - qrHtml += ';'; - qrHtml += '"/>'; - } - - qrHtml += '</tr>'; - } - - qrHtml += '</tbody>'; - qrHtml += '</table>'; - - return qrHtml; - }; - - _this.createSvgTag = function(cellSize, margin, alt, title) { - - var opts = {}; - if (typeof arguments[0] == 'object') { - // Called by options. - opts = arguments[0]; - // overwrite cellSize and margin. - cellSize = opts.cellSize; - margin = opts.margin; - alt = opts.alt; - title = opts.title; - } - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - // Compose alt property surrogate - alt = (typeof alt === 'string') ? {text: alt} : alt || {}; - alt.text = alt.text || null; - alt.id = (alt.text) ? alt.id || 'qrcode-description' : null; - - // Compose title property surrogate - title = (typeof title === 'string') ? {text: title} : title || {}; - title.text = title.text || null; - title.id = (title.text) ? title.id || 'qrcode-title' : null; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var c, mc, r, mr, qrSvg='', rect; - - rect = 'l' + cellSize + ',0 0,' + cellSize + - ' -' + cellSize + ',0 0,-' + cellSize + 'z '; - - qrSvg += '<svg version="1.1" xmlns="http://www.w3.org/2000/svg"'; - qrSvg += !opts.scalable ? ' width="' + size + 'px" height="' + size + 'px"' : ''; - qrSvg += ' viewBox="0 0 ' + size + ' ' + size + '" '; - qrSvg += ' preserveAspectRatio="xMinYMin meet"'; - qrSvg += (title.text || alt.text) ? ' role="img" aria-labelledby="' + - escapeXml([title.id, alt.id].join(' ').trim() ) + '"' : ''; - qrSvg += '>'; - qrSvg += (title.text) ? '<title id="' + escapeXml(title.id) + '">' + - escapeXml(title.text) + '' : ''; - qrSvg += (alt.text) ? '' + - escapeXml(alt.text) + '' : ''; - qrSvg += ''; - qrSvg += ''; - qrSvg += ''; - - return qrSvg; - }; - - _this.createDataURL = function(cellSize, margin) { - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var min = margin; - var max = size - margin; - - return createDataURL(size, size, function(x, y) { - if (min <= x && x < max && min <= y && y < max) { - var c = Math.floor( (x - min) / cellSize); - var r = Math.floor( (y - min) / cellSize); - return _this.isDark(r, c)? 0 : 1; - } else { - return 1; - } - } ); - }; - - _this.createImgTag = function(cellSize, margin, alt) { - - cellSize = cellSize || 2; - margin = (typeof margin == 'undefined')? cellSize * 4 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - - var img = ''; - img += '': escaped += '>'; break; - case '&': escaped += '&'; break; - case '"': escaped += '"'; break; - default : escaped += c; break; - } - } - return escaped; - }; - - var _createHalfASCII = function(margin) { - var cellSize = 1; - margin = (typeof margin == 'undefined')? cellSize * 2 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var min = margin; - var max = size - margin; - - var y, x, r1, r2, p; - - var blocks = { - '██': '█', - '█ ': '▀', - ' █': '▄', - ' ': ' ' - }; - - var blocksLastLineNoMargin = { - '██': '▀', - '█ ': '▀', - ' █': ' ', - ' ': ' ' - }; - - var ascii = ''; - for (y = 0; y < size; y += 2) { - r1 = Math.floor((y - min) / cellSize); - r2 = Math.floor((y + 1 - min) / cellSize); - for (x = 0; x < size; x += 1) { - p = '█'; - - if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) { - p = ' '; - } - - if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) { - p += ' '; - } - else { - p += '█'; - } - - // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. - ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p]; - } - - ascii += '\n'; - } - - if (size % 2 && margin > 0) { - return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀'); - } - - return ascii.substring(0, ascii.length-1); - }; - - _this.createASCII = function(cellSize, margin) { - cellSize = cellSize || 1; - - if (cellSize < 2) { - return _createHalfASCII(margin); - } - - cellSize -= 1; - margin = (typeof margin == 'undefined')? cellSize * 2 : margin; - - var size = _this.getModuleCount() * cellSize + margin * 2; - var min = margin; - var max = size - margin; - - var y, x, r, p; - - var white = Array(cellSize+1).join('██'); - var black = Array(cellSize+1).join(' '); - - var ascii = ''; - var line = ''; - for (y = 0; y < size; y += 1) { - r = Math.floor( (y - min) / cellSize); - line = ''; - for (x = 0; x < size; x += 1) { - p = 1; - - if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) { - p = 0; - } - - // Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square. - line += p ? white : black; - } - - for (r = 0; r < cellSize; r += 1) { - ascii += line + '\n'; - } - } - - return ascii.substring(0, ascii.length-1); - }; - - _this.renderTo2dContext = function(context, cellSize) { - cellSize = cellSize || 2; - var length = _this.getModuleCount(); - for (var row = 0; row < length; row++) { - for (var col = 0; col < length; col++) { - context.fillStyle = _this.isDark(row, col) ? 'black' : 'white'; - context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize); - } - } - } - - return _this; - }; - - //--------------------------------------------------------------------- - // qrcode.stringToBytes - //--------------------------------------------------------------------- - - qrcode.stringToBytesFuncs = { - 'default' : function(s) { - var bytes = []; - for (var i = 0; i < s.length; i += 1) { - var c = s.charCodeAt(i); - bytes.push(c & 0xff); - } - return bytes; - } - }; - - qrcode.stringToBytes = qrcode.stringToBytesFuncs['default']; - - //--------------------------------------------------------------------- - // qrcode.createStringToBytes - //--------------------------------------------------------------------- - - /** - * @param unicodeData base64 string of byte array. - * [16bit Unicode],[16bit Bytes], ... - * @param numChars - */ - qrcode.createStringToBytes = function(unicodeData, numChars) { - - // create conversion map. - - var unicodeMap = function() { - - var bin = base64DecodeInputStream(unicodeData); - var read = function() { - var b = bin.read(); - if (b == -1) throw 'eof'; - return b; - }; - - var count = 0; - var unicodeMap = {}; - while (true) { - var b0 = bin.read(); - if (b0 == -1) break; - var b1 = read(); - var b2 = read(); - var b3 = read(); - var k = String.fromCharCode( (b0 << 8) | b1); - var v = (b2 << 8) | b3; - unicodeMap[k] = v; - count += 1; - } - if (count != numChars) { - throw count + ' != ' + numChars; - } - - return unicodeMap; - }(); - - var unknownChar = '?'.charCodeAt(0); - - return function(s) { - var bytes = []; - for (var i = 0; i < s.length; i += 1) { - var c = s.charCodeAt(i); - if (c < 128) { - bytes.push(c); - } else { - var b = unicodeMap[s.charAt(i)]; - if (typeof b == 'number') { - if ( (b & 0xff) == b) { - // 1byte - bytes.push(b); - } else { - // 2bytes - bytes.push(b >>> 8); - bytes.push(b & 0xff); - } - } else { - bytes.push(unknownChar); - } - } - } - return bytes; - }; - }; - - //--------------------------------------------------------------------- - // QRMode - //--------------------------------------------------------------------- - - var QRMode = { - MODE_NUMBER : 1 << 0, - MODE_ALPHA_NUM : 1 << 1, - MODE_8BIT_BYTE : 1 << 2, - MODE_KANJI : 1 << 3 - }; - - //--------------------------------------------------------------------- - // QRErrorCorrectionLevel - //--------------------------------------------------------------------- - - var QRErrorCorrectionLevel = { - L : 1, - M : 0, - Q : 3, - H : 2 - }; - - //--------------------------------------------------------------------- - // QRMaskPattern - //--------------------------------------------------------------------- - - var QRMaskPattern = { - PATTERN000 : 0, - PATTERN001 : 1, - PATTERN010 : 2, - PATTERN011 : 3, - PATTERN100 : 4, - PATTERN101 : 5, - PATTERN110 : 6, - PATTERN111 : 7 - }; - - //--------------------------------------------------------------------- - // QRUtil - //--------------------------------------------------------------------- - - var QRUtil = function() { - - var PATTERN_POSITION_TABLE = [ - [], - [6, 18], - [6, 22], - [6, 26], - [6, 30], - [6, 34], - [6, 22, 38], - [6, 24, 42], - [6, 26, 46], - [6, 28, 50], - [6, 30, 54], - [6, 32, 58], - [6, 34, 62], - [6, 26, 46, 66], - [6, 26, 48, 70], - [6, 26, 50, 74], - [6, 30, 54, 78], - [6, 30, 56, 82], - [6, 30, 58, 86], - [6, 34, 62, 90], - [6, 28, 50, 72, 94], - [6, 26, 50, 74, 98], - [6, 30, 54, 78, 102], - [6, 28, 54, 80, 106], - [6, 32, 58, 84, 110], - [6, 30, 58, 86, 114], - [6, 34, 62, 90, 118], - [6, 26, 50, 74, 98, 122], - [6, 30, 54, 78, 102, 126], - [6, 26, 52, 78, 104, 130], - [6, 30, 56, 82, 108, 134], - [6, 34, 60, 86, 112, 138], - [6, 30, 58, 86, 114, 142], - [6, 34, 62, 90, 118, 146], - [6, 30, 54, 78, 102, 126, 150], - [6, 24, 50, 76, 102, 128, 154], - [6, 28, 54, 80, 106, 132, 158], - [6, 32, 58, 84, 110, 136, 162], - [6, 26, 54, 82, 110, 138, 166], - [6, 30, 58, 86, 114, 142, 170] - ]; - var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0); - var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0); - var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1); - - var _this = {}; - - var getBCHDigit = function(data) { - var digit = 0; - while (data != 0) { - digit += 1; - data >>>= 1; - } - return digit; - }; - - _this.getBCHTypeInfo = function(data) { - var d = data << 10; - while (getBCHDigit(d) - getBCHDigit(G15) >= 0) { - d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) ); - } - return ( (data << 10) | d) ^ G15_MASK; - }; - - _this.getBCHTypeNumber = function(data) { - var d = data << 12; - while (getBCHDigit(d) - getBCHDigit(G18) >= 0) { - d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) ); - } - return (data << 12) | d; - }; - - _this.getPatternPosition = function(typeNumber) { - return PATTERN_POSITION_TABLE[typeNumber - 1]; - }; - - _this.getMaskFunction = function(maskPattern) { - - switch (maskPattern) { - - case QRMaskPattern.PATTERN000 : - return function(i, j) { return (i + j) % 2 == 0; }; - case QRMaskPattern.PATTERN001 : - return function(i, j) { return i % 2 == 0; }; - case QRMaskPattern.PATTERN010 : - return function(i, j) { return j % 3 == 0; }; - case QRMaskPattern.PATTERN011 : - return function(i, j) { return (i + j) % 3 == 0; }; - case QRMaskPattern.PATTERN100 : - return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; }; - case QRMaskPattern.PATTERN101 : - return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; }; - case QRMaskPattern.PATTERN110 : - return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; }; - case QRMaskPattern.PATTERN111 : - return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; }; - - default : - throw 'bad maskPattern:' + maskPattern; - } - }; - - _this.getErrorCorrectPolynomial = function(errorCorrectLength) { - var a = qrPolynomial([1], 0); - for (var i = 0; i < errorCorrectLength; i += 1) { - a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) ); - } - return a; - }; - - _this.getLengthInBits = function(mode, type) { - - if (1 <= type && type < 10) { - - // 1 - 9 - - switch(mode) { - case QRMode.MODE_NUMBER : return 10; - case QRMode.MODE_ALPHA_NUM : return 9; - case QRMode.MODE_8BIT_BYTE : return 8; - case QRMode.MODE_KANJI : return 8; - default : - throw 'mode:' + mode; - } - - } else if (type < 27) { - - // 10 - 26 - - switch(mode) { - case QRMode.MODE_NUMBER : return 12; - case QRMode.MODE_ALPHA_NUM : return 11; - case QRMode.MODE_8BIT_BYTE : return 16; - case QRMode.MODE_KANJI : return 10; - default : - throw 'mode:' + mode; - } - - } else if (type < 41) { - - // 27 - 40 - - switch(mode) { - case QRMode.MODE_NUMBER : return 14; - case QRMode.MODE_ALPHA_NUM : return 13; - case QRMode.MODE_8BIT_BYTE : return 16; - case QRMode.MODE_KANJI : return 12; - default : - throw 'mode:' + mode; - } - - } else { - throw 'type:' + type; - } - }; - - _this.getLostPoint = function(qrcode) { - - var moduleCount = qrcode.getModuleCount(); - - var lostPoint = 0; - - // LEVEL1 - - for (var row = 0; row < moduleCount; row += 1) { - for (var col = 0; col < moduleCount; col += 1) { - - var sameCount = 0; - var dark = qrcode.isDark(row, col); - - for (var r = -1; r <= 1; r += 1) { - - if (row + r < 0 || moduleCount <= row + r) { - continue; - } - - for (var c = -1; c <= 1; c += 1) { - - if (col + c < 0 || moduleCount <= col + c) { - continue; - } - - if (r == 0 && c == 0) { - continue; - } - - if (dark == qrcode.isDark(row + r, col + c) ) { - sameCount += 1; - } - } - } - - if (sameCount > 5) { - lostPoint += (3 + sameCount - 5); - } - } - }; - - // LEVEL2 - - for (var row = 0; row < moduleCount - 1; row += 1) { - for (var col = 0; col < moduleCount - 1; col += 1) { - var count = 0; - if (qrcode.isDark(row, col) ) count += 1; - if (qrcode.isDark(row + 1, col) ) count += 1; - if (qrcode.isDark(row, col + 1) ) count += 1; - if (qrcode.isDark(row + 1, col + 1) ) count += 1; - if (count == 0 || count == 4) { - lostPoint += 3; - } - } - } - - // LEVEL3 - - for (var row = 0; row < moduleCount; row += 1) { - for (var col = 0; col < moduleCount - 6; col += 1) { - if (qrcode.isDark(row, col) - && !qrcode.isDark(row, col + 1) - && qrcode.isDark(row, col + 2) - && qrcode.isDark(row, col + 3) - && qrcode.isDark(row, col + 4) - && !qrcode.isDark(row, col + 5) - && qrcode.isDark(row, col + 6) ) { - lostPoint += 40; - } - } - } - - for (var col = 0; col < moduleCount; col += 1) { - for (var row = 0; row < moduleCount - 6; row += 1) { - if (qrcode.isDark(row, col) - && !qrcode.isDark(row + 1, col) - && qrcode.isDark(row + 2, col) - && qrcode.isDark(row + 3, col) - && qrcode.isDark(row + 4, col) - && !qrcode.isDark(row + 5, col) - && qrcode.isDark(row + 6, col) ) { - lostPoint += 40; - } - } - } - - // LEVEL4 - - var darkCount = 0; - - for (var col = 0; col < moduleCount; col += 1) { - for (var row = 0; row < moduleCount; row += 1) { - if (qrcode.isDark(row, col) ) { - darkCount += 1; - } - } - } - - var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5; - lostPoint += ratio * 10; - - return lostPoint; - }; - - return _this; - }(); - - //--------------------------------------------------------------------- - // QRMath - //--------------------------------------------------------------------- - - var QRMath = function() { - - var EXP_TABLE = new Array(256); - var LOG_TABLE = new Array(256); - - // initialize tables - for (var i = 0; i < 8; i += 1) { - EXP_TABLE[i] = 1 << i; - } - for (var i = 8; i < 256; i += 1) { - EXP_TABLE[i] = EXP_TABLE[i - 4] - ^ EXP_TABLE[i - 5] - ^ EXP_TABLE[i - 6] - ^ EXP_TABLE[i - 8]; - } - for (var i = 0; i < 255; i += 1) { - LOG_TABLE[EXP_TABLE[i] ] = i; - } - - var _this = {}; - - _this.glog = function(n) { - - if (n < 1) { - throw 'glog(' + n + ')'; - } - - return LOG_TABLE[n]; - }; - - _this.gexp = function(n) { - - while (n < 0) { - n += 255; - } - - while (n >= 256) { - n -= 255; - } - - return EXP_TABLE[n]; - }; - - return _this; - }(); - - //--------------------------------------------------------------------- - // qrPolynomial - //--------------------------------------------------------------------- - - function qrPolynomial(num, shift) { - - if (typeof num.length == 'undefined') { - throw num.length + '/' + shift; - } - - var _num = function() { - var offset = 0; - while (offset < num.length && num[offset] == 0) { - offset += 1; - } - var _num = new Array(num.length - offset + shift); - for (var i = 0; i < num.length - offset; i += 1) { - _num[i] = num[i + offset]; - } - return _num; - }(); - - var _this = {}; - - _this.getAt = function(index) { - return _num[index]; - }; - - _this.getLength = function() { - return _num.length; - }; - - _this.multiply = function(e) { - - var num = new Array(_this.getLength() + e.getLength() - 1); - - for (var i = 0; i < _this.getLength(); i += 1) { - for (var j = 0; j < e.getLength(); j += 1) { - num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) ); - } - } - - return qrPolynomial(num, 0); - }; - - _this.mod = function(e) { - - if (_this.getLength() - e.getLength() < 0) { - return _this; - } - - var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) ); - - var num = new Array(_this.getLength() ); - for (var i = 0; i < _this.getLength(); i += 1) { - num[i] = _this.getAt(i); - } - - for (var i = 0; i < e.getLength(); i += 1) { - num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio); - } - - // recursive call - return qrPolynomial(num, 0).mod(e); - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // QRRSBlock - //--------------------------------------------------------------------- - - var QRRSBlock = function() { - - var RS_BLOCK_TABLE = [ - - // L - // M - // Q - // H - - // 1 - [1, 26, 19], - [1, 26, 16], - [1, 26, 13], - [1, 26, 9], - - // 2 - [1, 44, 34], - [1, 44, 28], - [1, 44, 22], - [1, 44, 16], - - // 3 - [1, 70, 55], - [1, 70, 44], - [2, 35, 17], - [2, 35, 13], - - // 4 - [1, 100, 80], - [2, 50, 32], - [2, 50, 24], - [4, 25, 9], - - // 5 - [1, 134, 108], - [2, 67, 43], - [2, 33, 15, 2, 34, 16], - [2, 33, 11, 2, 34, 12], - - // 6 - [2, 86, 68], - [4, 43, 27], - [4, 43, 19], - [4, 43, 15], - - // 7 - [2, 98, 78], - [4, 49, 31], - [2, 32, 14, 4, 33, 15], - [4, 39, 13, 1, 40, 14], - - // 8 - [2, 121, 97], - [2, 60, 38, 2, 61, 39], - [4, 40, 18, 2, 41, 19], - [4, 40, 14, 2, 41, 15], - - // 9 - [2, 146, 116], - [3, 58, 36, 2, 59, 37], - [4, 36, 16, 4, 37, 17], - [4, 36, 12, 4, 37, 13], - - // 10 - [2, 86, 68, 2, 87, 69], - [4, 69, 43, 1, 70, 44], - [6, 43, 19, 2, 44, 20], - [6, 43, 15, 2, 44, 16], - - // 11 - [4, 101, 81], - [1, 80, 50, 4, 81, 51], - [4, 50, 22, 4, 51, 23], - [3, 36, 12, 8, 37, 13], - - // 12 - [2, 116, 92, 2, 117, 93], - [6, 58, 36, 2, 59, 37], - [4, 46, 20, 6, 47, 21], - [7, 42, 14, 4, 43, 15], - - // 13 - [4, 133, 107], - [8, 59, 37, 1, 60, 38], - [8, 44, 20, 4, 45, 21], - [12, 33, 11, 4, 34, 12], - - // 14 - [3, 145, 115, 1, 146, 116], - [4, 64, 40, 5, 65, 41], - [11, 36, 16, 5, 37, 17], - [11, 36, 12, 5, 37, 13], - - // 15 - [5, 109, 87, 1, 110, 88], - [5, 65, 41, 5, 66, 42], - [5, 54, 24, 7, 55, 25], - [11, 36, 12, 7, 37, 13], - - // 16 - [5, 122, 98, 1, 123, 99], - [7, 73, 45, 3, 74, 46], - [15, 43, 19, 2, 44, 20], - [3, 45, 15, 13, 46, 16], - - // 17 - [1, 135, 107, 5, 136, 108], - [10, 74, 46, 1, 75, 47], - [1, 50, 22, 15, 51, 23], - [2, 42, 14, 17, 43, 15], - - // 18 - [5, 150, 120, 1, 151, 121], - [9, 69, 43, 4, 70, 44], - [17, 50, 22, 1, 51, 23], - [2, 42, 14, 19, 43, 15], - - // 19 - [3, 141, 113, 4, 142, 114], - [3, 70, 44, 11, 71, 45], - [17, 47, 21, 4, 48, 22], - [9, 39, 13, 16, 40, 14], - - // 20 - [3, 135, 107, 5, 136, 108], - [3, 67, 41, 13, 68, 42], - [15, 54, 24, 5, 55, 25], - [15, 43, 15, 10, 44, 16], - - // 21 - [4, 144, 116, 4, 145, 117], - [17, 68, 42], - [17, 50, 22, 6, 51, 23], - [19, 46, 16, 6, 47, 17], - - // 22 - [2, 139, 111, 7, 140, 112], - [17, 74, 46], - [7, 54, 24, 16, 55, 25], - [34, 37, 13], - - // 23 - [4, 151, 121, 5, 152, 122], - [4, 75, 47, 14, 76, 48], - [11, 54, 24, 14, 55, 25], - [16, 45, 15, 14, 46, 16], - - // 24 - [6, 147, 117, 4, 148, 118], - [6, 73, 45, 14, 74, 46], - [11, 54, 24, 16, 55, 25], - [30, 46, 16, 2, 47, 17], - - // 25 - [8, 132, 106, 4, 133, 107], - [8, 75, 47, 13, 76, 48], - [7, 54, 24, 22, 55, 25], - [22, 45, 15, 13, 46, 16], - - // 26 - [10, 142, 114, 2, 143, 115], - [19, 74, 46, 4, 75, 47], - [28, 50, 22, 6, 51, 23], - [33, 46, 16, 4, 47, 17], - - // 27 - [8, 152, 122, 4, 153, 123], - [22, 73, 45, 3, 74, 46], - [8, 53, 23, 26, 54, 24], - [12, 45, 15, 28, 46, 16], - - // 28 - [3, 147, 117, 10, 148, 118], - [3, 73, 45, 23, 74, 46], - [4, 54, 24, 31, 55, 25], - [11, 45, 15, 31, 46, 16], - - // 29 - [7, 146, 116, 7, 147, 117], - [21, 73, 45, 7, 74, 46], - [1, 53, 23, 37, 54, 24], - [19, 45, 15, 26, 46, 16], - - // 30 - [5, 145, 115, 10, 146, 116], - [19, 75, 47, 10, 76, 48], - [15, 54, 24, 25, 55, 25], - [23, 45, 15, 25, 46, 16], - - // 31 - [13, 145, 115, 3, 146, 116], - [2, 74, 46, 29, 75, 47], - [42, 54, 24, 1, 55, 25], - [23, 45, 15, 28, 46, 16], - - // 32 - [17, 145, 115], - [10, 74, 46, 23, 75, 47], - [10, 54, 24, 35, 55, 25], - [19, 45, 15, 35, 46, 16], - - // 33 - [17, 145, 115, 1, 146, 116], - [14, 74, 46, 21, 75, 47], - [29, 54, 24, 19, 55, 25], - [11, 45, 15, 46, 46, 16], - - // 34 - [13, 145, 115, 6, 146, 116], - [14, 74, 46, 23, 75, 47], - [44, 54, 24, 7, 55, 25], - [59, 46, 16, 1, 47, 17], - - // 35 - [12, 151, 121, 7, 152, 122], - [12, 75, 47, 26, 76, 48], - [39, 54, 24, 14, 55, 25], - [22, 45, 15, 41, 46, 16], - - // 36 - [6, 151, 121, 14, 152, 122], - [6, 75, 47, 34, 76, 48], - [46, 54, 24, 10, 55, 25], - [2, 45, 15, 64, 46, 16], - - // 37 - [17, 152, 122, 4, 153, 123], - [29, 74, 46, 14, 75, 47], - [49, 54, 24, 10, 55, 25], - [24, 45, 15, 46, 46, 16], - - // 38 - [4, 152, 122, 18, 153, 123], - [13, 74, 46, 32, 75, 47], - [48, 54, 24, 14, 55, 25], - [42, 45, 15, 32, 46, 16], - - // 39 - [20, 147, 117, 4, 148, 118], - [40, 75, 47, 7, 76, 48], - [43, 54, 24, 22, 55, 25], - [10, 45, 15, 67, 46, 16], - - // 40 - [19, 148, 118, 6, 149, 119], - [18, 75, 47, 31, 76, 48], - [34, 54, 24, 34, 55, 25], - [20, 45, 15, 61, 46, 16] - ]; - - var qrRSBlock = function(totalCount, dataCount) { - var _this = {}; - _this.totalCount = totalCount; - _this.dataCount = dataCount; - return _this; - }; - - var _this = {}; - - var getRsBlockTable = function(typeNumber, errorCorrectionLevel) { - - switch(errorCorrectionLevel) { - case QRErrorCorrectionLevel.L : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0]; - case QRErrorCorrectionLevel.M : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1]; - case QRErrorCorrectionLevel.Q : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2]; - case QRErrorCorrectionLevel.H : - return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3]; - default : - return undefined; - } - }; - - _this.getRSBlocks = function(typeNumber, errorCorrectionLevel) { - - var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel); - - if (typeof rsBlock == 'undefined') { - throw 'bad rs block @ typeNumber:' + typeNumber + - '/errorCorrectionLevel:' + errorCorrectionLevel; - } - - var length = rsBlock.length / 3; - - var list = []; - - for (var i = 0; i < length; i += 1) { - - var count = rsBlock[i * 3 + 0]; - var totalCount = rsBlock[i * 3 + 1]; - var dataCount = rsBlock[i * 3 + 2]; - - for (var j = 0; j < count; j += 1) { - list.push(qrRSBlock(totalCount, dataCount) ); - } - } - - return list; - }; - - return _this; - }(); - - //--------------------------------------------------------------------- - // qrBitBuffer - //--------------------------------------------------------------------- - - var qrBitBuffer = function() { - - var _buffer = []; - var _length = 0; - - var _this = {}; - - _this.getBuffer = function() { - return _buffer; - }; - - _this.getAt = function(index) { - var bufIndex = Math.floor(index / 8); - return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1; - }; - - _this.put = function(num, length) { - for (var i = 0; i < length; i += 1) { - _this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1); - } - }; - - _this.getLengthInBits = function() { - return _length; - }; - - _this.putBit = function(bit) { - - var bufIndex = Math.floor(_length / 8); - if (_buffer.length <= bufIndex) { - _buffer.push(0); - } - - if (bit) { - _buffer[bufIndex] |= (0x80 >>> (_length % 8) ); - } - - _length += 1; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qrNumber - //--------------------------------------------------------------------- - - var qrNumber = function(data) { - - var _mode = QRMode.MODE_NUMBER; - var _data = data; - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return _data.length; - }; - - _this.write = function(buffer) { - - var data = _data; - - var i = 0; - - while (i + 2 < data.length) { - buffer.put(strToNum(data.substring(i, i + 3) ), 10); - i += 3; - } - - if (i < data.length) { - if (data.length - i == 1) { - buffer.put(strToNum(data.substring(i, i + 1) ), 4); - } else if (data.length - i == 2) { - buffer.put(strToNum(data.substring(i, i + 2) ), 7); - } - } - }; - - var strToNum = function(s) { - var num = 0; - for (var i = 0; i < s.length; i += 1) { - num = num * 10 + chatToNum(s.charAt(i) ); - } - return num; - }; - - var chatToNum = function(c) { - if ('0' <= c && c <= '9') { - return c.charCodeAt(0) - '0'.charCodeAt(0); - } - throw 'illegal char :' + c; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qrAlphaNum - //--------------------------------------------------------------------- - - var qrAlphaNum = function(data) { - - var _mode = QRMode.MODE_ALPHA_NUM; - var _data = data; - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return _data.length; - }; - - _this.write = function(buffer) { - - var s = _data; - - var i = 0; - - while (i + 1 < s.length) { - buffer.put( - getCode(s.charAt(i) ) * 45 + - getCode(s.charAt(i + 1) ), 11); - i += 2; - } - - if (i < s.length) { - buffer.put(getCode(s.charAt(i) ), 6); - } - }; - - var getCode = function(c) { - - if ('0' <= c && c <= '9') { - return c.charCodeAt(0) - '0'.charCodeAt(0); - } else if ('A' <= c && c <= 'Z') { - return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10; - } else { - switch (c) { - case ' ' : return 36; - case '$' : return 37; - case '%' : return 38; - case '*' : return 39; - case '+' : return 40; - case '-' : return 41; - case '.' : return 42; - case '/' : return 43; - case ':' : return 44; - default : - throw 'illegal char :' + c; - } - } - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qr8BitByte - //--------------------------------------------------------------------- - - var qr8BitByte = function(data) { - - var _mode = QRMode.MODE_8BIT_BYTE; - var _data = data; - var _bytes = qrcode.stringToBytes(data); - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return _bytes.length; - }; - - _this.write = function(buffer) { - for (var i = 0; i < _bytes.length; i += 1) { - buffer.put(_bytes[i], 8); - } - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // qrKanji - //--------------------------------------------------------------------- - - var qrKanji = function(data) { - - var _mode = QRMode.MODE_KANJI; - var _data = data; - - var stringToBytes = qrcode.stringToBytesFuncs['SJIS']; - if (!stringToBytes) { - throw 'sjis not supported.'; - } - !function(c, code) { - // self test for sjis support. - var test = stringToBytes(c); - if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) { - throw 'sjis not supported.'; - } - }('\u53cb', 0x9746); - - var _bytes = stringToBytes(data); - - var _this = {}; - - _this.getMode = function() { - return _mode; - }; - - _this.getLength = function(buffer) { - return ~~(_bytes.length / 2); - }; - - _this.write = function(buffer) { - - var data = _bytes; - - var i = 0; - - while (i + 1 < data.length) { - - var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]); - - if (0x8140 <= c && c <= 0x9FFC) { - c -= 0x8140; - } else if (0xE040 <= c && c <= 0xEBBF) { - c -= 0xC140; - } else { - throw 'illegal char at ' + (i + 1) + '/' + c; - } - - c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff); - - buffer.put(c, 13); - - i += 2; - } - - if (i < data.length) { - throw 'illegal char at ' + (i + 1); - } - }; - - return _this; - }; - - //===================================================================== - // GIF Support etc. - // - - //--------------------------------------------------------------------- - // byteArrayOutputStream - //--------------------------------------------------------------------- - - var byteArrayOutputStream = function() { - - var _bytes = []; - - var _this = {}; - - _this.writeByte = function(b) { - _bytes.push(b & 0xff); - }; - - _this.writeShort = function(i) { - _this.writeByte(i); - _this.writeByte(i >>> 8); - }; - - _this.writeBytes = function(b, off, len) { - off = off || 0; - len = len || b.length; - for (var i = 0; i < len; i += 1) { - _this.writeByte(b[i + off]); - } - }; - - _this.writeString = function(s) { - for (var i = 0; i < s.length; i += 1) { - _this.writeByte(s.charCodeAt(i) ); - } - }; - - _this.toByteArray = function() { - return _bytes; - }; - - _this.toString = function() { - var s = ''; - s += '['; - for (var i = 0; i < _bytes.length; i += 1) { - if (i > 0) { - s += ','; - } - s += _bytes[i]; - } - s += ']'; - return s; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // base64EncodeOutputStream - //--------------------------------------------------------------------- - - var base64EncodeOutputStream = function() { - - var _buffer = 0; - var _buflen = 0; - var _length = 0; - var _base64 = ''; - - var _this = {}; - - var writeEncoded = function(b) { - _base64 += String.fromCharCode(encode(b & 0x3f) ); - }; - - var encode = function(n) { - if (n < 0) { - // error. - } else if (n < 26) { - return 0x41 + n; - } else if (n < 52) { - return 0x61 + (n - 26); - } else if (n < 62) { - return 0x30 + (n - 52); - } else if (n == 62) { - return 0x2b; - } else if (n == 63) { - return 0x2f; - } - throw 'n:' + n; - }; - - _this.writeByte = function(n) { - - _buffer = (_buffer << 8) | (n & 0xff); - _buflen += 8; - _length += 1; - - while (_buflen >= 6) { - writeEncoded(_buffer >>> (_buflen - 6) ); - _buflen -= 6; - } - }; - - _this.flush = function() { - - if (_buflen > 0) { - writeEncoded(_buffer << (6 - _buflen) ); - _buffer = 0; - _buflen = 0; - } - - if (_length % 3 != 0) { - // padding - var padlen = 3 - _length % 3; - for (var i = 0; i < padlen; i += 1) { - _base64 += '='; - } - } - }; - - _this.toString = function() { - return _base64; - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // base64DecodeInputStream - //--------------------------------------------------------------------- - - var base64DecodeInputStream = function(str) { - - var _str = str; - var _pos = 0; - var _buffer = 0; - var _buflen = 0; - - var _this = {}; - - _this.read = function() { - - while (_buflen < 8) { - - if (_pos >= _str.length) { - if (_buflen == 0) { - return -1; - } - throw 'unexpected end of file./' + _buflen; - } - - var c = _str.charAt(_pos); - _pos += 1; - - if (c == '=') { - _buflen = 0; - return -1; - } else if (c.match(/^\s$/) ) { - // ignore if whitespace. - continue; - } - - _buffer = (_buffer << 6) | decode(c.charCodeAt(0) ); - _buflen += 6; - } - - var n = (_buffer >>> (_buflen - 8) ) & 0xff; - _buflen -= 8; - return n; - }; - - var decode = function(c) { - if (0x41 <= c && c <= 0x5a) { - return c - 0x41; - } else if (0x61 <= c && c <= 0x7a) { - return c - 0x61 + 26; - } else if (0x30 <= c && c <= 0x39) { - return c - 0x30 + 52; - } else if (c == 0x2b) { - return 62; - } else if (c == 0x2f) { - return 63; - } else { - throw 'c:' + c; - } - }; - - return _this; - }; - - //--------------------------------------------------------------------- - // gifImage (B/W) - //--------------------------------------------------------------------- - - var gifImage = function(width, height) { - - var _width = width; - var _height = height; - var _data = new Array(width * height); - - var _this = {}; - - _this.setPixel = function(x, y, pixel) { - _data[y * _width + x] = pixel; - }; - - _this.write = function(out) { - - //--------------------------------- - // GIF Signature - - out.writeString('GIF87a'); - - //--------------------------------- - // Screen Descriptor - - out.writeShort(_width); - out.writeShort(_height); - - out.writeByte(0x80); // 2bit - out.writeByte(0); - out.writeByte(0); - - //--------------------------------- - // Global Color Map - - // black - out.writeByte(0x00); - out.writeByte(0x00); - out.writeByte(0x00); - - // white - out.writeByte(0xff); - out.writeByte(0xff); - out.writeByte(0xff); - - //--------------------------------- - // Image Descriptor - - out.writeString(','); - out.writeShort(0); - out.writeShort(0); - out.writeShort(_width); - out.writeShort(_height); - out.writeByte(0); - - //--------------------------------- - // Local Color Map - - //--------------------------------- - // Raster Data - - var lzwMinCodeSize = 2; - var raster = getLZWRaster(lzwMinCodeSize); - - out.writeByte(lzwMinCodeSize); - - var offset = 0; - - while (raster.length - offset > 255) { - out.writeByte(255); - out.writeBytes(raster, offset, 255); - offset += 255; - } - - out.writeByte(raster.length - offset); - out.writeBytes(raster, offset, raster.length - offset); - out.writeByte(0x00); - - //--------------------------------- - // GIF Terminator - out.writeString(';'); - }; - - var bitOutputStream = function(out) { - - var _out = out; - var _bitLength = 0; - var _bitBuffer = 0; - - var _this = {}; - - _this.write = function(data, length) { - - if ( (data >>> length) != 0) { - throw 'length over'; - } - - while (_bitLength + length >= 8) { - _out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) ); - length -= (8 - _bitLength); - data >>>= (8 - _bitLength); - _bitBuffer = 0; - _bitLength = 0; - } - - _bitBuffer = (data << _bitLength) | _bitBuffer; - _bitLength = _bitLength + length; - }; - - _this.flush = function() { - if (_bitLength > 0) { - _out.writeByte(_bitBuffer); - } - }; - - return _this; - }; - - var getLZWRaster = function(lzwMinCodeSize) { - - var clearCode = 1 << lzwMinCodeSize; - var endCode = (1 << lzwMinCodeSize) + 1; - var bitLength = lzwMinCodeSize + 1; - - // Setup LZWTable - var table = lzwTable(); - - for (var i = 0; i < clearCode; i += 1) { - table.add(String.fromCharCode(i) ); - } - table.add(String.fromCharCode(clearCode) ); - table.add(String.fromCharCode(endCode) ); - - var byteOut = byteArrayOutputStream(); - var bitOut = bitOutputStream(byteOut); - - // clear code - bitOut.write(clearCode, bitLength); - - var dataIndex = 0; - - var s = String.fromCharCode(_data[dataIndex]); - dataIndex += 1; - - while (dataIndex < _data.length) { - - var c = String.fromCharCode(_data[dataIndex]); - dataIndex += 1; - - if (table.contains(s + c) ) { - - s = s + c; - - } else { - - bitOut.write(table.indexOf(s), bitLength); - - if (table.size() < 0xfff) { - - if (table.size() == (1 << bitLength) ) { - bitLength += 1; - } - - table.add(s + c); - } - - s = c; - } - } - - bitOut.write(table.indexOf(s), bitLength); - - // end code - bitOut.write(endCode, bitLength); - - bitOut.flush(); - - return byteOut.toByteArray(); - }; - - var lzwTable = function() { - - var _map = {}; - var _size = 0; - - var _this = {}; - - _this.add = function(key) { - if (_this.contains(key) ) { - throw 'dup key:' + key; - } - _map[key] = _size; - _size += 1; - }; - - _this.size = function() { - return _size; - }; - - _this.indexOf = function(key) { - return _map[key]; - }; - - _this.contains = function(key) { - return typeof _map[key] != 'undefined'; - }; - - return _this; - }; - - return _this; - }; - - var createDataURL = function(width, height, getPixel) { - var gif = gifImage(width, height); - for (var y = 0; y < height; y += 1) { - for (var x = 0; x < width; x += 1) { - gif.setPixel(x, y, getPixel(x, y) ); - } - } - - var b = byteArrayOutputStream(); - gif.write(b); - - var base64 = base64EncodeOutputStream(); - var bytes = b.toByteArray(); - for (var i = 0; i < bytes.length; i += 1) { - base64.writeByte(bytes[i]); - } - base64.flush(); - - return 'data:image/gif;base64,' + base64; - }; - - //--------------------------------------------------------------------- - // returns qrcode function. - - return qrcode; -}(); - -// multibyte support -!function() { - - qrcode.stringToBytesFuncs['UTF-8'] = function(s) { - // http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array - function toUTF8Array(str) { - var utf8 = []; - for (var i=0; i < str.length; i++) { - var charcode = str.charCodeAt(i); - if (charcode < 0x80) utf8.push(charcode); - else if (charcode < 0x800) { - utf8.push(0xc0 | (charcode >> 6), - 0x80 | (charcode & 0x3f)); - } - else if (charcode < 0xd800 || charcode >= 0xe000) { - utf8.push(0xe0 | (charcode >> 12), - 0x80 | ((charcode>>6) & 0x3f), - 0x80 | (charcode & 0x3f)); - } - // surrogate pair - else { - i++; - // UTF-16 encodes 0x10000-0x10FFFF by - // subtracting 0x10000 and splitting the - // 20 bits of 0x0-0xFFFFF into two halves - charcode = 0x10000 + (((charcode & 0x3ff)<<10) - | (str.charCodeAt(i) & 0x3ff)); - utf8.push(0xf0 | (charcode >>18), - 0x80 | ((charcode>>12) & 0x3f), - 0x80 | ((charcode>>6) & 0x3f), - 0x80 | (charcode & 0x3f)); - } - } - return utf8; - } - return toUTF8Array(s); - }; - -}(); - -(function (factory) { - if (typeof define === 'function' && define.amd) { - define([], factory); - } else if (typeof exports === 'object') { - module.exports = factory(); - } -}(function () { - return qrcode; -})); diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..78d5725 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,123 @@ +site_name: Blockout +site_description: A multiplication game for 3rd graders — roll two dice, draw the rectangle, claim the board — with classroom play, homework and more games. +repo_url: https://github.com/DeepSpaceCartel/blockout +repo_name: DeepSpaceCartel/blockout +edit_uri: edit/main/docs/ + +# The v0.3.0 plan and work log are real records of how this version was built, +# not reader docs; not_in_nav keeps them out of the site's navigation without +# mkdocs build reporting them. +not_in_nav: | + v0.3.0-plan.md + v0.3.0-log.md + +nav: + - Home: + - Welcome: home/index.md + - Quickstart: home/quickstart.md + - Guides: + - Playing: guides/playing.md + - Teachers: guides/teachers.md + - Parents: guides/parents.md + - Admins: guides/admins.md + - Share through a tunnel: guides/share-through-a-tunnel.md + - Run the phone app: guides/run-the-phone-app.md + - Sign in with Keycloak: guides/set-up-keycloak.md + - Set up MongoDB and Valkey: guides/set-up-the-databases.md + - Deploy to a cluster: guides/deploy.md + - Run it day to day: guides/operate.md + - Back up the database: guides/back-up-the-database.md + - Application: + - Architecture: concepts/architecture.md + - Security: concepts/security.md + - Progression: concepts/progression.md + - Privacy: privacy.md + - Configuration: reference/CONFIGURATION.md + - HTTP API: reference/HTTP-API.md + - Project: + - Installing: project/installing.md + - Running the tests: project/testing.md + - CI: project/ci.md + - Changelog: project/changelog.md + - Decisions: + - Decision log: decisions/index.md + - 0001. A Turborepo of plain-JavaScript Vite apps: decisions/0001-turborepo-monorepo.md + - 0002. Swappable sign-in, dev now and Keycloak later: decisions/0002-swappable-sign-in.md + - 0003. sqlite through node:sqlite: decisions/0003-sqlite-with-node-builtin.md + - 0004. Store the minimum about children: decisions/0004-store-minimum-child-data.md + - 0005. Game modules declare at import, work in run(): decisions/0005-game-modules-run-phase.md + - 0006. MongoDB and Redis, for several servers: decisions/0006-mongodb-and-redis.md + - 0007. Live classes in Redis: decisions/0007-live-classes-in-redis.md + - 0008. Roles kept by Blockout, changed by invites: decisions/0008-roles-in-blockout.md + - 0009. One Helm chart for everything: decisions/0009-one-chart.md + - 0010. Boards and difficulties are Journey levels: decisions/0010-journey-progression.md + - 0011. Practice is free, a times table a level: decisions/0011-practice-journey.md + - 0012. Three stages a level: decisions/0012-journey-stages.md + - 0013. Expert rounds in Practice: decisions/0013-practice-expert-rounds.md + - 0014. First boards one stage; stage 3 every answer right: decisions/0014-first-boards-one-stage.md + - 0015. Every web page is one Vite app: decisions/0015-one-web-app.md + - 0016. One shared Keycloak for development: decisions/0016-shared-keycloak.md + +theme: + name: material + features: + - navigation.sections + - navigation.tabs + - navigation.tabs.sticky + - navigation.top + - navigation.footer + - navigation.path + - navigation.instant + - navigation.instant.progress + - toc.follow + - search.highlight + - search.suggest + - content.code.copy + - content.action.edit + - content.tooltips + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: deep orange + accent: blue + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: deep orange + accent: blue + toggle: + icon: material/brightness-4 + name: Switch to light mode + icon: + repo: fontawesome/brands/github + +markdown_extensions: + - abbr + - admonition + - attr_list + - def_list + - md_in_html + - pymdownx.details + - pymdownx.emoji: + emoji_index: !!python/name:material.extensions.emoji.twemoji + emoji_generator: !!python/name:material.extensions.emoji.to_svg + - pymdownx.highlight + - pymdownx.inlinehilite + - pymdownx.keys + - pymdownx.snippets: + check_paths: true + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format + - pymdownx.tabbed: + alternate_style: true + - tables + - toc: + permalink: true + +plugins: + - search diff --git a/package-lock.json b/package-lock.json index 70fef54..417866e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,52 +1,9306 @@ { "name": "blockout", - "version": "0.1.0", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "blockout", - "version": "0.1.0", + "version": "0.11.0", + "workspaces": [ + "apps/*", + "packages/*" + ], "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.11.0", + "globals": "^17.12.0", "jsqr": "^1.4.0", - "playwright": "^1.63.0" + "playwright": "^1.63.0", + "turbo": "^2.11.3", + "vite": "^8.3.0" + }, + "engines": { + "node": ">=26" + } + }, + "apps/mobile": { + "name": "@blockout/mobile", + "version": "0.11.0", + "dependencies": { + "@blockout/classroom": "*", + "@blockout/engine": "*", + "@blockout/progress": "*", + "@expo/metro-runtime": "~57.0.17", + "@react-native-async-storage/async-storage": "2.2.0", + "expo": "~57.0.30", + "expo-auth-session": "~57.0.16", + "expo-constants": "~57.0.22", + "expo-crypto": "~57.0.4", + "expo-secure-store": "~57.0.4", + "expo-status-bar": "~57.0.1", + "expo-web-browser": "~57.0.3", + "qrcode-generator": "^2.0.4", + "react": "19.2.3", + "react-dom": "19.2.3", + "react-native": "0.86.3", + "react-native-safe-area-context": "~5.7.0", + "react-native-svg": "15.15.4", + "react-native-web": "^0.21.4" + }, + "devDependencies": { + "@expo/ngrok": "^4.1.3" + } + }, + "apps/server": { + "name": "@blockout/server", + "version": "0.11.0", + "dependencies": { + "@blockout/engine": "*", + "@blockout/progress": "*", + "mongodb": "^7.7.0", + "redis": "^6.3.0" + } + }, + "apps/web": { + "name": "@blockout/web", + "version": "0.11.0", + "dependencies": { + "@blockout/auth": "*", + "@blockout/classroom": "*", + "@blockout/engine": "*", + "@blockout/game-kit": "*", + "@blockout/progress": "*", + "@blockout/ui": "*", + "qrcode-generator": "^2.0.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.29.7.tgz", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.29.10.tgz", + "integrity": "sha512-/YY9soQuWa8TURtHXHT+GbklpbAA+dZErxFGHIr6qgDg96xuGWrTAs1+MfjgVJPF0YLQsOVsOYs8lhgsPbOinQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.5.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.29.7.tgz", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.29.7.tgz", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.29.7.tgz", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.29.7.tgz", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.29.7.tgz", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.29.7.tgz", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-proposal-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-decorators/-/plugin-proposal-decorators-7.29.7.tgz", + "integrity": "sha512-EtU0Hi3GvrTqD56xKmZvV/uCXK2ZbwVNPNLAquVItcAZpUhkXwWlo3Fmj0c2LxgSf2I8IDULeAepwNP1OefLXg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-decorators": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-proposal-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-export-default-from/-/plugin-proposal-export-default-from-7.29.7.tgz", + "integrity": "sha512-p+G5BNXDcy3bOXplhY4HybQ1GxH3i2Tppmdm/3epyRu2VgJJZuUlZ61MqRTg582Q7ZLBdP7fePYvsumSEkMxcQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-decorators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-decorators/-/plugin-syntax-decorators-7.29.7.tgz", + "integrity": "sha512-9MTTLbF39X6sqM92JPEsoI7++26hjZvzkxKZy64aMhWLH2mPkJ/Q3AV4QLmls3R14FpSpkOwQQfUh962JGQxxg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-export-default-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-default-from/-/plugin-syntax-export-default-from-7.29.7.tgz", + "integrity": "sha512-foag0BB37ROhdeIX9O8G0jX7hw0UekJc04cHMrYLOnrErsnBKqJGHJ8eDRpoCFZBvEPPygmmtw4qyU97qa4oOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-flow": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.29.7.tgz", + "integrity": "sha512-ajMX6QPcyomotqwpzhkYGxcK2i/us0rs1Qo9QvUpa+Fca0FTmqrzKrctoIYLMxcOhGZldGT/BAVkRGTWBiR8gQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.29.7.tgz", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.7.tgz", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.29.7.tgz", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.29.7.tgz", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.29.7.tgz", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.29.7.tgz", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.29.7.tgz", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.29.7.tgz", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.29.7.tgz", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-flow-strip-types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-flow-strip-types/-/plugin-transform-flow-strip-types-7.29.7.tgz", + "integrity": "sha512-wRHeUjUjCZnMHmiO5bRgjFLcoEh7JyTdByOW11ahhwNa4V0bmeGEaIvt51yq0zQp2yWIpqfxXXPyUP6GFJZHOQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-flow": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.29.7.tgz", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.29.7.tgz", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.29.7.tgz", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.7.tgz", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.29.7.tgz", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.29.7.tgz", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.29.7.tgz", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.29.7.tgz", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.29.7.tgz", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.29.7.tgz", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.29.7.tgz", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.29.7.tgz", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.29.7.tgz", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.29.7.tgz", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.29.7.tgz", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.29.7.tgz", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@blockout/auth": { + "resolved": "packages/auth", + "link": true + }, + "node_modules/@blockout/classroom": { + "resolved": "packages/classroom", + "link": true + }, + "node_modules/@blockout/engine": { + "resolved": "packages/engine", + "link": true + }, + "node_modules/@blockout/game-kit": { + "resolved": "packages/game-kit", + "link": true + }, + "node_modules/@blockout/mobile": { + "resolved": "apps/mobile", + "link": true + }, + "node_modules/@blockout/progress": { + "resolved": "packages/progress", + "link": true + }, + "node_modules/@blockout/server": { + "resolved": "apps/server", + "link": true + }, + "node_modules/@blockout/ui": { + "resolved": "packages/ui", + "link": true + }, + "node_modules/@blockout/web": { + "resolved": "apps/web", + "link": true + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@expo/cli": { + "version": "57.0.31", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-57.0.31.tgz", + "integrity": "sha512-NPTMPGQ9i3KzULyP1bzp8hMggoe3QlkfICw388zJuj0UhqfGHEZURkJFuHBksb/ndTjfrCrCBOO0HQz/9QzXIw==", + "license": "MIT", + "dependencies": { + "@expo/code-signing-certificates": "^0.0.6", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", + "@expo/devcert": "^1.2.1", + "@expo/env": "~2.4.3", + "@expo/image-utils": "^0.11.6", + "@expo/inline-modules": "^0.1.8", + "@expo/json-file": "^11.0.1", + "@expo/log-box": "^57.0.4", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~57.0.13", + "@expo/metro-file-map": "^57.0.4", + "@expo/osascript": "^2.7.1", + "@expo/package-manager": "^1.13.1", + "@expo/plist": "^0.8.1", + "@expo/prebuild-config": "^57.0.19", + "@expo/require-utils": "^57.0.6", + "@expo/router-server": "^57.0.13", + "@expo/schema-utils": "^57.0.2", + "@expo/spawn-async": "^1.8.0", + "@expo/ws-tunnel": "^2.0.0", + "@expo/xcpretty": "^4.4.4", + "@react-native/dev-middleware": "0.86.3", + "accepts": "^1.3.8", + "agent-cli-detector": "0.1.7", + "arg": "^5.0.2", + "bplist-creator": "0.1.0", + "bplist-parser": "^0.3.1", + "chalk": "^4.0.0", + "ci-info": "^3.3.0", + "compression": "^1.7.4", + "connect": "^3.7.0", + "debug": "^4.3.4", + "dnssd-advertise": "^1.1.4", + "expo-server": "^57.0.3", + "fetch-nodeshim": "^0.4.10", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "lan-network": "^0.2.1", + "multitars": "^1.0.2", + "node-forge": "^1.3.3", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "picomatch": "^4.0.4", + "pretty-format": "^29.7.0", + "progress": "^2.0.3", + "prompts": "^2.3.2", + "resolve-from": "^5.0.0", + "sandbox-cli-detector": "^0.2.0", + "semver": "^7.6.0", + "send": "^0.19.0", + "slugify": "^1.3.4", + "stacktrace-parser": "^0.1.10", + "structured-headers": "^0.4.1", + "terminal-link": "^2.1.1", + "toqr": "^0.1.1", + "wrap-ansi": "^7.0.0", + "ws": "^8.12.1", + "zod": "^3.25.76" + }, + "bin": { + "expo-internal": "main.js" + }, + "peerDependencies": { + "expo": "*", + "expo-router": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "expo-router": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/cli/node_modules/@expo/router-server": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-57.0.13.tgz", + "integrity": "sha512-A25869lSp6916GiYjz1y1S/Kfi5qn7S1xepycFv8x8VOrulLBbv0byXl6o3BSLh5nLBx5RiMTjBFM2u+K7JsrA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4" + }, + "peerDependencies": { + "@expo/metro-runtime": "^57.0.17", + "expo": "*", + "expo-constants": "^57.0.22", + "expo-font": "^57.0.4", + "expo-router": "*", + "expo-server": "^57.0.3", + "react": "*", + "react-dom": "*", + "react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1" + }, + "peerDependenciesMeta": { + "@expo/metro-runtime": { + "optional": true + }, + "expo-router": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-server-dom-webpack": { + "optional": true + } + } + }, + "node_modules/@expo/code-signing-certificates": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", + "integrity": "sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==", + "license": "MIT", + "dependencies": { + "node-forge": "^1.3.3" + } + }, + "node_modules/@expo/config": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/config/-/config-57.0.10.tgz", + "integrity": "sha512-YpexTqLWEXdbbXgHh7Zhpq+qA5diuvO46lb7TiY81JwqKLAl9zK31APY1D7jUFlDX56owje5RtK0o5bMmGIMUg==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.10", + "@expo/config-types": "^57.0.2", + "@expo/json-file": "^11.0.1", + "@expo/require-utils": "^57.0.6", + "deepmerge": "^4.3.1", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "resolve-workspace-root": "^2.0.0", + "semver": "^7.6.0", + "slugify": "^1.3.4" + } + }, + "node_modules/@expo/config-plugins": { + "version": "57.0.10", + "resolved": "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-57.0.10.tgz", + "integrity": "sha512-XFcj01blofSohoOxibrPsrsFPt2gAigrcWE3s6Vg+rSHvP9QVEfnyfmJNVsuIVT0EjY/1rZVJ4SajNDR5cJjpA==", + "license": "MIT", + "dependencies": { + "@expo/config-types": "^57.0.2", + "@expo/json-file": "~11.0.1", + "@expo/plist": "^0.8.1", + "@expo/require-utils": "^57.0.6", + "@expo/sdk-runtime-versions": "^1.0.0", + "chalk": "^4.1.2", + "debug": "^4.3.5", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "semver": "^7.5.4", + "slugify": "^1.6.6", + "xcode": "^3.0.1", + "xml2js": "0.6.0" + } + }, + "node_modules/@expo/config-plugins/node_modules/@expo/json-file": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", + "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/config-types": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/config-types/-/config-types-57.0.2.tgz", + "integrity": "sha512-ewW08OonrcRIsRKIlFvvcmmafE5zemb1ocu3HkNwtVPyRtj2w42pZCAkMIROYpcVBaPnc3mDT9UZDzwXWC3i6g==", + "license": "MIT" + }, + "node_modules/@expo/devcert": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@expo/devcert/-/devcert-1.2.2.tgz", + "integrity": "sha512-wz1YedXwdNbYqk/PRr34vixnuw1+l6uSwmPXUj649cot5iYNccb2zIOnK7Nonb1Pfd/dxAY6CvU6KM3uSdxC7A==", + "license": "MIT", + "dependencies": { + "@expo/sudo-prompt": "^9.3.1", + "debug": "^3.1.0" + } + }, + "node_modules/@expo/devcert/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/@expo/devtools": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/devtools/-/devtools-57.0.1.tgz", + "integrity": "sha512-GyUf+wFNkbttaX0jR7MZa9bm77U0IrLg6d2AjpxdyoXw/w4abHoXG0oFufwLMgP9zLTd5+Ct4X/ffNUTnlzZgg==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/@expo/dom-webview": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/@expo/dom-webview/-/dom-webview-57.0.1.tgz", + "integrity": "sha512-lAKsME4SAq+8sf56oN0DX5TBYyruupoRxbWbD2xf9RnKY8y6x8eb9LCE5pxSN0qyWdqnp+0wmyWzDkKboThKAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/env": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/@expo/env/-/env-2.4.3.tgz", + "integrity": "sha512-M1NXeZCA1mkMkYOyIe7PlyRX0/jqFtMoJgyblnlq/vpCRfmueFT7RnGSQG8uEFDF5WHOFGijAQ3fogPh3/n5Ng==", + "license": "MIT", + "dependencies": { + "chalk": "^4.0.0", + "debug": "^4.3.4", + "getenv": "^2.0.0" + }, + "engines": { + "node": ">=20.12.0" + } + }, + "node_modules/@expo/expo-modules-macros-plugin": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@expo/expo-modules-macros-plugin/-/expo-modules-macros-plugin-0.6.1.tgz", + "integrity": "sha512-cpsLZE4rqkc1Y3eZTkxB98jrqY1YXgetmtxFt8q89jBRmk3quRuk1BZo+VcnCSObZardjg99r1k5xijEMONFGA==", + "license": "MIT" + }, + "node_modules/@expo/fingerprint": { + "version": "0.20.14", + "resolved": "https://registry.npmjs.org/@expo/fingerprint/-/fingerprint-0.20.14.tgz", + "integrity": "sha512-7sihStGX1ZepJBNbYgeQY7cU2GQ+PSkSROe3g7JOQqnT6OOuX0rXKY0ZNoVnlB2Y/H5FWnnJ6QrPlK3fNg9+bQ==", + "license": "MIT", + "dependencies": { + "@expo/env": "^2.4.3", + "@expo/spawn-async": "^1.8.0", + "arg": "^5.0.2", + "chalk": "^4.1.2", + "debug": "^4.3.4", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "ignore": "^5.3.1", + "minimatch": "^10.2.2", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + }, + "bin": { + "fingerprint": "bin/cli.js" + } + }, + "node_modules/@expo/image-utils": { + "version": "0.11.6", + "resolved": "https://registry.npmjs.org/@expo/image-utils/-/image-utils-0.11.6.tgz", + "integrity": "sha512-rmMpn7QGp6pUGRCQ3vvB9RP/vqq2q/PpToRh4dL/FbhSZOLZ1YTotpuLHf71FGOEfgKil/4PCd3hkWe/CCeiEg==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^57.0.6", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "getenv": "^2.0.0", + "jimp-compact": "0.16.1", + "parse-png": "^2.1.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/inline-modules": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@expo/inline-modules/-/inline-modules-0.1.8.tgz", + "integrity": "sha512-5OCHzv/VHVuam9UT6/yeArtUnmkp5VdAH6k4JwdRzK/34ukzdjQK7B9n71fiswZKtm2MmZlPuy4hJo2Ao8CZNw==", + "license": "MIT", + "dependencies": { + "@expo/config-plugins": "~57.0.10" + } + }, + "node_modules/@expo/json-file": { + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.2.1.tgz", + "integrity": "sha512-UPhKoFCj8BcwgLB1rKQ1CuR/REITnZLiqxHEHOeH1tPsdXcHGtUDh0P5bLbr7oU0nKVdtJ5rzyKx8jniMQdESQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/local-build-cache-provider": { + "version": "57.0.9", + "resolved": "https://registry.npmjs.org/@expo/local-build-cache-provider/-/local-build-cache-provider-57.0.9.tgz", + "integrity": "sha512-hox13/K/dqvKG7StLYs9nwyNR/mJ5ieAkNN8jTee6liMPyUQTfpzgticRyWnVxj49Fr8PpTaQab/ArYVEBJOcQ==", + "license": "MIT", + "dependencies": { + "@expo/config": "~57.0.10", + "chalk": "^4.1.2" + } + }, + "node_modules/@expo/log-box": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/log-box/-/log-box-57.0.4.tgz", + "integrity": "sha512-IxwS9s1L2muj8mj8AQSuiy7u8OFJdc02NRFo2me/Tj6DiaeG5SREqmpBE4rQpR2cadqSg5jl8Qab8Cjie616dg==", + "license": "MIT", + "dependencies": { + "@expo/dom-webview": "^57.0.1", + "anser": "^1.4.9", + "stacktrace-parser": "^0.1.10" + }, + "peerDependencies": { + "@expo/dom-webview": "^57.0.1", + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/@expo/metro": { + "version": "56.0.2", + "resolved": "https://registry.npmjs.org/@expo/metro/-/metro-56.0.2.tgz", + "integrity": "sha512-Ld5AeYMCCDa8bLeWhfuLbZFFjlV3f6ORqyPz2glGh6RltIngMuLf9BTC2yvHFjkKuGxL5SynijmA8xmNNWn5iA==", + "license": "MIT", + "dependencies": { + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5" + } + }, + "node_modules/@expo/metro-config": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-57.0.13.tgz", + "integrity": "sha512-r+YCwqh8FM5cymkC3MdYO32DqLZQ2wDWkGEnzwgwI40Bj355BvL4XoY0hGgao9vNHjYa7be7XxbVqtMTi76snw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.20.0", + "@babel/generator": "^7.20.5", + "@expo/config": "~57.0.10", + "@expo/env": "~2.4.3", + "@expo/json-file": "~11.0.1", + "@expo/metro": "~56.0.2", + "@expo/require-utils": "^57.0.6", + "@expo/spawn-async": "^1.8.0", + "@jridgewell/gen-mapping": "^0.3.13", + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.5.5", + "browserslist": "^4.25.0", + "chalk": "^4.1.0", + "debug": "^4.3.2", + "getenv": "^2.0.0", + "glob": "^13.0.0", + "hermes-parser": "^0.36.0", + "jsc-safe-url": "^0.2.4", + "lightningcss": "^1.30.1", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "resolve-from": "^5.0.0" + }, + "peerDependencies": { + "expo": "*" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@expo/metro-config/node_modules/@expo/json-file": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/@expo/json-file/-/json-file-11.0.1.tgz", + "integrity": "sha512-zxHWj4MKKMAL29ZQSY/Fssx4Thluk40JmuGNaeS078wy/NhlFhnVi+rHHunulE3xJAJ0CM73m8VK2+GkF9eRwQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "json5": "^2.2.3" + } + }, + "node_modules/@expo/metro-file-map": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/@expo/metro-file-map/-/metro-file-map-57.0.4.tgz", + "integrity": "sha512-gLABIbU3SlxRVVCe56ZfYFbxjhdpAKomQYr6LIeDYf7rca8Pfvzv8FS39hH0+IcdpEHly2Jy0X2nd4/obd8Kig==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.4", + "fb-watchman": "^2.0.2", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "walker": "^1.0.8" + } + }, + "node_modules/@expo/metro-runtime": { + "version": "57.0.17", + "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-57.0.17.tgz", + "integrity": "sha512-fiWYLaMDV8hoIpyPztRFPhGbHCHWlVfRTac3jZV7tP+fYCpo1QmgsuL23ubnoWqDZhFuRDNk+hQ4y21DMmxEFA==", + "license": "MIT", + "dependencies": { + "@expo/log-box": "^57.0.4", + "anser": "^1.4.9", + "pretty-format": "^29.7.0", + "stacktrace-parser": "^0.1.10", + "whatwg-fetch": "^3.0.0" + }, + "peerDependencies": { + "@expo/log-box": "^57.0.4", + "expo": "*", + "react": "*", + "react-dom": "*", + "react-native": "*" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@expo/metro/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@expo/metro/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/@expo/metro/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/@expo/metro/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/@expo/metro/node_modules/metro": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.5.tgz", + "integrity": "sha512-r1liLkyFZMVSEMNjU1CJU5pRzs3NdkxHqXS60O25c0rCIqAR+cGk7rPydw/g0WAIKVXojIBIF45yYBPagJGcgw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-config": "0.84.5", + "metro-core": "0.84.5", + "metro-file-map": "0.84.5", + "metro-resolver": "0.84.5", + "metro-runtime": "0.84.5", + "metro-source-map": "0.84.5", + "metro-symbolicate": "0.84.5", + "metro-transform-plugins": "0.84.5", + "metro-transform-worker": "0.84.5", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-babel-transformer": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.5.tgz", + "integrity": "sha512-2WbHILKMiJUzfdjmGOQOqU1bWi9//gqiclc/tkk/AIsrrVw3efhZ1uhkOwMTxUEPOzqoo091H0olLmVZH5FHGQ==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-cache": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.5.tgz", + "integrity": "sha512-WHS0n2OxQqtwEjSeQFPePNrMvEFhmQcUQM9cRJMHByWoi/GMWFBEWOf7hVkAM/0KRutAXNbDlSu/cZB6CyxgQQ==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-cache-key": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.5.tgz", + "integrity": "sha512-3dPB2TnvGjjf0/9O7AXVQURKXuQNauTZE7WpTGTlR017Gh/B5y0m/2wcqxfveUguHSpu89KhVxCAlr2k/H7uhQ==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-config": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.5.tgz", + "integrity": "sha512-zie+uN6oohscowi2S7ByU+wUw6CrT4ZxW9uAbONOObSxx86RGmnIAmjXHLkfmcdYoY7jzOPEbqcI6oeVmqyBQA==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.5", + "metro-cache": "0.84.5", + "metro-core": "0.84.5", + "metro-runtime": "0.84.5", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-core": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.5.tgz", + "integrity": "sha512-xwm605hCi5Y6eJTTb8ZWo6pkUcoBEIyiQOfkZh5GwtDwUrP9SNhTQZhzJHrBCwwxlf3Ptl/pxWJgQ1rsNYMnrA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.5" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-file-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.5.tgz", + "integrity": "sha512-mlm/JL8toSbSc2akpKIGmzvrVRSCgZ5vkbycI34oMLoOnLGuLyC8WTyVJ6P0hZG/usDaGwZSl/s9BCRriqjGJA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-minify-terser": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.5.tgz", + "integrity": "sha512-BJoFwCEDsYnagPqarayInv2+diCDNDdLlaof/p6s9w4gh+gc9HXYM+pDvsKGKKUumpZswNF3Z/ftTMqKl/5IBg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-resolver": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.5.tgz", + "integrity": "sha512-VSSnepg1k6LyCwtb6eirWdAWlpKwBG8Rdtsr1mU38rMelFyWgh3/QuMSiZIZAIjwg/fsa8GhW5/FO54CAUPCEA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-runtime": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.5.tgz", + "integrity": "sha512-U1m2+d1Pr+JO2/iVXBB2OfXXityz7tqwIorxfrT15IEgaHvpJBq/OHiqnOWPKJbUl3JcxjcdviZZOKk85oK4Qg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-source-map": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.5.tgz", + "integrity": "sha512-2BtV5L9uPc49F13Gn5wiP6bX/EncqzqTIk2VL/0F/96Vo0YEOjluT/qktQjFODfqGFsucwnh5mPEAl/2jVEfeg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.5", + "nullthrows": "^1.1.1", + "ob1": "0.84.5", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-symbolicate": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.5.tgz", + "integrity": "sha512-rQ40zYDAkaWBN9yvjUuAD0ZpzBMZSoKyGYXnb5JrfbKjun7fTvfoLHL3KXFYenBTYZkQtlp4cKSCv/1utxFyOw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.5", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-transform-plugins": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.5.tgz", + "integrity": "sha512-+InaSVGaOyt0DyRo4Y/zIdPI6CZwnbNho5LAL23tgmuGwv7fyfkF7kKfPjZcfxXBcoYdTLLFnCfCH/dHSiCqNg==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/metro-transform-worker": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.5.tgz", + "integrity": "sha512-ui1Z8x4s5RL36gMmKLaMMO7O9NNDHNdthEZSCDQHAau3JcAsTaFOK6I+2q4I/kW5u8hSEjJk9L45TXSVJw6g1A==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.5", + "metro-babel-transformer": "0.84.5", + "metro-cache": "0.84.5", + "metro-cache-key": "0.84.5", + "metro-minify-terser": "0.84.5", + "metro-source-map": "0.84.5", + "metro-transform-plugins": "0.84.5", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@expo/metro/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@expo/metro/node_modules/ob1": { + "version": "0.84.5", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.5.tgz", + "integrity": "sha512-aH9RkoZc7w/90HBamFxTw8ZLFr05wXS+iOnvmrgo53Ep8Pyrm5FieQSaPIVROkfFVQISeD/zo92fes26TOwe+A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@expo/metro/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@expo/ngrok": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@expo/ngrok/-/ngrok-4.1.3.tgz", + "integrity": "sha512-AESYaROGIGKWwWmUyQoUXcbvaUZjmpecC5buArXxYou+RID813F8T0Y5jQ2HUY49mZpYfJiy9oh4VSN37GgrXA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@expo/ngrok-bin": "2.3.42", + "got": "^11.5.1", + "uuid": "^3.3.2", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/@expo/ngrok-bin": { + "version": "2.3.42", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin/-/ngrok-bin-2.3.42.tgz", + "integrity": "sha512-kyhORGwv9XpbPeNIrX6QZ9wDVCDOScyTwxeS+ScNmUqYoZqD9LRmEqF7bpDh5VonTsrXgWrGl7wD2++oSHcaTQ==", + "dev": true, + "bin": { + "ngrok": "bin/ngrok.js" + }, + "optionalDependencies": { + "@expo/ngrok-bin-darwin-arm64": "2.3.41", + "@expo/ngrok-bin-darwin-x64": "2.3.41", + "@expo/ngrok-bin-freebsd-ia32": "2.3.41", + "@expo/ngrok-bin-freebsd-x64": "2.3.41", + "@expo/ngrok-bin-linux-arm": "2.3.41", + "@expo/ngrok-bin-linux-arm64": "2.3.41", + "@expo/ngrok-bin-linux-ia32": "2.3.41", + "@expo/ngrok-bin-linux-x64": "2.3.41", + "@expo/ngrok-bin-sunos-x64": "2.3.41", + "@expo/ngrok-bin-win32-ia32": "2.3.41", + "@expo/ngrok-bin-win32-x64": "2.3.41" + } + }, + "node_modules/@expo/ngrok-bin-darwin-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz", + "integrity": "sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-darwin-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz", + "integrity": "sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz", + "integrity": "sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-freebsd-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz", + "integrity": "sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz", + "integrity": "sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-arm64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz", + "integrity": "sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz", + "integrity": "sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-linux-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz", + "integrity": "sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@expo/ngrok-bin-sunos-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz", + "integrity": "sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ] + }, + "node_modules/@expo/ngrok-bin-win32-ia32": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz", + "integrity": "sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok-bin-win32-x64": { + "version": "2.3.41", + "resolved": "https://registry.npmjs.org/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz", + "integrity": "sha512-mncsPRaG462LiYrM8mQT8OYe3/i44m3N/NzUeieYpGi8+pCOo8TIC23kR9P93CVkbM9mmXsy3X6hq91a8FWBdA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@expo/ngrok/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, + "node_modules/@expo/ngrok/node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/@expo/osascript": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/@expo/osascript/-/osascript-2.8.1.tgz", + "integrity": "sha512-SCi+6q6uOkGhiAn6AIP/OPFgaIq/iKD2oYpnAOg2rShHStJ8ey7wyT127RbOaZXmlcY/62rHOX1WiiMnD8u/BA==", + "license": "MIT", + "dependencies": { + "@expo/spawn-async": "^1.8.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/package-manager": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/@expo/package-manager/-/package-manager-1.14.2.tgz", + "integrity": "sha512-sftl0eRVVCarI6grMdjAB3jX2pX1Mt3cdSnajlXS4QyeDkaKRM/ntBy6dcagj1VL0+cEfEqctlyvinmlZdNubg==", + "license": "MIT", + "dependencies": { + "@expo/json-file": "^11.2.1", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.0.0", + "npm-package-arg": "^11.0.0", + "ora": "^3.4.0", + "resolve-workspace-root": "^2.0.0" + } + }, + "node_modules/@expo/plist": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@expo/plist/-/plist-0.8.1.tgz", + "integrity": "sha512-3gTReGIUm0oRaMClsAJYxBnVPCl6fVpsl8HS+DTVxDhW4GyVyxg9E/Znm3BvcHtUJ51RJJI14pC1wvrNilCRHw==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.8.8", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + } + }, + "node_modules/@expo/prebuild-config": { + "version": "57.0.19", + "resolved": "https://registry.npmjs.org/@expo/prebuild-config/-/prebuild-config-57.0.19.tgz", + "integrity": "sha512-trBpySOP8Pv/xtMSb5ASAL5y8h6kbZTinakZ5cUbu0lX5iDpiB3Cg3ANYc3coriH7jpl3cJecG4e0PzUMced8g==", + "license": "MIT", + "dependencies": { + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", + "@expo/config-types": "^57.0.2", + "@expo/image-utils": "^0.11.6", + "@expo/json-file": "^11.0.1", + "@react-native/normalize-colors": "0.86.3", + "debug": "^4.3.1", + "expo-modules-autolinking": "~57.0.16", + "resolve-from": "^5.0.0", + "semver": "^7.6.0" + } + }, + "node_modules/@expo/require-utils": { + "version": "57.0.6", + "resolved": "https://registry.npmjs.org/@expo/require-utils/-/require-utils-57.0.6.tgz", + "integrity": "sha512-PKWfFdMgwWrlSP6NGplCi2nK0dEM5MeJ2Cj1ijr5VWTmZ+pKsJsJw2dWkBtlFDyxMY8eUjCPZ7hoqFEnbc0Pcw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "@babel/core": "^7.25.2", + "@babel/plugin-transform-modules-commonjs": "^7.24.8" + }, + "peerDependencies": { + "typescript": "^5.0.0 || ^5.0.0-0 || ^6.0.0 || ^7.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@expo/schema-utils": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-57.0.2.tgz", + "integrity": "sha512-fMu/jyN0l1Wzv7XkeWR4IYCx1M8ryui3FdBNGrWwbRgJ7EhxXxK8E2jxP2W3pbgUwUY0V3hG8+GyfCZwny+Lxw==", + "license": "MIT" + }, + "node_modules/@expo/sdk-runtime-versions": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@expo/sdk-runtime-versions/-/sdk-runtime-versions-1.0.1.tgz", + "integrity": "sha512-kvisefkhxu8Fm/4M8G6DEqUaigrXfrSOm2lFw7hsk/nq0zchcp6B4wSk8rlheHY8FRi5iHxKtoVMGY/3M6h04A==", + "license": "MIT" + }, + "node_modules/@expo/spawn-async": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@expo/spawn-async/-/spawn-async-1.8.0.tgz", + "integrity": "sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@expo/sudo-prompt": { + "version": "9.3.2", + "resolved": "https://registry.npmjs.org/@expo/sudo-prompt/-/sudo-prompt-9.3.2.tgz", + "integrity": "sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==", + "license": "MIT" + }, + "node_modules/@expo/ws-tunnel": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@expo/ws-tunnel/-/ws-tunnel-2.0.1.tgz", + "integrity": "sha512-mljsRffbOHzmSimYQbQu9h9NR3alwCCwEEouRGFL4QRTt8dNvsNtKEPa/5jHmtDLDtsh4xXsWKwzjN9BTpeXHQ==", + "license": "MIT", + "peerDependencies": { + "ws": "^8.0.0" + } + }, + "node_modules/@expo/xcpretty": { + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@expo/xcpretty/-/xcpretty-4.4.7.tgz", + "integrity": "sha512-EbxYllZ7/ozONVZd425cm4XnolfW+AmSFT5aP7Jdno6oc3WrYQcHz2CO20z/2rwWwYgztmV2PXvYH+BQ8YFjqg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.20.0", + "chalk": "^4.1.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "excpretty": "build/cli.js" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/ttlcache": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@isaacs/ttlcache/-/ttlcache-1.4.1.tgz", + "integrity": "sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.6.0.tgz", + "integrity": "sha512-19jGXfpctLDJUELLUZGJ5BllcLT7rHdF+gTTaN2jzqu0bGkUhpdcYtgK2Vux+g3zuBAa0TM/W666QMIjYhYujw==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@react-native-async-storage/async-storage": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", + "integrity": "sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==", + "license": "MIT", + "dependencies": { + "merge-options": "^3.0.4" + }, + "peerDependencies": { + "react-native": "^0.0.0-0 || >=0.65 <1.0" + } + }, + "node_modules/@react-native/assets-registry": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/assets-registry/-/assets-registry-0.86.3.tgz", + "integrity": "sha512-TDhgCZA4wjJg84d5A9swiOQYPIWSKEEVdg9IwMFZDupQzW/F3QoLUrfAJOcalgqTDA9/buTB8awhE3Whwg6u9Q==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/babel-plugin-codegen": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.86.3.tgz", + "integrity": "sha512-O6Xza4JBGPIU8J7YbKTyBoYL4thpy8jMW/oaLDWdAyOwYHKIjK47pAL5HUEbOe2bWz2PEKjbYRF2ApkJv1ottQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@react-native/codegen": "0.86.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/codegen": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.86.3.tgz", + "integrity": "sha512-Ux4jHi0fh+bdtVEcL0gaPLbY56V+SvFUDl/8sRAE1jdb4k+o7fT/4Nc29yz4X+qfjstkSqObQTMBGhdzxH9JvA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/parser": "^7.29.0", + "hermes-parser": "0.36.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "tinyglobby": "^0.2.15", + "yargs": "^17.6.2" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@babel/core": "*" + } + }, + "node_modules/@react-native/codegen/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/@react-native/codegen/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/@react-native/community-cli-plugin": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/community-cli-plugin/-/community-cli-plugin-0.86.3.tgz", + "integrity": "sha512-qSDL9LQc5mZSZPNczT95WU9YQuPzxBklgON9vLhhqfI0yWIwKInqFx88dQ/uiEXBtf0yossthaQIqA4Ml6bF6g==", + "license": "MIT", + "dependencies": { + "@react-native/dev-middleware": "0.86.3", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "metro": "^0.84.3", + "metro-config": "^0.84.3", + "metro-core": "^0.84.3", + "semver": "^7.1.3" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native-community/cli": "*", + "@react-native/metro-config": "0.86.3" + }, + "peerDependenciesMeta": { + "@react-native-community/cli": { + "optional": true + }, + "@react-native/metro-config": { + "optional": true + } + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "license": "MIT" + }, + "node_modules/@react-native/community-cli-plugin/node_modules/hermes-estree": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.35.0.tgz", + "integrity": "sha512-xVx5Opwy8Oo1I5yGpVRhCvWL/iV3M+ylksSKVNlxxD90cpDpR/AR1jLYqK8HWihm065a6UI3HeyAmYzwS8NOOg==", + "license": "MIT" + }, + "node_modules/@react-native/community-cli-plugin/node_modules/hermes-parser": { + "version": "0.35.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.35.0.tgz", + "integrity": "sha512-9JLjeHxBx8T4CAsydZR49PNZUaix+WpQJwu9p2010lu+7Kwl6D/7wYFFJxoz+aXkaaClp9Zfg6W6/zVlSJORaA==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.35.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro/-/metro-0.84.6.tgz", + "integrity": "sha512-ty/tx/imE5Ph2gvPU788Ckim6RAC2tyZxLz2hWRgC9U7/fGKV7qY/slADy0qHPEw9cpUgc4BfmV3oUsYwm7oeg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "accepts": "^2.0.0", + "ci-info": "^2.0.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "error-stack-parser": "^2.0.6", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "hermes-parser": "0.35.0", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "jsc-safe-url": "^0.2.2", + "lodash.throttle": "^4.1.1", + "metro-babel-transformer": "0.84.6", + "metro-cache": "0.84.6", + "metro-cache-key": "0.84.6", + "metro-config": "0.84.6", + "metro-core": "0.84.6", + "metro-file-map": "0.84.6", + "metro-resolver": "0.84.6", + "metro-runtime": "0.84.6", + "metro-source-map": "0.84.6", + "metro-symbolicate": "0.84.6", + "metro-transform-plugins": "0.84.6", + "metro-transform-worker": "0.84.6", + "mime-types": "^3.0.1", + "nullthrows": "^1.1.1", + "serialize-error": "^2.1.0", + "source-map": "^0.5.6", + "throat": "^5.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "metro": "src/cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-babel-transformer": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-babel-transformer/-/metro-babel-transformer-0.84.6.tgz", + "integrity": "sha512-B1ozl6KxFHbQjXZ2U8WiTPWXylqiD3V4EXyV5r0fqETOrqIrJlBvbVTD82UxIN+B3Vpe4l8G1tb15ocd2RyOQA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "flow-enums-runtime": "^0.0.6", + "hermes-parser": "0.35.0", + "metro-cache-key": "0.84.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-cache": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-cache/-/metro-cache-0.84.6.tgz", + "integrity": "sha512-KBJVpb02oKNO+jlWQ1Aax7cd11YP0MClDaMgx6C4MhyZ9g95J/dC1Bo6sCzXaU9L9h/w5cQfT5zDkGW5sOj99A==", + "license": "MIT", + "dependencies": { + "exponential-backoff": "^3.1.1", + "flow-enums-runtime": "^0.0.6", + "https-proxy-agent": "^7.0.5", + "metro-core": "0.84.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-cache-key": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-cache-key/-/metro-cache-key-0.84.6.tgz", + "integrity": "sha512-6tyXt1BZ/3U183XV48oif7Nm65TE+pTo0Vs31u4FxMgqiYQR/SiSr3RoXn3pmy4pd280ZmLmct0x3jZpu0pV4A==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-config": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-config/-/metro-config-0.84.6.tgz", + "integrity": "sha512-cD2mLEofcuV26kxSV4hfV1Pbuh6igdUhYQriO8ZVeJ7HyZfq4/wqHV0gSR7fEO8NUp9odr60/Kzm5YakmghWwQ==", + "license": "MIT", + "dependencies": { + "connect": "^3.6.5", + "flow-enums-runtime": "^0.0.6", + "jest-validate": "^29.7.0", + "metro": "0.84.6", + "metro-cache": "0.84.6", + "metro-core": "0.84.6", + "metro-runtime": "0.84.6", + "yaml": "^2.6.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-core": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-core/-/metro-core-0.84.6.tgz", + "integrity": "sha512-ZqYskM8+f3PFMosBDJd8DLqRvT1ltRGhWL/ZfTpBvf4SoadO30VVrdhAoHFVGz3G1CJpWa3fgZH7nnnX/ybllA==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "lodash.throttle": "^4.1.1", + "metro-resolver": "0.84.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-file-map": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-file-map/-/metro-file-map-0.84.6.tgz", + "integrity": "sha512-ov9VywWBsHPt54Zc7/DxWvqjTwxuZvhfeyHg8ZAMGKtf2PcH1wS3EnoBH7In53Rspk/eXVWAO5+avxunPn081w==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "fb-watchman": "^2.0.0", + "flow-enums-runtime": "^0.0.6", + "graceful-fs": "^4.2.4", + "invariant": "^2.2.4", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "nullthrows": "^1.1.1", + "walker": "^1.0.7" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-minify-terser": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-minify-terser/-/metro-minify-terser-0.84.6.tgz", + "integrity": "sha512-SqhB/Kxrw0XfyW0k8mEscqX/CQopm6Fp3EpdSIPzvKfGgQp9bTMahg9PAdVsXUmIWwErbmFw1VqB+VFBOKksUw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "terser": "^5.15.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-resolver": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-resolver/-/metro-resolver-0.84.6.tgz", + "integrity": "sha512-PplpGc/OCLxgKeLNsLQO/VHsYDIvkcGGr3zNT7UanHCGRqhxKv245o+naV8cRI4/pcLqyc+9j+J0AJbT3Kp5Sg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-runtime": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.6.tgz", + "integrity": "sha512-47EYO/DOai0PA3GoAyDpyXOgHNMwbl/Z9dQlQineDLHkpD42xOwMrIx1crT0+n6F+aGoEyxZmeFwfMzaIqVqKQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-source-map": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.6.tgz", + "integrity": "sha512-4p/EplRbgNxhdqA6kAj1AmIq9qqYXYSAjgxuZ73rYA+p037xDDrxK8CyDKNfbn1k6aMuVt8LV0fpt7NHUTKiZw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.6", + "nullthrows": "^1.1.1", + "ob1": "0.84.6", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-symbolicate": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.6.tgz", + "integrity": "sha512-wJFyyF5ysbVyoYVWGL6GwVMiNnGllwDU2gF096376fl0fc8IFUlADSyNlswj31K9og2sOilXe/FdyWFPczPUYg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.6", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-transform-plugins": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-transform-plugins/-/metro-transform-plugins-0.84.6.tgz", + "integrity": "sha512-pROPMFaj25Y9+3LlLqpBRz/7B/2YPnfEQz/z3juYxkh3FGQ+3c/Qh4E+khPSTdwTw0M6LitLYHMqaHMbQmuBJw==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/metro-transform-worker": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-transform-worker/-/metro-transform-worker-0.84.6.tgz", + "integrity": "sha512-xLTVrOENaHR9DiuhnHS+zcle0rKsCy2CpXSIx7xD+zhJs+qVRNUyV7Z1zIt/0P/d0cSGVx846R+dIjHEudhcww==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.2", + "@babel/generator": "^7.29.1", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "metro": "0.84.6", + "metro-babel-transformer": "0.84.6", + "metro-cache": "0.84.6", + "metro-cache-key": "0.84.6", + "metro-minify-terser": "0.84.6", + "metro-source-map": "0.84.6", + "metro-transform-plugins": "0.84.6", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/ob1": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.6.tgz", + "integrity": "sha512-+s+6zjd0X68hfAcn92NsEPXnSYOF3O7exw/Fj4UfRTB+UL+Tdjdkzu5/4WquXlzqgvNm8FWuuW6/0Yd8S2rYxw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/community-cli-plugin/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/debugger-frontend": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-frontend/-/debugger-frontend-0.86.3.tgz", + "integrity": "sha512-TQmeofQ0PcuylhhlleOeuzHYZfbrgm3gayXzowqUEzgRisTm1D40/J3ggqs7XkQi5HP5ZA3n8dHmKL9vIzPcsw==", + "license": "BSD-3-Clause", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/debugger-shell": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/debugger-shell/-/debugger-shell-0.86.3.tgz", + "integrity": "sha512-O4ds+J7xZfxkbih9T+cAGegBdvKSPKYJm/lDgC9CpEjFMkmzWTpVLU3Qsv9sqZuo58z+sGhIcJfPsCFFWHpqbQ==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "debug": "^4.4.0", + "fb-dotslash": "0.5.8" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/dev-middleware/-/dev-middleware-0.86.3.tgz", + "integrity": "sha512-LiEPTqTg/63bYUnrPyHLfjTDCNhA/+CUqI1+DsA9tYyewtSbULd5awsva6SgE10I+2iMhgKXS3ymkhU/kSCrGA==", + "license": "MIT", + "dependencies": { + "@isaacs/ttlcache": "^1.4.1", + "@react-native/debugger-frontend": "0.86.3", + "@react-native/debugger-shell": "0.86.3", + "chrome-launcher": "^0.15.2", + "chromium-edge-launcher": "^0.3.0", + "connect": "^3.6.5", + "debug": "^4.4.0", + "invariant": "^2.2.4", + "nullthrows": "^1.1.1", + "open": "^7.0.3", + "serve-static": "^1.16.2", + "ws": "^7.5.10" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/open": { + "version": "7.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-7.4.2.tgz", + "integrity": "sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0", + "is-wsl": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@react-native/dev-middleware/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/@react-native/gradle-plugin": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/gradle-plugin/-/gradle-plugin-0.86.3.tgz", + "integrity": "sha512-lxmx0GqLEWRIpZfpYFXlYVIs3ENQwaW6Vmp6oi29l2GoQJ1wZfFZRdMimDWlGEk8LKfHar3QH3iaPMkTcK9lEQ==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/js-polyfills": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.86.3.tgz", + "integrity": "sha512-eYIJ0es967+tePBFQDnl/gidVFxLns3fnbiK6rxscQrGodvuUO6hwxpQnfNynJ8MjbbndImXihXjcnJdc7SzJg==", + "license": "MIT", + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/@react-native/normalize-colors": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.86.3.tgz", + "integrity": "sha512-Cv3CDkprb67GrzuaS9BGbBJC/6G4lIw3nyKOHRKTqTTum4bn37y5+R0Z04L8mcbQN85eEohNrRwb7IOM4j6uvg==", + "license": "MIT" + }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.86.3.tgz", + "integrity": "sha512-1j44NEyNn05Ut40vHAmoSWbsIcybFkMAOBTwQt1PrESyfSS+qBoyU1LGIogNva0VIa0rQyEC5PzbA7R4/7Nhyw==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@types/react": "^19.2.0", + "react": "*", + "react-native": "0.86.3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@redis/bloom": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.3.0.tgz", + "integrity": "sha512-NQ5poYpZr0jv6zazN1cy6JY5kIipKFZ3R9GTL4kOUTX3a9YAfY29U8bsE4lWhVQEM+G4V0BXVC57mubIdDijeA==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/client": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/client/-/client-6.3.0.tgz", + "integrity": "sha512-fHrjwGCBvAANGi+TuRuwKi83sIOzgHr8eh+Uf48w3fSX7Hhrz174NrWqfJhAC2vnN0d/tfaZpdr6+o6JC3QHwg==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@node-rs/xxhash": "^1.1.0", + "@opentelemetry/api": ">=1 <2" + }, + "peerDependenciesMeta": { + "@node-rs/xxhash": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@redis/json": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/json/-/json-6.3.0.tgz", + "integrity": "sha512-yt6vRfPBXtW/3qm53cF81TSQpLMjWdW4/2E9WQ2GgbvsKkuCKCFQIcMiKlil1ZMnezK+IQuS795xKU5psbaegw==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/search": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/search/-/search-6.3.0.tgz", + "integrity": "sha512-RPXAZmKjZZG7Tm7m1w9keFdXzbL91PiSHQBfQRe9xwW40jNsTRbG0FwhIz4QlHPU4gKDAsCy8IQHj4yZl4H9rw==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@redis/time-series": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.3.0.tgz", + "integrity": "sha512-+fJOB8mN1z5WRakCJNh5lVdz1KqvHGSOistsKeitD24QsCtPwyRQXWC0iq6T/i/ubLE/YcguZqPm8F598MKk4A==", + "license": "MIT", + "engines": { + "node": ">= 20.0.0" + }, + "peerDependencies": { + "@redis/client": "^6.3.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", + "integrity": "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==", + "dev": true, + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@turbo/darwin-64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.11.3.tgz", + "integrity": "sha512-D4EIKTgPpxFQL5aZNbNqy57XOiRldM+mwP1ME9ZLM4DJoV3oNK7GKyQWVusz1umjiFNmLvunNGw7Jq4RUyYL3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/darwin-arm64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.11.3.tgz", + "integrity": "sha512-WOvgWIO86LzBQmL9aRFekROing1CZMVDjmdD7Dk6R65qFAFdrlKkufojenjW1AbUAqytpG6xsXzq8UEScdSdDQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/linux-64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.11.3.tgz", + "integrity": "sha512-zIgNl9KkMnsdU54JrnazELatpMJDSktKjD3kjKVliUNMl/5uhFRrqQ+Z1qaACKw/2ybL7xzlsHir9s5q7sYlQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android", + "linux" + ] + }, + "node_modules/@turbo/linux-arm64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.11.3.tgz", + "integrity": "sha512-aJToWHx5JYvpPwIrAt1DuYrHWcpQGjGf0XVPRMKQBHou//JUYR6oowHeUZp9Oq7e3XDWA75E37C2GnNaBqCVnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android", + "linux" + ] + }, + "node_modules/@turbo/windows-64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.11.3.tgz", + "integrity": "sha512-hu5biiwVGdMvHB/vvaoPjtudNBn3TqcsKK2A06ap3O0wnSZwAtTXe92tKTckpecDeVs+IC+4r8s2wXpbdSPWoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@turbo/windows-arm64": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.11.3.tgz", + "integrity": "sha512-naDEb/31UmOOJOChi+LebLMKWKa1kId0yig1yTRZzDViDg1lBw1Ptu5iQYCvOfOOrx6swUUucf3zPWuPGWz7Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/cacheable-request": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz", + "integrity": "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "*", + "@types/keyv": "^3.1.4", + "@types/node": "*", + "@types/responselike": "^1.0.0" + } + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/keyv": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/@types/keyv/-/keyv-3.1.4.tgz", + "integrity": "sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/responselike": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.3.tgz", + "integrity": "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-13.0.0.tgz", + "integrity": "sha512-N8WXpbE6Wgri7KUSvrmQcqrMllKZ9uxkYWMt+mCSGwNc0Hsw9VQTW7ApqI4XNrx6/SaM2QQJCzMPDEXE058s+Q==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "license": "ISC" + }, + "node_modules/@xmldom/xmldom": { + "version": "0.8.15", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.15.tgz", + "integrity": "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/agent-cli-detector": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/agent-cli-detector/-/agent-cli-detector-0.1.7.tgz", + "integrity": "sha512-d8OWDVdZMgjhLUT9ZPgSv/BdFFF9pVuscC0JdUSz3bjwE15gcp6u/o0/JooM2yyAWC49KThFhXlgTXRa9B7yng==", + "license": "MIT", + "bin": { + "agent-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/anser": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/anser/-/anser-1.4.10.tgz", + "integrity": "sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==", + "license": "MIT" + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, + "node_modules/babel-plugin-react-native-web": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.4.tgz", + "integrity": "sha512-FSclsDTGi5EcvpbW3TM7s8GLWwQqo5wI9zQqJr2m0IoJRenSkp1+bVvdeZXTNznI5Ms9Bry7DLBR+C5Shb9gtw==", + "license": "MIT" + }, + "node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.1.tgz", + "integrity": "sha512-ycduwJbvdvIMmVvlAZqGggS+pm5Eu4Bk9pcV9Sm2Z4PJNRVsKkv0g7vHj+LeuC1gHTeF67sJXFOq61IlqCa2hA==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.1" + } + }, + "node_modules/babel-plugin-transform-flow-enums": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-flow-enums/-/babel-plugin-transform-flow-enums-0.0.2.tgz", + "integrity": "sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==", + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-flow": "^7.12.1" + } + }, + "node_modules/babel-preset-expo": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/babel-preset-expo/-/babel-preset-expo-57.0.16.tgz", + "integrity": "sha512-hGsVNbgxYchVyvBiFAVf+uHVMVIGObiWzAsnv8GsM/ZZRnrhGJLD8x/BLtNNycEYSIb23wPPlSqTGiw0iwwJvQ==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.20.5", + "@babel/helper-module-imports": "^7.25.9", + "@babel/plugin-proposal-decorators": "^7.12.9", + "@babel/plugin-proposal-export-default-from": "^7.24.7", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-syntax-export-default-from": "^7.24.7", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-transform-async-generator-functions": "^7.25.4", + "@babel/plugin-transform-async-to-generator": "^7.24.7", + "@babel/plugin-transform-block-scoping": "^7.25.0", + "@babel/plugin-transform-class-properties": "^7.25.4", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.25.4", + "@babel/plugin-transform-destructuring": "^7.24.8", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-flow-strip-types": "^7.25.2", + "@babel/plugin-transform-for-of": "^7.24.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", + "@babel/plugin-transform-object-rest-spread": "^7.24.7", + "@babel/plugin-transform-optional-catch-binding": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", + "@babel/plugin-transform-parameters": "^7.24.7", + "@babel/plugin-transform-private-methods": "^7.24.7", + "@babel/plugin-transform-private-property-in-object": "^7.24.7", + "@babel/plugin-transform-react-display-name": "^7.24.7", + "@babel/plugin-transform-react-jsx": "^7.28.6", + "@babel/plugin-transform-react-jsx-development": "^7.27.1", + "@babel/plugin-transform-react-pure-annotations": "^7.27.1", + "@babel/plugin-transform-runtime": "^7.24.7", + "@babel/plugin-transform-typescript": "^7.25.2", + "@babel/plugin-transform-unicode-regex": "^7.24.7", + "@babel/preset-typescript": "^7.23.0", + "@react-native/babel-plugin-codegen": "0.86.3", + "babel-plugin-react-compiler": "^1.0.0", + "babel-plugin-react-native-web": "~0.21.0", + "babel-plugin-syntax-hermes-parser": "^0.36.0", + "babel-plugin-transform-flow-enums": "^0.0.2", + "debug": "^4.3.4" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.0", + "expo": "*", + "expo-widgets": "^57.0.26", + "react-refresh": ">=0.14.0 <1.0.0" + }, + "peerDependenciesMeta": { + "@babel/runtime": { + "optional": true + }, + "expo": { + "optional": true + }, + "expo-widgets": { + "optional": true + } + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/bplist-creator": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", + "integrity": "sha512-sXaHZicyEEmY86WyueLTQesbeoH/mquvarJaQNbjuOQO+7gbFcDEWqKmcWA4cOTLzFlfgvkiVxolk1k5bBIpmg==", + "license": "MIT", + "dependencies": { + "stream-buffers": "2.2.x" + } + }, + "node_modules/bplist-parser": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz", + "integrity": "sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.2.tgz", + "integrity": "sha512-V2o8YS2EhrKMO9AumnkK0ynynfFlsajpYupwq7S46H5mhcanwxDkNTw3K67wPIcKTH1ffkA4vhMOtAc7gRmPZw==", + "license": "MIT", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/bson": { + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/bson/-/bson-7.3.3.tgz", + "integrity": "sha512-oz3LwE3qGEhTCSyhzQRLY3Bj1NLOsjFMf6tcmY5J8M+3R7EVcmoBfSHWvjUK3yXCLDUsGpIuB3aQL4nDZ1EV/g==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/cacheable-lookup": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz", + "integrity": "sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.6.0" + } + }, + "node_modules/cacheable-request": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-7.0.4.tgz", + "integrity": "sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-response": "^1.0.2", + "get-stream": "^5.1.0", + "http-cache-semantics": "^4.0.0", + "keyv": "^4.0.0", + "lowercase-keys": "^2.0.0", + "normalize-url": "^6.0.1", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cacheable-request/node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chrome-launcher": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/chrome-launcher/-/chrome-launcher-0.15.2.tgz", + "integrity": "sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0" + }, + "bin": { + "print-chrome-path": "bin/print-chrome-path.js" + }, + "engines": { + "node": ">=12.13.0" + } + }, + "node_modules/chromium-edge-launcher": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.3.0.tgz", + "integrity": "sha512-p03azHlGjtyRvFEee3cyvtsRYdniSkwjkzmM/KmVnqT5d7QkkwpJBhis/zCLMYdQMVJ5tt140TBNqqrZPaWeFA==", + "license": "Apache-2.0", + "dependencies": { + "@types/node": "*", + "escape-string-regexp": "^4.0.0", + "is-wsl": "^2.2.0", + "lighthouse-logger": "^1.0.0", + "mkdirp": "^1.0.4" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha512-8lgKz8LmCRYZZQDpRyT2m5rKJ08TnU4tR9FFFW2rxpxR1FzWi4PQ/NfyODchAatHaUgnSPVcx/R5w6NuTBzFiw==", + "license": "MIT", + "dependencies": { + "restore-cursor": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-response": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/clone-response/-/clone-response-1.0.3.tgz", + "integrity": "sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.2.tgz", + "integrity": "sha512-o8vI5RE5A6EVVOd9o41jKp41aJom+QTEO/Bx8MYNjexMo/Bv2WOjUfZr+aL0WnYSgymUy6zeguqLTsIhV0gMvQ==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "destroy": "1.2.0", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/connect": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", + "integrity": "sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "finalhandler": "1.1.2", + "parseurl": "~1.3.3", + "utils-merge": "1.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/connect/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/connect/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/core-js-compat": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.50.0.tgz", + "integrity": "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.7" + }, + "engines": { + "node": ">=6.4.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-in-js-utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-3.1.0.tgz", + "integrity": "sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==", + "license": "MIT", + "dependencies": { + "hyphenate-style-name": "^1.0.3" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dnssd-advertise": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/dnssd-advertise/-/dnssd-advertise-1.1.6.tgz", + "integrity": "sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==", + "license": "MIT" + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.458", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.458.tgz", + "integrity": "sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/expo": { + "version": "57.0.30", + "resolved": "https://registry.npmjs.org/expo/-/expo-57.0.30.tgz", + "integrity": "sha512-dpNWbsqiAHkgInOBClv5PxKCSMAly1n0oBVoIjS/yv8LQtRgDqSkWAnJq16cExndKrgONxdItY+O3J8u6MNYlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "@expo/cli": "^57.0.31", + "@expo/config": "~57.0.10", + "@expo/config-plugins": "~57.0.10", + "@expo/devtools": "~57.0.1", + "@expo/dom-webview": "~57.0.1", + "@expo/fingerprint": "^0.20.14", + "@expo/local-build-cache-provider": "^57.0.9", + "@expo/log-box": "^57.0.4", + "@expo/metro": "~56.0.2", + "@expo/metro-config": "~57.0.13", + "@ungap/structured-clone": "^1.3.0", + "babel-preset-expo": "~57.0.16", + "expo-asset": "~57.0.20", + "expo-constants": "~57.0.22", + "expo-file-system": "~57.0.7", + "expo-font": "~57.0.4", + "expo-keep-awake": "~57.0.2", + "expo-modules-autolinking": "~57.0.16", + "expo-modules-core": "~57.0.23", + "pretty-format": "^29.7.0", + "react-refresh": "^0.14.2", + "whatwg-url-minimum": "^0.1.2" + }, + "bin": { + "expo": "bin/cli", + "expo-modules-autolinking": "bin/autolinking", + "fingerprint": "bin/fingerprint" + }, + "peerDependencies": { + "@expo/dom-webview": "*", + "@expo/metro-runtime": "*", + "react": "*", + "react-dom": "*", + "react-native": "*", + "react-native-web": "*", + "react-native-webview": "*" + }, + "peerDependenciesMeta": { + "@expo/dom-webview": { + "optional": true + }, + "@expo/metro-runtime": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native-web": { + "optional": true + }, + "react-native-webview": { + "optional": true + } + } + }, + "node_modules/expo-asset": { + "version": "57.0.20", + "resolved": "https://registry.npmjs.org/expo-asset/-/expo-asset-57.0.20.tgz", + "integrity": "sha512-xm1p3Squ/dCzipISO1TE5+kDsIFEUc6DxEtRe2rRgPMDLPi6AumLlrNorFxetiP6zrvxK23xzVlNng/g7o/+aA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.11.6", + "expo-constants": "~57.0.22" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-auth-session": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-57.0.16.tgz", + "integrity": "sha512-u/RUGLE5AEAhBor6QhlgSeO53i5UtZgizlO1uEH76VV9/yYrMJxKEy1M6Olqix7Xmuv97jdouyrXZMgDR8GxnA==", + "license": "MIT", + "dependencies": { + "expo-application": "~57.0.3", + "expo-constants": "~57.0.22", + "expo-crypto": "~57.0.4", + "expo-linking": "~57.0.13", + "expo-web-browser": "~57.0.3", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-auth-session/node_modules/expo-application": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-57.0.3.tgz", + "integrity": "sha512-t/CxeLio0NjY9/cOak1tQp62/hAUjzJxmhR4rVUR7K5bb9YoEtfl1ZekTn+Iuh+dYOMYzF0l72ODMSmY3CR1NA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-constants": { + "version": "57.0.22", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.22.tgz", + "integrity": "sha512-+mBjnzMWMKIrQa7BP7LTDRC0W7cI+q12/sZns4HzVQJGzgGoAdRJSTEKiObxIw/3EswKXlESkRob8upiL95Q4A==", + "license": "MIT", + "dependencies": { + "@expo/env": "~2.4.3" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-crypto": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-57.0.4.tgz", + "integrity": "sha512-LqMMs/N6wvwZyw4deVmcl3AgXgqMwCVQ9WyWf2eaWyrk99TXSKk5da3QspiLOOjnn5Qv9f8C50cDNuoyLThpdw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-file-system": { + "version": "57.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-57.0.7.tgz", + "integrity": "sha512-1vkZRadOsIputFDBcclqBa1pO3iXOO6iIsdqJipQWw4V5TSnWgmRTuOF7ro8IzrVue+PHbum++KCG3Tawem4cg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-font": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-57.0.4.tgz", + "integrity": "sha512-7WOMC2oA6xCfOm8bbofqWn2bIqyWFm7YLEnpc/gA1MJH0Ll0TfT/3+CqRkoW2eNRWMS8sxrz5WukUmSvmNquHA==", + "license": "MIT", + "dependencies": { + "fontfaceobserver": "^2.1.0" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-keep-awake": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-57.0.2.tgz", + "integrity": "sha512-GqgH746wtJImmsbyHmCQoi7cOslKuWQBbJHXr53S35Bpf5UvxCAztvnitUEd7D0QvFN2rvkOhylbZpgZZfBLlQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, + "node_modules/expo-linking": { + "version": "57.0.13", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-57.0.13.tgz", + "integrity": "sha512-1cjN7Ely0FZqlZItfPYjl6jjrCFLRevrIE/H5XhA6tgDm9uhTaSyJQXyftgt2FvJZ7liheDft7WMnPB9Uktx+A==", + "license": "MIT", + "dependencies": { + "expo-constants": "~57.0.22", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-modules-autolinking": { + "version": "57.0.16", + "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-57.0.16.tgz", + "integrity": "sha512-OJh6rMMIIZ/dfwOk2FufJjjsUSw5Boy1Y5Tl+zbBmua/FcD03eEg097dkYIf3iBHVkQ6q9eR08qT88qxvnelow==", + "license": "MIT", + "dependencies": { + "@expo/require-utils": "^57.0.6", + "@expo/spawn-async": "^1.8.0", + "chalk": "^4.1.0", + "commander": "^7.2.0" + }, + "bin": { + "expo-modules-autolinking": "bin/expo-modules-autolinking.js" + } + }, + "node_modules/expo-modules-core": { + "version": "57.0.23", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-57.0.23.tgz", + "integrity": "sha512-vZQoSlhqU6me5K4KCl42yXB0x3zqAiG2FAjzHmbuez77CslJJQ+82cmJoHC3tN5X8o/KxRArJejKNQIeciyoXA==", + "license": "MIT", + "dependencies": { + "@expo/expo-modules-macros-plugin": "0.6.1", + "expo-modules-jsi": "~57.1.2", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-worklets": "^0.7.4 || ^0.8.0 || ^0.9.0 || ^0.10.0" + }, + "peerDependenciesMeta": { + "react-native-worklets": { + "optional": true + } + } + }, + "node_modules/expo-modules-jsi": { + "version": "57.1.2", + "resolved": "https://registry.npmjs.org/expo-modules-jsi/-/expo-modules-jsi-57.1.2.tgz", + "integrity": "sha512-DYhBwO0fqcKIUma4jF16zRDP/m6ZbawEZbo4eX3gdekREIFqrrz8HKWXydgNUqOyOQcUkdrkZatxi2I9+V6E8g==", + "license": "MIT", + "peerDependencies": { + "react-native": "*" + } + }, + "node_modules/expo-secure-store": { + "version": "57.0.4", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-57.0.4.tgz", + "integrity": "sha512-7zBA1RThURTXdP4w65kBzZNbcSsfArqVd+p+ETAnPRWXO8J296Qd0YaCVyRpJ0HSwlCnGdvOnqO+NgSreHsOhA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-server": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.3.tgz", + "integrity": "sha512-aK+LdKzauHSGmsOStZtyxdzv0zWssCkxTw3m4QuOhfDSJsZaMRTd9O41d8ixU/QfELTbaJ0oRNcF7JFV/7O9YQ==", + "license": "MIT", + "engines": { + "node": ">=20.16.0" + } + }, + "node_modules/expo-status-bar": { + "version": "57.0.1", + "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-57.0.1.tgz", + "integrity": "sha512-Xwaq1gAoVRWx5dPG5VhT5RSbnI9OilhZnO5qoPBnUaBAa5VzRzfdS8q0/bsPt0jR2DKLtGuP0bQ6efMJ4RIMDg==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, + "node_modules/expo-web-browser": { + "version": "57.0.3", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-57.0.3.tgz", + "integrity": "sha512-+ecvhyS/PdWkpv4ai6FF1Trd6Y/4kn/eQ3O86mie2ecU3w+I8bKZh13kppY/jw1uSL7yikt03L5W/iiHojrFwQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "license": "Apache-2.0" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fb-dotslash": { + "version": "0.5.8", + "resolved": "https://registry.npmjs.org/fb-dotslash/-/fb-dotslash-0.5.8.tgz", + "integrity": "sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==", + "license": "(MIT OR Apache-2.0)", + "bin": { + "dotslash": "bin/dotslash" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.4.tgz", + "integrity": "sha512-tGQtaoYisPDriJBN0mT5yZSyNETnJGA1gTYxaVRAE5KUO3LI9kPUc9WIu8JXcxaIci+faIKlzXqXyPgrmubtBQ==", + "license": "MIT", + "dependencies": { + "bser": "2.1.2" + } + }, + "node_modules/fbjs": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-3.0.5.tgz", + "integrity": "sha512-ztsSx77JBtkuMrEypfhgc3cI0+0h+svqeie7xHbh1k/IKdcydnvadp/mUaGgjAOXQmQSxsqgaRhS3q9fy+1kxg==", + "license": "MIT", + "dependencies": { + "cross-fetch": "^3.1.5", + "fbjs-css-vars": "^1.0.0", + "loose-envify": "^1.0.0", + "object-assign": "^4.1.0", + "promise": "^7.1.1", + "setimmediate": "^1.0.5", + "ua-parser-js": "^1.0.35" + } + }, + "node_modules/fbjs-css-vars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz", + "integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==", + "license": "MIT" + }, + "node_modules/fbjs/node_modules/promise": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", + "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.3" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fetch-nodeshim": { + "version": "0.4.10", + "resolved": "https://registry.npmjs.org/fetch-nodeshim/-/fetch-nodeshim-0.4.10.tgz", + "integrity": "sha512-m6I8ALe4L4XpdETy7MJZWs6L1IVMbjs99bwbpIKphxX+0CTns4IKDWJY0LWfr4YsFjfg+z1TjzTMU8lKl8rG0w==", + "license": "MIT" + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", + "integrity": "sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "~2.3.0", + "parseurl": "~1.3.3", + "statuses": "~1.5.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/flow-enums-runtime": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz", + "integrity": "sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==", + "license": "MIT" + }, + "node_modules/fontfaceobserver": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/fontfaceobserver/-/fontfaceobserver-2.3.0.tgz", + "integrity": "sha512-6FPvD/IVyT4ZlNe7Wcn5Fb/4ChigpucKYSvD6a+0iMoLn2inpo711eyIcKjmDtE5XNcgAkSH9uN/nfAeZzHEfg==", + "license": "BSD-2-Clause" + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-stream": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", + "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/getenv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/getenv/-/getenv-2.0.0.tgz", + "integrity": "sha512-VilgtJj/ALgGY77fiLam5iD336eSWi96Q15JSAG1zi8NRBysm3LXKdGnHb4m5cuyxvOLQQKWpBZAT6ni4FI2iQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz", + "integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/got": { + "version": "11.8.6", + "resolved": "https://registry.npmjs.org/got/-/got-11.8.6.tgz", + "integrity": "sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.0.0", + "@szmarczak/http-timer": "^4.0.5", + "@types/cacheable-request": "^6.0.1", + "@types/responselike": "^1.0.0", + "cacheable-lookup": "^5.0.3", + "cacheable-request": "^7.0.2", + "decompress-response": "^6.0.0", + "http2-wrapper": "^1.0.0-beta.5.2", + "lowercase-keys": "^2.0.0", + "p-cancelable": "^2.0.0", + "responselike": "^2.0.0" + }, + "engines": { + "node": ">=10.19.0" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.1.tgz", + "integrity": "sha512-guv1nQ6IJ7S83NRFPWc3SA7IBZrdNC9kapwOq6uXvF4wP+sDCgjzQbKPCoyYmoyZRzztF/n/c36l/rccCZSiCw==", + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.36.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.1.tgz", + "integrity": "sha512-GApNk4zLHi2UWoWZZkx7LNCOSzLSc5lB55pZ/PhK7ycFeg7u5LcF88p/WbpIi1XUDtE0MpHE3uRR3u3KB7TjSQ==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.1" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/hosted-git-info": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", + "integrity": "sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w==", + "license": "ISC", + "dependencies": { + "lru-cache": "^10.0.1" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/http-cache-semantics": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.3.0.tgz", + "integrity": "sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http2-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", + "integrity": "sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.0.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/hyphenate-style-name": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz", + "integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==", + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/inline-style-prefixer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-7.0.1.tgz", + "integrity": "sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==", + "license": "MIT", + "dependencies": { + "css-in-js-utils": "^3.1.0" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-core-module": { + "version": "2.17.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.17.0.tgz", + "integrity": "sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jimp-compact": { + "version": "0.16.1", + "resolved": "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz", + "integrity": "sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww==", + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsc-safe-url": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz", + "integrity": "sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==", + "license": "0BSD" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lan-network": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/lan-network/-/lan-network-0.2.1.tgz", + "integrity": "sha512-ONPnazC96VKDntab9j9JKwIWhZ4ZUceB4A9Epu4Ssg0hYFmtHZSeQ+n15nIwTFmcBUKtExOer8WTJ4GF9MO64A==", + "license": "MIT", + "bin": { + "lan-network": "dist/lan-network-cli.js" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lighthouse-logger": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", + "integrity": "sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==", + "license": "Apache-2.0", + "dependencies": { + "debug": "^2.6.9", + "marky": "^1.2.2" + } + }, + "node_modules/lighthouse-logger/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/lighthouse-logger/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.throttle": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz", + "integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", + "integrity": "sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.0.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/log-symbols/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/log-symbols/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/log-symbols/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/log-symbols/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowercase-keys": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz", + "integrity": "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/marky": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz", + "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", + "license": "Apache-2.0" + }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, + "node_modules/merge-options": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz", + "integrity": "sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==", + "license": "MIT", + "dependencies": { + "is-plain-obj": "^2.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mimic-response": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-1.0.1.tgz", + "integrity": "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mongodb": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.7.0.tgz", + "integrity": "sha512-XG1xXEqyouAV8GCb3PuRaujofeXKpAJAPvXYWZgHl6Dz4QHSJ1nRLl0+GpqaLTwKvC0RFE9truOur34RNFY1nw==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.5.0", + "bson": "^7.3.2", + "mongodb-connection-string-url": "^7.0.2" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.806.0", + "@mongodb-js/zstd": "^7.0.0", + "gcp-metadata": "^7.0.1", + "kerberos": "^7.0.0", + "mongodb-client-encryption": "^7.2.0", + "snappy": "^7.3.2", + "socks": "^2.8.6" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-7.0.2.tgz", + "integrity": "sha512-ZoS07RoFqpKYQwAk59qmrx8+jJHNHU30UjlU96QktiGn1ltvDr+vCznLX5DiUBLEpMAHatHNWV1nM/74ul66kA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^13.0.0", + "whatwg-url": "^14.1.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multitars": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/multitars/-/multitars-1.0.2.tgz", + "integrity": "sha512-6GwVw5eLi9sThdtlS4PKwC7yRLaf45pYhIEzKBHdKxi+YOXGKFX8acIniH+Uh/+k9mS2lQOupTccjoe5r0/1IQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-forge": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", + "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-url": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", + "integrity": "sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-package-arg": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-11.0.3.tgz", + "integrity": "sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw==", + "license": "ISC", + "dependencies": { + "hosted-git-info": "^7.0.0", + "proc-log": "^4.0.0", + "semver": "^7.3.5", + "validate-npm-package-name": "^5.0.0" + }, + "engines": { + "node": "^16.14.0 || >=18.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/on-finished": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", + "integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha512-oyyPpiMaKARvvcgip+JV+7zci5L8D1W9RZIz2l1o08AM3pfspitVWnPt3mzHcBPp12oYMTy0pqrFs/C+m3EwsQ==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", + "integrity": "sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg==", + "license": "MIT", + "dependencies": { + "chalk": "^2.4.2", + "cli-cursor": "^2.1.0", + "cli-spinners": "^2.0.0", + "log-symbols": "^2.2.0", + "strip-ansi": "^5.2.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/ora/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/ora/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/ora/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/ora/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ora/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/p-cancelable": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", + "integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-png": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/parse-png/-/parse-png-2.1.0.tgz", + "integrity": "sha512-Nt/a5SfCLiTnQAjx3fHlqp8hRgTL3z7kTQZzvIMS9uCAepnCyjpdEc6M/sz69WqMBdaDBw9sF1F1UaHROYzGkQ==", + "license": "MIT", + "dependencies": { + "pngjs": "^3.3.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/plist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/plist/-/plist-3.1.1.tgz", + "integrity": "sha512-ZIfcLJC+7E7FBFnDxm9MPmt7D+DidyQ26lewieO75AdhA2ayMtsJSES0iWzqJQbcVRSrTufQoy0DR94xHue0oA==", + "license": "MIT", + "dependencies": { + "@xmldom/xmldom": "^0.9.10", + "base64-js": "^1.5.1", + "xmlbuilder": "^15.1.1" + }, + "engines": { + "node": ">=10.4.0" + } + }, + "node_modules/plist/node_modules/@xmldom/xmldom": { + "version": "0.9.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.12.tgz", + "integrity": "sha512-5AXjrcMClTryPe9LgZrygpB1lj7s0S9E0+W+AHaVKAVyHanafK86iPSvG5xHVSp/jC+VH1UXu0TAEmY279xH7A==", + "license": "MIT", + "engines": { + "node": ">=14.6" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/proc-log": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz", + "integrity": "sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/promise": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz", + "integrity": "sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==", + "license": "MIT", + "dependencies": { + "asap": "~2.0.6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/qrcode-generator": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz", + "integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==", + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/react": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-devtools-core": { + "version": "6.1.5", + "resolved": "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz", + "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", + "license": "MIT", + "dependencies": { + "shell-quote": "^1.6.1", + "ws": "^7" + } + }, + "node_modules/react-devtools-core/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.3" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-native": { + "version": "0.86.3", + "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.86.3.tgz", + "integrity": "sha512-JR5s3bM9ezud+Mw24GlNXNfthqPIKwrQgPPJcam+L97t2sKjjEavhCzBn+fyqZZRcM5+XlhYxpTxVkK7e1n38Q==", + "license": "MIT", + "dependencies": { + "@react-native/assets-registry": "0.86.3", + "@react-native/codegen": "0.86.3", + "@react-native/community-cli-plugin": "0.86.3", + "@react-native/gradle-plugin": "0.86.3", + "@react-native/js-polyfills": "0.86.3", + "@react-native/normalize-colors": "0.86.3", + "@react-native/virtualized-lists": "0.86.3", + "abort-controller": "^3.0.0", + "anser": "^1.4.9", + "ansi-regex": "^5.0.0", + "babel-plugin-syntax-hermes-parser": "0.36.0", + "base64-js": "^1.5.1", + "commander": "^12.0.0", + "flow-enums-runtime": "^0.0.6", + "hermes-compiler": "250829098.0.17", + "invariant": "^2.2.4", + "memoize-one": "^5.0.0", + "metro-runtime": "^0.84.3", + "metro-source-map": "^0.84.3", + "nullthrows": "^1.1.1", + "pretty-format": "^29.7.0", + "promise": "^8.3.0", + "react-devtools-core": "^6.1.5", + "react-refresh": "^0.14.0", + "regenerator-runtime": "^0.13.2", + "scheduler": "0.27.0", + "semver": "^7.1.3", + "stacktrace-parser": "^0.1.10", + "tinyglobby": "^0.2.15", + "whatwg-fetch": "^3.0.0", + "ws": "^7.5.10", + "yargs": "^17.6.2" + }, + "bin": { + "react-native": "cli.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + }, + "peerDependencies": { + "@react-native/jest-preset": "0.86.3", + "@types/react": "^19.1.1", + "react": "^19.2.3" + }, + "peerDependenciesMeta": { + "@react-native/jest-preset": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-native-safe-area-context": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz", + "integrity": "sha512-/9/MtQz8ODphjsLdZ+GZAIcC/RtoqW9EeShf7Uvnfgm/pzYrJ75y3PV/J1wuAV1T5Dye5ygq4EAW20RoBq0ABQ==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, + "node_modules/react-native-web": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.4.tgz", + "integrity": "sha512-77B6KlalB4osNIck91TNFixPkYPsTbuNEvw8P7IV+4v4/5wZSBRKZnw0Zhx6YjuauDTz71gc8IW+fVhcBMjr5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@react-native/normalize-colors": "^0.74.1", + "fbjs": "^3.0.4", + "inline-style-prefixer": "^7.0.1", + "memoize-one": "^6.0.0", + "nullthrows": "^1.1.1", + "postcss-value-parser": "^4.2.0", + "styleq": "^0.1.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-native-web/node_modules/@react-native/normalize-colors": { + "version": "0.74.89", + "resolved": "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz", + "integrity": "sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==", + "license": "MIT" + }, + "node_modules/react-native-web/node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/babel-plugin-syntax-hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.36.0.tgz", + "integrity": "sha512-LhD0xdoedDw7ansQgXbB2DADLZIK/LRXuWNBPuVzMc5S2WK5GyT89tCM+cQzxFGO0mGyLK6D5TrVOJJzAoDy8Q==", + "license": "MIT", + "dependencies": { + "hermes-parser": "0.36.0" + } + }, + "node_modules/react-native/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/react-native/node_modules/hermes-compiler": { + "version": "250829098.0.17", + "resolved": "https://registry.npmjs.org/hermes-compiler/-/hermes-compiler-250829098.0.17.tgz", + "integrity": "sha512-qG1PXzTEtriF6oQLZF3vyHhSMxOdW5h2TqqLri0rdpstPustd2fSvRZQMVAPdlhgFwBfYnj3OUZtiO6LjYsEFw==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-estree": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.36.0.tgz", + "integrity": "sha512-A1+8zn5oss2CFP7pKsOaxorQG6FNIz1WU1VDqruLPPZl3LVgeE2C5xfFg8Ow6/Ow4mSslLLtYP1J3n38eKyW9w==", + "license": "MIT" + }, + "node_modules/react-native/node_modules/hermes-parser": { + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.36.0.tgz", + "integrity": "sha512-GdpwMmH5x6IpC1cijvcvYnlPB60Mh6kTSF/NFdYV/j56gYdi+0RIakYs+eqOV+bbO0SW7mgVVGSsTJxyPQfo3w==", + "license": "MIT", + "dependencies": { + "hermes-estree": "0.36.0" + } + }, + "node_modules/react-native/node_modules/metro-runtime": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.84.6.tgz", + "integrity": "sha512-47EYO/DOai0PA3GoAyDpyXOgHNMwbl/Z9dQlQineDLHkpD42xOwMrIx1crT0+n6F+aGoEyxZmeFwfMzaIqVqKQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.0", + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/react-native/node_modules/metro-source-map": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.84.6.tgz", + "integrity": "sha512-4p/EplRbgNxhdqA6kAj1AmIq9qqYXYSAjgxuZ73rYA+p037xDDrxK8CyDKNfbn1k6aMuVt8LV0fpt7NHUTKiZw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-symbolicate": "0.84.6", + "nullthrows": "^1.1.1", + "ob1": "0.84.6", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/react-native/node_modules/metro-symbolicate": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/metro-symbolicate/-/metro-symbolicate-0.84.6.tgz", + "integrity": "sha512-wJFyyF5ysbVyoYVWGL6GwVMiNnGllwDU2gF096376fl0fc8IFUlADSyNlswj31K9og2sOilXe/FdyWFPczPUYg==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6", + "invariant": "^2.2.4", + "metro-source-map": "0.84.6", + "nullthrows": "^1.1.1", + "source-map": "^0.5.6", + "vlq": "^1.0.0" + }, + "bin": { + "metro-symbolicate": "src/index.js" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/react-native/node_modules/ob1": { + "version": "0.84.6", + "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.84.6.tgz", + "integrity": "sha512-+s+6zjd0X68hfAcn92NsEPXnSYOF3O7exw/Fj4UfRTB+UL+Tdjdkzu5/4WquXlzqgvNm8FWuuW6/0Yd8S2rYxw==", + "license": "MIT", + "dependencies": { + "flow-enums-runtime": "^0.0.6" + }, + "engines": { + "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" + } + }, + "node_modules/react-native/node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/react-refresh": { + "version": "0.14.2", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", + "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/redis": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/redis/-/redis-6.3.0.tgz", + "integrity": "sha512-XFQbPie1lGpKeUZ8ySYY43yLnQy/iIQtHNvIr/1XdaABpb96rUSdm3Mfl+98FEVbv9ZJIzINMno5s0Nh8E5LEw==", + "license": "MIT", + "dependencies": { + "@redis/bloom": "6.3.0", + "@redis/client": "6.3.0", + "@redis/json": "6.3.0", + "@redis/search": "6.3.0", + "@redis/time-series": "6.3.0" + }, + "engines": { + "node": ">= 20.0.0" + } + }, + "node_modules/regenerate": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.5.0.tgz", + "integrity": "sha512-1s+CxXjJ71i7LNVy8nYwg47XV4y6lAnOEhsMtSKq3WpmPfhl9LuKlTqKIn/3yUpE8lBz+W+TT88Bxyhnr0U4yw==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.3.0.tgz", + "integrity": "sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.5.3.tgz", + "integrity": "sha512-E8VgUoSK+LMQrLboDxavm76MTBGrQhx9RiNuomRmYnZ10Uw12HrI8fkxOOncktb2hBpzIZ9SV/347euQHG5z+g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.5.0", + "regenerate-unicode-properties": "^10.3.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.4", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.13.4.tgz", + "integrity": "sha512-EWV80YgFaVgFrjGCAsV7oybp4GOxbls5RzBg8OB0nKEVXsBJR1JlV8Di1JhCROg3MzTfe1RI8yitIm1eOs68iA==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.13", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.13.tgz", + "integrity": "sha512-Dj/cW9zBV2aRj6XzCxuGIs3ESZKv02jixne3q6jFZYZ3/Tg09gHHviSMDL4kObh9pqSahdRHEnDoaP2gn8YLhA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.17.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-workspace-root": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/resolve-workspace-root/-/resolve-workspace-root-2.0.1.tgz", + "integrity": "sha512-nR23LHAvaI6aHtMg6RWoaHpdR4D881Nydkzi2CixINyg9T00KgaJdJI6Vwty+Ps8WLxZHuxsS0BseWjxSA4C+w==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", + "integrity": "sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lowercase-keys": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==", + "license": "MIT", + "dependencies": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/sandbox-cli-detector": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/sandbox-cli-detector/-/sandbox-cli-detector-0.2.0.tgz", + "integrity": "sha512-4lyHX0ZU0AZKwjgZ1InxZAa3PNpyEb8rOQ+Zss1ReYmhNzW0Q+h1zE5nvniXN0HaAWZaZE1zgVNEirb0R7LmNg==", + "license": "MIT", + "bin": { + "sandbox-cli-detector": "dist/cli.js" + }, + "engines": { + "node": ">=18.18" + } + }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serialize-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-2.1.0.tgz", + "integrity": "sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/simple-plist": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/simple-plist/-/simple-plist-1.3.1.tgz", + "integrity": "sha512-iMSw5i0XseMnrhtIzRb7XpQEXepa9xhWxGUojHBL43SIpQuDQkh3Wpy67ZbDzZVr6EKxvwVChnVpdl8hEVLDiw==", + "license": "MIT", + "dependencies": { + "bplist-creator": "0.1.0", + "bplist-parser": "0.3.1", + "plist": "^3.0.5" + } + }, + "node_modules/simple-plist/node_modules/bplist-parser": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz", + "integrity": "sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA==", + "license": "MIT", + "dependencies": { + "big-integer": "1.6.x" + }, + "engines": { + "node": ">= 5.10.0" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/stacktrace-parser": { + "version": "0.1.11", + "resolved": "https://registry.npmjs.org/stacktrace-parser/-/stacktrace-parser-0.1.11.tgz", + "integrity": "sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==", + "license": "MIT", + "dependencies": { + "type-fest": "^0.7.1" + }, + "engines": { + "node": ">=6" } }, - "node_modules/jsqr": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", - "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "node_modules/statuses": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/stream-buffers": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/stream-buffers/-/stream-buffers-2.2.0.tgz", + "integrity": "sha512-uyQK/mx5QjHun80FLJTfaWE7JtwfRMKBLkMne6udYOmvH0CawotVa7TfgYHzAnpphn4+TweIx1QKMnRIbipmUg==", + "license": "Unlicense", + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/structured-headers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz", + "integrity": "sha512-0MP/Cxx5SzeeZ10p/bZI0S6MpgD+yxAhi1BOQ34jgnMXsCq3j1t6tQnZu+KdlL7dvJTLT3g9xN8tl10TqgFMcg==", + "license": "MIT" + }, + "node_modules/styleq": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/styleq/-/styleq-0.1.3.tgz", + "integrity": "sha512-3ZUifmCDCQanjeej1f6kyl/BeP/Vae5EYkQ9iJfUm/QwZvlgnZzyflqAsAWYURdtea8Vkvswu2GrC57h3qffcA==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-hyperlinks": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", + "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0", + "supports-color": "^7.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/terminal-link": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", + "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.2.1", + "supports-hyperlinks": "^2.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.51.2.tgz", + "integrity": "sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/throat": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", + "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toqr": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/toqr/-/toqr-0.1.1.tgz", + "integrity": "sha512-FWAPzCIHZHnrE/5/w9MPk0kK25hSQSH2IKhYh9PyjS3SG/+IEMvlwIHbhz+oF7xl54I+ueZlVnMjyzdSwLmAwA==", + "license": "MIT" + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/turbo": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.11.3.tgz", + "integrity": "sha512-Pfg2dWNOUPwJYbR+eYAcjoBbo4Kmoc8uQb80hzKrBgNEQtxvwf2ucXG7C28MyLVhV/zJSKvChrR+2CP/BONpoA==", "dev": true, - "license": "Apache-2.0" + "license": "MIT", + "bin": { + "turbo": "bin/turbo" + }, + "optionalDependencies": { + "@turbo/darwin-64": "2.11.3", + "@turbo/darwin-arm64": "2.11.3", + "@turbo/linux-64": "2.11.3", + "@turbo/linux-arm64": "2.11.3", + "@turbo/windows-64": "2.11.3", + "@turbo/windows-arm64": "2.11.3" + } }, - "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.7.1.tgz", + "integrity": "sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=8" + } + }, + "node_modules/ua-parser-js": { + "version": "1.0.41", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz", + "integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-7.0.3.tgz", + "integrity": "sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/validate-npm-package-name": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz", + "integrity": "sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vlq": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", + "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", + "license": "MIT" + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "makeerror": "1.0.12" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url-minimum": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url-minimum/-/whatwg-url-minimum-0.1.2.tgz", + "integrity": "sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" }, "bin": { - "playwright": "cli.js" + "node-which": "bin/node-which" }, "engines": { - "node": ">=20" + "node": ">= 8" } }, - "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xcode": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", + "integrity": "sha512-kCz5k7J7XbJtjABOvkc5lJmkiDh8VhjVCGNiqdKCscmVpdVUpEAyXv1xmCLkQJ5dsHqx3IPO4XW+NTDhU/fatA==", "license": "Apache-2.0", + "dependencies": { + "simple-plist": "^1.1.0", + "uuid": "^7.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", + "integrity": "sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xml2js/node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlbuilder": { + "version": "15.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", + "integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==", + "license": "MIT", + "engines": { + "node": ">=8.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "license": "ISC", "bin": { - "playwright-core": "cli.js" + "yaml": "bin.mjs" }, "engines": { - "node": ">=20" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "packages/auth": { + "name": "@blockout/auth", + "version": "0.11.0", + "dependencies": { + "@blockout/progress": "*" + } + }, + "packages/classroom": { + "name": "@blockout/classroom", + "version": "0.11.0", + "dependencies": { + "@blockout/auth": "*" + } + }, + "packages/engine": { + "name": "@blockout/engine", + "version": "0.11.0" + }, + "packages/game-kit": { + "name": "@blockout/game-kit", + "version": "0.11.0", + "dependencies": { + "@blockout/engine": "*", + "@blockout/progress": "*", + "@blockout/ui": "*" + } + }, + "packages/progress": { + "name": "@blockout/progress", + "version": "0.11.0", + "dependencies": { + "@blockout/engine": "*" + } + }, + "packages/ui": { + "name": "@blockout/ui", + "version": "0.11.0" } } } diff --git a/package.json b/package.json index 0b6a843..3b61991 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,32 @@ { "name": "blockout", - "version": "0.1.0", + "version": "0.11.0", "private": true, - "description": "Blockout: a multiplication game. The game has no dependencies; these are for development and testing.", + "type": "module", + "description": "Blockout: multiplication (and more) as area. A Turborepo of plain JavaScript apps and packages.", + "workspaces": [ + "apps/*", + "packages/*" + ], + "packageManager": "npm@11.17.0", + "engines": { + "node": ">=26" + }, "scripts": { - "test": "node --test test/*.test.js", - "classroom": "node server/classroom.js 8080" + "dev": "turbo run dev", + "build": "turbo run build", + "test": "turbo run test", + "lint": "turbo run lint", + "start": "npm run build && node apps/server/src/index.js 8080", + "services": "deploy/blockout/forward.sh" }, "devDependencies": { + "@eslint/js": "^10.0.1", + "eslint": "^10.11.0", + "globals": "^17.12.0", "jsqr": "^1.4.0", - "playwright": "^1.63.0" + "playwright": "^1.63.0", + "turbo": "^2.11.3", + "vite": "^8.3.0" } } diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..7a5b26c --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/auth", + "version": "0.11.0", + "private": true, + "description": "Browser sign-in: dev sign-in or Keycloak (OIDC + PKCE), the bearer token, and syncing a signed-in player's progress.", + "type": "module", + "exports": { + ".": "./src/client.js" + }, + "dependencies": { + "@blockout/progress": "*" + }, + "scripts": { + "lint": "eslint src" + } +} diff --git a/packages/auth/src/client.js b/packages/auth/src/client.js new file mode 100644 index 0000000..966068a --- /dev/null +++ b/packages/auth/src/client.js @@ -0,0 +1,312 @@ +// Blockout sign-in, in the browser: who's playing, their token, and keeping a +// signed-in player's progress in step with the server (server/accounts.js). +// Guests never need any of this: they play exactly as before. +// +// Providers (the server says which, GET /api/auth/config): +// dev a "dev sign-in" form: pick a name and a role (for now) +// keycloak OpenID Connect with PKCE against a Keycloak realm (later; Keycloak +// can broker Google sign-in for @iusd.org students) +// +// Progress: a signed-in player's save lives under its own localStorage key +// (blockout.progress.), so the guest save on a shared Chromebook is +// untouched. The first time an account is used on a device, that device's guest +// progress is merged into the account; after that the server copy wins and the +// local one is just a cache. + +import * as Progress from '@blockout/progress'; +const AUTH_KEY = 'blockout.auth'; // { token, user, refresh?, idToken? } +const GUEST_KEY = 'blockout.progress'; +const MERGED_KEY = 'blockout.mergedInto'; // user ids this device's guest save went into +const PUSH_DELAY_MS = 1500; +const PKCE_KEY = 'blockout.pkce'; +const SIGNED_OUT_KEY = 'blockout.signedOut'; // sessionStorage: the provider a Keycloak sign-out came from + +const read = (key, fallback = null) => { + try { + const v = JSON.parse(localStorage.getItem(key)); + return v === null ? fallback : v; + } catch (_) { + return fallback; + } +}; +const write = (key, value) => { + try { + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + } catch (_) { + // private window etc.: signing in just won't stick + } +}; + +let session = read(AUTH_KEY); // { token, user } +const served = () => location.protocol === 'http:' || location.protocol === 'https:'; + +class AuthError extends Error { + constructor(code, message, status) { + super(message); + this.code = code; + this.status = status; + } +} + +// A Keycloak access token lasts minutes; the refresh token renews it for as +// long as the Keycloak session lasts (a school day). Dev tokens can't be renewed. +const REFRESH_EARLY_S = 60; +let refreshing = null; +const expiresSoon = (s) => { + const exp = claims(s.token).exp; + return Boolean(exp) && exp - Date.now() / 1000 < REFRESH_EARLY_S; +}; + +// Swap the refresh token for a fresh access token. True if it worked. One at a +// time, and another tab may already have done it. +function refresh(force = false) { + if (!refreshing) { + refreshing = (async () => { + const stored = read(AUTH_KEY); + if (stored && session && stored.token !== session.token && stored.user && session.user && stored.user.id === session.user.id) session = stored; + if (!session || !session.refresh) return false; + if (!force && !expiresSoon(session)) return true; // (another tab renewed it) + try { + const c = await config(); + if (c.provider !== 'keycloak') return false; + const res = await fetch(`${c.issuer}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'refresh_token', client_id: c.clientId, refresh_token: session.refresh }), + }); + if (!res.ok) return false; // the Keycloak session is over: sign in again + const t = await res.json(); + saveSession({ ...session, token: t.access_token, refresh: t.refresh_token || session.refresh, idToken: t.id_token || session.idToken }); + return true; + } catch (_) { + return false; + } + })().finally(() => (refreshing = null)); + } + return refreshing; +} + +// A JSON request with the signed-in user's token. +async function api(method, path, body, retried = false) { + if (session && session.refresh && expiresSoon(session)) await refresh(); + let res; + try { + res = await fetch(path, { + method, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(session ? { Authorization: `Bearer ${session.token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + keepalive: method !== 'GET' && JSON.stringify(body || {}).length < 60000, + }); + } catch (e) { + throw new AuthError('offline', 'Can’t reach the server.'); + } + let data; + try { + data = await res.json(); + } catch (e) { + throw new AuthError('offline', 'The server isn’t running here.'); + } + if (res.status === 401 && session && data.error === 'auth') { + // the token expired: renew it once if we can, otherwise back to guest + if (!retried && session.refresh && (await refresh(true))) return api(method, path, body, true); + signOut(false); + } + if (!res.ok) throw new AuthError(data.error || 'server', data.message || 'Something went wrong.', res.status); + return data; +} + +// Public, so it's fetched without the token: refreshing the token needs it +let configCache = null; +async function config() { + if (!configCache) { + let res; + try { + res = await fetch('/api/auth/config'); + } catch (e) { + throw new AuthError('offline', 'Can’t reach the server.'); + } + if (!res.ok) throw new AuthError('server', 'The server isn’t running here.', res.status); + configCache = await res.json(); + } + return configCache; +} + +function saveSession(s) { + session = s; + write(AUTH_KEY, s); +} + +// ---- dev provider +async function signInDev(person) { + const { token, user } = await api('POST', '/api/auth/dev', person); + saveSession({ token, user }); + return user; +} + +// ---- keycloak provider (OIDC authorization code + PKCE) +// A JWT's claims, unchecked: only for what the page shows (the server checks tokens) +const claims = (jwt) => { + try { + return JSON.parse(atob(jwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); + } catch (_) { + return {}; + } +}; +const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +async function signInKeycloak() { + const c = await config(); + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); + const state = b64url(crypto.getRandomValues(new Uint8Array(16))); + sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, back: location.hash })); + const url = new URL(`${c.issuer}/protocol/openid-connect/auth`); + url.search = new URLSearchParams({ + client_id: c.clientId, + redirect_uri: location.origin + location.pathname, + response_type: 'code', + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }); + location.assign(url.toString()); +} + +// Back from Keycloak with ?code=…&state=…: swap the code for tokens. +async function finishKeycloak() { + const q = new URLSearchParams(location.search); + if (!q.has('code') || !q.has('state')) return false; + const saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); + sessionStorage.removeItem(PKCE_KEY); + history.replaceState(null, '', location.pathname + ((saved && saved.back) || '')); + if (!saved || saved.state !== q.get('state')) return false; + const c = await config(); + const res = await fetch(`${c.issuer}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', client_id: c.clientId, code: q.get('code'), redirect_uri: location.origin + location.pathname, code_verifier: saved.verifier }), + }); + if (!res.ok) throw new AuthError('auth', 'Signing in didn’t work. Please try again.'); + const t = await res.json(); + // the ID token is only kept to end the Keycloak session on sign out + saveSession({ token: t.access_token, refresh: t.refresh_token, idToken: t.id_token, user: null }); + const { user } = await api('GET', '/api/me'); + saveSession({ ...session, user }); + return true; +} + +// After something changed the account (a new role from an invite): ask again who we are +async function refreshUser() { + if (!session) return null; + const { user } = await api('GET', '/api/me'); + saveSession({ ...session, user }); + return user; +} + +// Signing out of a Keycloak account also ends the Keycloak session; otherwise +// the next "Sign in" on a shared Chromebook would quietly be the same child. +// reload = false (an expired token) only forgets the token here. +function signOut(reload = true) { + flushProgress(); // keepalive: it survives leaving the page + const idToken = session && session.idToken; + saveSession(null); + if (!reload) return; + if (!idToken) { + location.reload(); + return; + } + try { + // Keycloak names the identity provider ("google") when the account came from one + sessionStorage.setItem(SIGNED_OUT_KEY, claims(idToken).identity_provider || 'keycloak'); + } catch (_) { + // private window: no "signed out" message + } + config().then((c) => { + const url = new URL(`${c.issuer}/protocol/openid-connect/logout`); + url.search = new URLSearchParams({ id_token_hint: idToken, post_logout_redirect_uri: location.origin + location.pathname, client_id: c.clientId }); + location.assign(url.toString()); + }, () => location.reload()); +} + +// Once, after coming back from a Keycloak sign-out: which provider the account +// came from ('google', or 'keycloak' for a Keycloak password), else null. +function takeSignedOut() { + try { + const from = sessionStorage.getItem(SIGNED_OUT_KEY); + sessionStorage.removeItem(SIGNED_OUT_KEY); + return from; + } catch (_) { + return null; + } +} + +// ---- progress +const user = () => (session && session.user) || null; +const progressKey = () => (user() ? `${GUEST_KEY}.${user().id}` : GUEST_KEY); + +let pushTimer = null; +let pushState = null; +// Called after every local save: sends it to the server shortly after. +function queueProgress(state) { + if (!user()) return; + pushState = state; + clearTimeout(pushTimer); + pushTimer = setTimeout(flushProgress, PUSH_DELAY_MS); +} +function flushProgress() { + clearTimeout(pushTimer); + if (!pushState || !user()) return; + const state = pushState; + pushState = null; + api('PUT', '/api/me/progress', { progress: state }).catch(() => { + pushState = pushState || state; // try again with the next save + }); +} +window.addEventListener('pagehide', flushProgress); + +// After signing in (and on every page load while signed in): bring the +// account's save and this device together. Returns the save to use, or null +// when the local cache is already right. +async function syncProgress(localState) { + if (!user()) return null; + const { progress: remote } = await api('GET', '/api/me/progress'); + const merged = read(MERGED_KEY, []); + const firstTimeHere = !merged.includes(user().id); + let result; + if (firstTimeHere) { + // this device's guest progress goes into the account, once + const guest = read(GUEST_KEY); + result = [remote, guest, localState].filter(Boolean).reduce((a, b) => Progress.mergeProgress(b, a)); + write(MERGED_KEY, [...merged, user().id]); + } else { + result = remote ? Progress.normalize(remote) : null; + } + if (!result) return null; + // Cheats belong to the device: switch this device's back on + for (const code of Object.keys((localState && localState.cheats) || {})) Progress.cheatOn(result, code); + if (firstTimeHere || !remote) await api('PUT', '/api/me/progress', { progress: Progress.withoutCheats(result) }); + return result; +} + +export const signedIn = () => Boolean(user()); +export const token = () => (session ? session.token : null); +export const role = () => (user() ? user().role : 'guest'); + +export { + served, + api, + config, + user, + signInDev, + signInKeycloak, + finishKeycloak, + signOut, + refreshUser, + takeSignedOut, + progressKey, + queueProgress, + flushProgress, + syncProgress, + AuthError, +}; diff --git a/packages/classroom/package.json b/packages/classroom/package.json new file mode 100644 index 0000000..12a0b00 --- /dev/null +++ b/packages/classroom/package.json @@ -0,0 +1,18 @@ +{ + "name": "@blockout/classroom", + "version": "0.11.0", + "private": true, + "description": "Client for the classroom server: requests, live updates (SSE with long-poll fallback, or long polling only) and the class session. client.js for the browser, core.js for anywhere.", + "type": "module", + "exports": { + ".": "./src/client.js", + "./core": "./src/core.js" + }, + "dependencies": { + "@blockout/auth": "*" + }, + "scripts": { + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/packages/classroom/src/client.js b/packages/classroom/src/client.js new file mode 100644 index 0000000..b8b3340 --- /dev/null +++ b/packages/classroom/src/client.js @@ -0,0 +1,34 @@ +// The browser's classroom client (core.js, for this page's own server): the +// token from @blockout/auth, live updates by Server-Sent Events, and which +// class this tab is in kept per tab (sessionStorage), so a reload or a +// Chromebook that went to sleep can rejoin, and several tabs can be different +// students when testing. The screens themselves live in the games. + +import * as Auth from '@blockout/auth'; +import { ClassError, createClient } from './core.js'; + +// The classroom needs the page to come from the classroom server. +const served = () => location.protocol === 'http:' || location.protocol === 'https:'; + +// Reloading or closing the page drops the connection too; that isn't the class going away. +let unloading = false; +window.addEventListener('pagehide', () => (unloading = true)); +window.addEventListener('beforeunload', () => (unloading = true)); + +const client = createClient({ + token: () => Auth.token(), + storage: { get: (k) => sessionStorage.getItem(k), set: (k, v) => (v === null ? sessionStorage.removeItem(k) : sessionStorage.setItem(k, v)) }, + EventSource: window.EventSource, + unloading: () => unloading, + served, +}); + +// Join links look like https://host/#class=ABCD; the teacher's other device +// gets #class=ABCD&play=KEY. +function linkFromHash(hash = location.hash) { + const m = /^#class=([A-Za-z]{4})(?:&play=([0-9a-f]+))?$/.exec(hash || ''); + return m ? { code: m[1].toUpperCase(), play: m[2] || null } : null; +} + +export const { check, health, roomInfo, createRoom, join, joinAsTeacher, joinWithPlayKey, live, roll, leave, sendResult, teacher, listenStudent, listenTeacher, session, saveSession, host, saveHost } = client; +export { ClassError, served, linkFromHash, createClient }; diff --git a/packages/classroom/src/core.js b/packages/classroom/src/core.js new file mode 100644 index 0000000..ebd9c38 --- /dev/null +++ b/packages/classroom/src/core.js @@ -0,0 +1,197 @@ +// Talking to the classroom server (apps/server) from anywhere: JSON requests, +// live updates (Server-Sent Events, or long polling where there are none, like +// a phone), and the class session. The browser's client (client.js) and the +// phone app each make one with what their platform has. +// +// createClient({ base, token, storage, EventSource, unloading, served }) +// base: the server's address ('' for the page's own); token(): the bearer +// token or null (may be async); storage: { get(key), set(key, value) } +// kept synchronously (strings); EventSource: the class, or null to poll; +// unloading(): the page is going away (a dropped connection isn't the +// class going away); served(): can this page reach a server at all + +const SESSION_KEY = 'blockout.class'; // { code, id, key, name } for a student +const HOST_KEY = 'blockout.classHost'; // { code, teacherKey } for the teacher + +class ClassError extends Error { + constructor(code, message) { + super(message); + this.code = code; + } +} + +export function createClient({ base = '', token = () => null, storage = null, EventSource = globalThis.EventSource || null, unloading = () => false, served = () => true } = {}) { + const BUSY_TRIES = 4; + + // A busy class (503 busy: the change wasn't saved, others were writing at that + // instant) is tried again a few times before giving up + async function request(method, path, body, tries = BUSY_TRIES) { + let res; + try { + // Signed in: the server knows who's hosting or joining + const bearer = await token(); + res = await fetch(base + path, { + method, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (e) { + throw new ClassError('offline', 'Can’t reach the classroom server.'); + } + let data; + try { + data = await res.json(); + } catch (e) { + throw new ClassError('offline', 'The classroom server isn’t running here.'); + } + if (res.status === 503 && data.error === 'busy' && tries > 1) { + await new Promise((r) => setTimeout(r, 100 + Math.random() * 200 * (BUSY_TRIES - tries + 1))); + return request(method, path, body, tries - 1); + } + if (!res.ok) throw new ClassError(data.error || 'server', data.message || 'Something went wrong.'); + return data; + } + + // null when the server is there, otherwise why not + async function check() { + if (!served()) return 'file'; + try { + const h = await request('GET', '/api/health'); + return h.ok ? null : 'offline'; + } catch (e) { + return 'offline'; + } + } + + const health = () => request('GET', '/api/health'); + const roomInfo = (code) => request('GET', `/api/rooms/${encodeURIComponent(code)}/info`); + const createRoom = (settings) => request('POST', '/api/rooms', { settings }); + // look: { avatar, title } Wardrobe ids, so classmates see your avatar and title + const join = (code, name, look) => request('POST', `/api/rooms/${encodeURIComponent(code)}/join`, { name, look }); + // The teacher as a player (pairs: playing the odd one out), from the projector + // screen (with the teacher key) or another device (with the play key from its link). + const joinAsTeacher = (h, look) => request('POST', `/api/rooms/${h.code}/join`, { teacher: h.teacherKey, look }); + const joinWithPlayKey = (code, play, look) => request('POST', `/api/rooms/${encodeURIComponent(code)}/join`, { play, look }); + // Pairs: what you're doing on your turn, for your partner to watch ({ phase, rect }) + const live = (s, payload) => request('POST', `/api/rooms/${s.code}/live`, { id: s.id, key: s.key, live: payload }); + const roll = (s) => request('POST', `/api/rooms/${s.code}/roll`, { id: s.id, key: s.key }); + const leave = (s) => request('POST', `/api/rooms/${s.code}/leave`, { id: s.id, key: s.key }); + const sendResult = (s, result) => request('POST', `/api/rooms/${s.code}/result`, { id: s.id, key: s.key, ...result }); + const teacher = (h, action, extra = {}) => request('POST', `/api/rooms/${h.code}/teacher`, { key: h.teacherKey, action, ...extra }); + + // Live updates. handlers: { view(v), gone(reason) }. Returns a close() function. + // First an event stream (EventSource reconnects by itself after a blip). Some + // proxies hold streams back (Cloudflare quick tunnels, some school filters), so if + // nothing arrives in a few seconds we switch to long polling, which gets through + // anything. If the class or the player no longer exists we stop and say why. + const STREAM_WAIT_MS = 4000; + const RETRY_MS = 2000; + + function listen(path, query, handlers) { + let closed = false; + let got = false; + const stop = (reason) => { + if (closed) return; + closed = true; + if (src) src.close(); + handlers.gone(reason); + }; + const deliver = (view) => { + got = true; + handlers.view(view); + }; + + let src = EventSource ? new EventSource(`${base + path}/events?${query}`) : null; + if (src) { + src.addEventListener('view', (e) => deliver(JSON.parse(e.data))); + src.addEventListener('closed', () => stop('closed')); + src.addEventListener('removed', () => stop('removed')); + src.addEventListener('error', () => { + if (closed || unloading() || !src) return; + // Any error answer (a 404, but also a server that's restarting, or too + // busy) closes the stream for good (readyState CLOSED). Polling finds out + // which: it stops on a 404 and keeps trying otherwise. + if (src.readyState === EventSource.CLOSED) poll(); + }); + setTimeout(() => { + if (closed || got) return; + poll(); + }, STREAM_WAIT_MS); + } + + let polling = false; + async function poll() { + if (polling) return; + polling = true; + if (src) src.close(); + src = null; + let version = -1; + while (!closed) { + let data; + try { + const res = await fetch(`${base + path}/poll?${query}&v=${version}`, { cache: 'no-store' }); + if (res.status === 404) return stop('lost'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); // (restarting, busy: try again shortly) + data = await res.json(); + } catch (e) { + if (closed || unloading()) return; + await new Promise((r) => setTimeout(r, RETRY_MS)); // offline for a moment: try again + continue; + } + if (closed) return; + if (data.closed) return stop('closed'); + if (data.removed) return stop('removed'); + if (data.v !== version) { + version = data.v; + deliver(data.view); + } + } + } + + if (!src) poll(); // (no event streams here, like a phone: long polling from the start) + + return () => { + closed = true; + if (src) src.close(); + }; + } + + const listenStudent = (s, handlers) => listen(`/api/rooms/${s.code}`, `id=${encodeURIComponent(s.id)}&key=${encodeURIComponent(s.key)}`, handlers); + const listenTeacher = (h, handlers) => listen(`/api/rooms/${h.code}`, `teacher=${encodeURIComponent(h.teacherKey)}`, handlers); + + const load = (key) => { + try { + return storage ? JSON.parse(storage.get(key)) : null; + } catch (e) { + return null; + } + }; + const store = (key, value) => { + try { + if (storage) storage.set(key, value ? JSON.stringify(value) : null); + } catch (e) {} + }; + + return { + check, + health, + roomInfo, + createRoom, + join, + joinAsTeacher, + joinWithPlayKey, + live, + roll, + leave, + sendResult, + teacher, + listenStudent, + listenTeacher, + session: () => load(SESSION_KEY), + saveSession: (s) => store(SESSION_KEY, s), + host: () => load(HOST_KEY), + saveHost: (h) => store(HOST_KEY, h), + }; +} + +export { ClassError }; diff --git a/packages/classroom/test/core.test.js b/packages/classroom/test/core.test.js new file mode 100644 index 0000000..bdeb744 --- /dev/null +++ b/packages/classroom/test/core.test.js @@ -0,0 +1,46 @@ +// The client anywhere (core.js), as the phone app makes it: the server's +// address, an async token, no event streams (long polling from the start), +// and its own storage. +import test from 'node:test'; +import assert from 'node:assert'; +import { createClient } from '../src/core.js'; + +const noop = () => {}; + +test('without event streams it polls the server’s address, and sends the token', async () => { + const calls = []; + let answers = [ + [200, { v: 1, view: { round: 1 } }], + [200, { v: 2, view: { round: 2 } }], + [200, { closed: true }], + ]; + globalThis.fetch = async (url, opts = {}) => { + calls.push({ url, auth: opts.headers && opts.headers.Authorization }); + if (url.endsWith('/join')) return { status: 200, ok: true, json: async () => ({ id: 'p1', key: 'k' }) }; + const next = answers.shift(); + if (!next) return new Promise(noop); + return { status: next[0], ok: true, json: async () => next[1] }; + }; + const kept = new Map(); + const client = createClient({ + base: 'https://blockout.example', + token: async () => 'tok', + EventSource: null, + storage: { get: (k) => kept.get(k) ?? null, set: (k, v) => (v === null ? kept.delete(k) : kept.set(k, v)) }, + }); + const joined = await client.join('ABCD', 'Sam', {}); + assert.deepEqual(joined, { id: 'p1', key: 'k' }); + assert.equal(calls[0].url, 'https://blockout.example/api/rooms/ABCD/join'); + assert.equal(calls[0].auth, 'Bearer tok'); + client.saveSession({ code: 'ABCD', id: 'p1', key: 'k', name: 'Sam' }); + assert.equal(client.session().name, 'Sam'); + const views = []; + const gone = []; + client.listenStudent({ code: 'ABCD', id: 'p1', key: 'k' }, { view: (v) => views.push(v), gone: (r) => gone.push(r) }); + await new Promise((r) => setTimeout(r, 30)); + assert.deepEqual(views, [{ round: 1 }, { round: 2 }]); + assert.deepEqual(gone, ['closed']); + assert.match(calls[1].url, /^https:\/\/blockout\.example\/api\/rooms\/ABCD\/poll\?id=p1&key=k&v=-1$/); + client.saveSession(null); + assert.equal(client.session(), null); +}); diff --git a/packages/classroom/test/listen.test.js b/packages/classroom/test/listen.test.js new file mode 100644 index 0000000..0ca7651 --- /dev/null +++ b/packages/classroom/test/listen.test.js @@ -0,0 +1,103 @@ +// Live updates in the browser, with a stand-in EventSource and fetch: a class +// is only "lost" when the server says it's gone (404), not when the server is +// restarting or busy for a moment. +import test from 'node:test'; +import assert from 'node:assert'; + +// Just enough of a browser for the client +const noop = () => {}; +const memoryStorage = () => { + const m = new Map(); + return { getItem: (k) => (m.has(k) ? m.get(k) : null), setItem: (k, v) => m.set(k, String(v)), removeItem: (k) => m.delete(k) }; +}; +Object.assign(globalThis, { window: { addEventListener: noop }, location: { protocol: 'http:' }, localStorage: memoryStorage(), sessionStorage: memoryStorage() }); + +class FakeEventSource { + static CONNECTING = 0; + static OPEN = 1; + static CLOSED = 2; + static last = null; + constructor(url) { + this.url = url; + this.readyState = FakeEventSource.CONNECTING; + this.listeners = {}; + FakeEventSource.last = this; + } + addEventListener(name, fn) { + (this.listeners[name] ||= []).push(fn); + } + emit(name, data) { + for (const fn of this.listeners[name] || []) fn({ data: JSON.stringify(data) }); + } + fail() { + this.readyState = FakeEventSource.CLOSED; // what a browser does on an error answer + this.emit('error'); + } + close() { + this.readyState = FakeEventSource.CLOSED; + } +} +globalThis.EventSource = FakeEventSource; + +// fetch answers from a queue of [status, body]; then holds (a long poll) +let answers = []; +let polls = 0; +globalThis.fetch = async () => { + polls++; + const next = answers.shift(); + if (!next) return new Promise(noop); + const [status, body] = next; + return { status, ok: status < 300, json: async () => body }; +}; + +const { listenStudent } = await import('../src/client.js'); +const settle = (ms = 30) => new Promise((r) => setTimeout(r, ms)); + +test('a stream that fails with 503 falls back to polling and keeps the class', async () => { + answers = [ + [503, { error: 'server' }], + [200, { v: 3, view: { round: 3 } }], + ]; + polls = 0; + const views = []; + const gone = []; + const close = listenStudent({ code: 'ABCD', id: 'p1', key: 'k' }, { view: (v) => views.push(v), gone: (r) => gone.push(r) }); + FakeEventSource.last.emit('view', { round: 2 }); + FakeEventSource.last.fail(); // the server answered 503 (restarting) + await settle(2100); // one retry after RETRY_MS + assert.deepEqual(gone, []); + assert.deepEqual(views, [{ round: 2 }, { round: 3 }]); + assert.ok(polls >= 2); + close(); +}); + +test('a 404 means the class is gone', async () => { + answers = [[404, { error: 'room' }]]; + const gone = []; + const close = listenStudent({ code: 'WXYZ', id: 'p1', key: 'k' }, { view: noop, gone: (r) => gone.push(r) }); + FakeEventSource.last.fail(); + await settle(); + assert.deepEqual(gone, ['lost']); + close(); +}); + +test('an answer to a busy class is sent again, and lands', async () => { + const { sendResult } = await import('../src/client.js'); + answers = [ + [503, { error: 'busy', message: 'The class is busy. Try again.' }], + [503, { error: 'busy', message: 'The class is busy. Try again.' }], + [200, { ok: true, score: 12 }], + ]; + polls = 0; + const r = await sendResult({ code: 'ABCD', id: 'p1', key: 'k' }, { round: 1, kind: 'timeout' }); + assert.deepEqual(r, { ok: true, score: 12 }); + assert.equal(polls, 3, 'three tries'); +}); + +test('other errors are not retried', async () => { + const { sendResult } = await import('../src/client.js'); + answers = [[409, { error: 'done', message: 'You already answered.' }]]; + polls = 0; + await assert.rejects(sendResult({ code: 'ABCD', id: 'p1', key: 'k' }, { round: 1, kind: 'timeout' }), /already answered/); + assert.equal(polls, 1); +}); diff --git a/packages/engine/package.json b/packages/engine/package.json new file mode 100644 index 0000000..740237a --- /dev/null +++ b/packages/engine/package.json @@ -0,0 +1,18 @@ +{ + "name": "@blockout/engine", + "version": "0.11.0", + "private": true, + "description": "The board, dice, placement and CPU for multiplication (core.js), one game turn by turn (match.js), and the rules of the other operations (ops/).", + "type": "module", + "exports": { + ".": "./src/core.js", + "./fraction": "./src/ops/fraction.js", + "./boards": "./src/ops/boards.js", + "./variants": "./src/ops/variants.js", + "./match": "./src/match.js" + }, + "scripts": { + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/packages/engine/src/core.js b/packages/engine/src/core.js new file mode 100644 index 0000000..42e8d3a --- /dev/null +++ b/packages/engine/src/core.js @@ -0,0 +1,392 @@ +// Blockout game rules, independent of the UI. +// An ES module (@blockout/engine), used by the games and the server. + +const EMPTY = -1; + +function createBoard(size) { + return { size, cells: new Array(size * size).fill(EMPTY), rects: [] }; +} + +function fits(board, x, y, w, h) { + const n = board.size; + if (x < 0 || y < 0 || x + w > n || y + h > n) return false; + for (let r = y; r < y + h; r++) { + for (let c = x; c < x + w; c++) { + if (board.cells[r * n + c] !== EMPTY) return false; + } + } + return true; +} + +// (Once, the first rectangle of a game had to go in the top-left corner.) +const isValidPlacement = (board, x, y, w, h) => fits(board, x, y, w, h); + +// Both orientations of an a-by-b roll (only one when a === b). +function orientations(a, b) { + return a === b ? [[a, b]] : [[a, b], [b, a]]; +} + +function validPlacements(board, a, b) { + const out = []; + for (const [w, h] of orientations(a, b)) { + for (let y = 0; y + h <= board.size; y++) { + for (let x = 0; x + w <= board.size; x++) { + if (isValidPlacement(board, x, y, w, h)) out.push({ x, y, w, h }); + } + } + } + return out; +} + +function place(board, x, y, w, h, player) { + if (!isValidPlacement(board, x, y, w, h)) throw new Error('Invalid placement'); + const n = board.size; + for (let r = y; r < y + h; r++) { + for (let c = x; c < x + w; c++) board.cells[r * n + c] = player; + } + const rect = { x, y, w, h, player, area: w * h }; + board.rects.push(rect); + return rect; +} + +// Take a rectangle back off the board (e.g. when a player runs out of time). +function removeRect(board, rect) { + const i = board.rects.indexOf(rect); + if (i === -1) throw new Error('Rectangle is not on this board'); + board.rects.splice(i, 1); + const n = board.size; + for (let r = rect.y; r < rect.y + rect.h; r++) { + for (let c = rect.x; c < rect.x + rect.w; c++) board.cells[r * n + c] = EMPTY; + } +} + +function emptyCount(board) { + return board.cells.reduce((sum, v) => sum + (v === EMPTY ? 1 : 0), 0); +} + +function isOpen(board, c, r) { + const n = board.size; + return c >= 0 && r >= 0 && c < n && r < n && board.cells[r * n + c] === EMPTY; +} + +// Number of perimeter neighbours that are walls or already filled. +// Snug placements leave fewer awkward gaps. +function contactScore(board, { x, y, w, h }) { + let contact = 0; + for (let c = x; c < x + w; c++) { + if (!isOpen(board, c, y - 1)) contact++; + if (!isOpen(board, c, y + h)) contact++; + } + for (let r = y; r < y + h; r++) { + if (!isOpen(board, x - 1, r)) contact++; + if (!isOpen(board, x + w, r)) contact++; + } + return contact; +} + +// Count open cells that would end up fully walled in on all four sides +// (only a 1x1 roll could ever use them). +function isolatedHolesAround(board, { x, y, w, h }) { + const n = board.size; + const filled = new Set(); + for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) filled.add(r * n + c); + const open = (c, r) => isOpen(board, c, r) && !filled.has(r * n + c); + let holes = 0; + for (let r = y - 1; r <= y + h; r++) { + for (let c = x - 1; c <= x + w; c++) { + if (!open(c, r)) continue; + if (!open(c - 1, r) && !open(c + 1, r) && !open(c, r - 1) && !open(c, r + 1)) holes++; + } + } + return holes; +} + +// Computer strategy: pack rectangles tightly against walls and other +// rectangles and avoid leaving single-cell holes. Returns null if nothing fits. +function chooseComputerMove(board, a, b, rng = Math.random) { + const options = validPlacements(board, a, b); + if (options.length === 0) return null; + let best = null; + let bestScore = -Infinity; + for (const p of options) { + const perimeter = 2 * (p.w + p.h); + const score = + contactScore(board, p) / perimeter - 0.6 * isolatedHolesAround(board, p) + rng() * 0.05; + if (score > bestScore) { + bestScore = score; + best = p; + } + } + return best; +} + +function rollDie(rng = Math.random, sides = 6) { + return 1 + Math.floor(rng() * sides); +} + +// Difficulty sets the dice: easy d6, medium d8, hard d12. After Hard the +// facts stay within the times table (or just past it) but get harder: +// tricky 12-sided dice without the giveaways (no ×1, ×2, ×5, ×10, ×11) +// master 12-sided dice that lean towards the player's weakest facts +// legend two-digit × one-digit: 11–19 on one die, 2–9 on the other +// DIFFICULTY_SIDES is the biggest face (numbered dice, fit maths). +const DIFFICULTIES = ['easy', 'medium', 'hard', 'tricky', 'master', 'legend']; +const DIFFICULTY_SIDES = { easy: 6, medium: 8, hard: 12, tricky: 12, master: 12, legend: 19 }; +const TRICKY_FACES = [3, 4, 6, 7, 8, 9, 12]; +const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i); + +// The faces of each die: { a: [...], b: [...] } (the same for both except Legend). +// sides: numbered dice other than the difficulty's (a Journey level's). +function diceFaces(difficulty, sides = null) { + if (difficulty === 'tricky') return { a: TRICKY_FACES, b: TRICKY_FACES }; + if (difficulty === 'legend') return { a: range(11, 19), b: range(2, 9) }; + const n = sides || DIFFICULTY_SIDES[difficulty] || 6; + return { a: range(1, n), b: range(1, n) }; +} + +// The new difficulties roll both dice at once from a list of pairs (Legend's +// dice differ; Master weights each fact). Easy/Medium/Hard use createDice. +const usesPairDice = (difficulty) => ['tricky', 'master', 'legend'].includes(difficulty); + +// Legend: about 1 roll in LEGEND_TEEN_SHARE is a teen × 2–9; the rest are +// Tricky's facts. All-teen games filled the board in 3–8 rectangles. +const LEGEND_TEEN_SHARE = 1 / 2; +const PAIR_ROOM = 1 / 6; // share of the board a "comfortable" rectangle covers + +// weight(a, b): how likely a roll is (Master: weak facts more), default even. +// Pairs that can't fit the board at all are left out (Legend on small boards). +function createPairDice(boardSize, rng = Math.random, difficulty = 'tricky', weight = null) { + const fits = ([a, b]) => a <= boardSize && b <= boardSize; + const cross = ({ a: A, b: B }) => A.flatMap((a) => B.map((b) => [a, b])).filter(fits); + // Like the loaded dice: big rectangles are rarer on smaller boards, so a + // game lasts about as long as on Hard (big facts still come up, just less). + const roomy = boardSize * boardSize * PAIR_ROOM; + const sizeBias = (a, b) => 1 / (1 + ((a * b) / roomy) ** 2); + const given = weight || (() => 1); + let pairs = cross(diceFaces(difficulty)); + let w = (a, b) => given(a, b) * sizeBias(a, b); + if (difficulty === 'legend') { + // Each group gets its share of the rolls, with the size bias inside it + const isTeen = (a, b) => a >= 11 && b <= 9; + const teens = pairs; + const tricky = cross(diceFaces('tricky')).filter(([a, b]) => !isTeen(a, b)); // 12 × 7 is already a teen roll + pairs = [...teens, ...tricky]; + const total = (list) => list.reduce((sum, [a, b]) => sum + sizeBias(a, b), 0); + const teenShare = teens.length ? LEGEND_TEEN_SHARE : 0; + const teenTotal = total(teens) || 1; + const trickyTotal = total(tricky); + w = (a, b) => sizeBias(a, b) * (isTeen(a, b) ? teenShare / teenTotal : (1 - teenShare) / trickyTotal); + } + return { + sides: DIFFICULTY_SIDES[difficulty], + pairs, + seen: [], // this game's rolls, as shapes ('3x4'): see rollForBoard + // a weighted pick from `from` (default: every pair) + rollPair(from = pairs) { + const total = from.reduce((sum, [a, b]) => sum + w(a, b), 0); + let r = rng() * total; + for (const [a, b] of from) if ((r -= w(a, b)) <= 0) return [a, b]; + return from[from.length - 1]; + }, + }; +} + +// Can a difficulty be played on this board? Legend needs 12×12 or bigger +// (smaller boards would never see a teen). +const fitsDifficulty = (difficulty, boardSize) => (difficulty === 'legend' ? boardSize >= 12 : !usesPairDice(difficulty) || createPairDice(boardSize, Math.random, difficulty).pairs.length > 0); + +// "Loaded" dice matched to the board size, so small boards aren't swallowed +// by huge rectangles and big boards don't take ages to fill with 1x1s. +// Most rolls come from the common range; each rare face can only turn up a +// limited number of times per game. For a d6 this gives: +// small (<=13): mostly 1-4; 5 at most twice, 6 once +// medium (<=17): mostly 2-5; 1 at most once, 6 twice +// large (18+): mostly 3-6; 1 at most once, 2 twice +// and the same shape stretched over 8 or 12 sides. +function diceProfile(boardSize, sides = 6) { + const rare = {}; + let common; + // A tiny die (a Journey's first levels) rolls every face evenly: loading a + // d2 would almost never show its 2. + if (sides <= 4) return { sides, common: [1, sides], rare }; + if (boardSize <= 13) { + common = [1, Math.round((sides * 2) / 3)]; + for (let f = common[1] + 1; f <= sides; f++) rare[f] = f === sides ? 1 : 2; + } else if (boardSize <= 17) { + common = [Math.floor(sides / 6) + 1, sides - Math.ceil(sides / 6)]; + for (let f = 1; f < common[0]; f++) rare[f] = 1; + for (let f = common[1] + 1; f <= sides; f++) rare[f] = 2; + } else { + common = [Math.round(sides / 3) + 1, sides]; + for (let f = 1; f < common[0]; f++) rare[f] = f === 1 ? 1 : 2; + } + return { sides, common, rare }; +} + +// Chance per die of trying rare faces: about 1 in 6 overall, split between them. +const RARE_TOTAL_CHANCE = 1 / 6; + +// A per-game dice bag's state, as plain data (it can be saved as JSON, so a +// live class can be stored and picked up by another server). +function diceState(boardSize, sides = 6) { + const profile = diceProfile(boardSize, sides); + const remaining = { ...profile.rare }; + return { sides, common: profile.common, remaining, perFace: RARE_TOTAL_CHANCE / Math.max(1, Object.keys(remaining).length), seen: [] }; +} + +// A bag that rolls from a saved state: bag.roll() gives one die, and uses up +// rare faces in state.remaining (the state itself, so it can be saved again). +function diceFromState(state, rng = Math.random) { + const { sides, remaining, perFace } = state; + const [lo, hi] = state.common; + if (!state.seen) state.seen = []; // (a bag saved before rolls were remembered) + return { + sides, + remaining, + seen: state.seen, // this game's rolls, as shapes ('3x4'): see rollForBoard + roll() { + for (const face of Object.keys(remaining)) { + if (remaining[face] > 0 && rng() < perFace) { + remaining[face]--; + return Number(face); + } + } + return lo + Math.floor(rng() * (hi - lo + 1)); + }, + }; +} + +// Returns a per-game dice bag: bag.roll() gives one die. +const createDice = (boardSize, rng = Math.random, sides = 6) => diceFromState(diceState(boardSize, sides), rng); + +// "No room": when a roll doesn't fit anywhere the player has to pass. To keep +// that from happening, rolls can be limited to ones that fit. Modes: 'always' +// (what every game plays now: nobody passes while anything fits), 'end' (only +// near the end of the game) and 'never' (the original rules). +// "Near the end" means fewer empty squares than the larger of the biggest +// possible rectangle (36 / 64 / 144 for 6 / 8 / 12-sided dice) and a fifth of +// the board, so it scales with both board size and difficulty. +// The classic 12x12 board with a d6 gives 36. +const FIT_MODES = ['end', 'always', 'never']; + +function fitThreshold(boardSize, sides = 6) { + return Math.max(sides * sides, Math.round(boardSize * boardSize * 0.2)); +} + +function fitOnlyNow(board, sides = 6, mode = 'end') { + if (mode === 'never') return false; + if (mode === 'always') return true; + return emptyCount(board) < fitThreshold(board.size, sides); +} + +function canFit(board, a, b) { + for (const [w, h] of orientations(a, b)) { + for (let y = 0; y + h <= board.size; y++) { + for (let x = 0; x + w <= board.size; x++) { + if (isValidPlacement(board, x, y, w, h)) return true; + } + } + } + return false; +} + +// Every [a, b] roll (1..sides each, both orders) that fits somewhere on the board. +function fittingRolls(board, sides = 6) { + const out = []; + for (let a = 1; a <= sides; a++) { + for (let b = 1; b <= sides; b++) if (canFit(board, a, b)) out.push([a, b]); + } + return out; +} + +// Rolls worth asking: none with a 1 (1 × 1 is no question, 1 × 7 barely one) +// and none already rolled this game (3 × 4 and 4 × 3 are the same rectangle). +// Roll again until one is both; failing that, one without a 1 even if it's a +// repeat; failing that, whatever the bag rolls. Each preference only holds +// while some roll the dice can make fits that way (the last 1-wide gaps still +// get their 1 × N). The bag remembers its rolls in bag.seen. +const PREFERENCE_REROLLS = 20; +const hasOne = ([a, b]) => a === 1 || b === 1; +const shape = ([a, b]) => (a <= b ? `${a}x${b}` : `${b}x${a}`); +function rollForBoard(board, bag, rng = Math.random, mode = 'always') { + const seen = bag.seen || (bag.seen = []); + const options = (bag.rollPair ? bag.pairs : fittingRolls(board, bag.sides || 6)).filter(([a, b]) => canFit(board, a, b)); + const fresh = (r) => !hasOne(r) && !seen.includes(shape(r)); + const preferences = [fresh, (r) => !hasOne(r)].filter((good) => options.some(good)); + let roll = null; + for (const good of preferences) { + for (let i = 0; i < PREFERENCE_REROLLS && !roll; i++) { + const saved = bag.remaining ? { ...bag.remaining } : null; + const r = rollOnce(board, bag, rng, mode); + if (good(r)) roll = r; + else if (saved) Object.assign(bag.remaining, saved); // (a rejected roll doesn't use up rare faces) + } + if (!roll) { + const list = options.filter(good); + roll = bag.rollPair ? bag.rollPair(list) : list[Math.floor(rng() * list.length)]; + } + break; + } + roll = roll || rollOnce(board, bag, rng, mode); + seen.push(shape(roll)); + return roll; +} + +// Roll two dice from the bag. When fitOnlyNow() says so, re-roll until the +// result fits (without using up rare faces on rejected rolls); if the bag +// can't produce a fitting roll, pick one of the fitting rolls at random. +function rollOnce(board, bag, rng, mode) { + const sides = bag.sides || 6; + if (bag.rollPair) { + // pair dice: near the end (or always), only pairs that fit + if (!fitOnlyNow(board, sides, mode)) return bag.rollPair(); + const fitting = bag.pairs.filter(([a, b]) => canFit(board, a, b)); + if (fitting.length) return bag.rollPair(fitting); + // None of this difficulty's facts fit the gaps left: finish the board with + // smaller facts (up to 12 × 12) rather than ending it half empty. + const small = fittingRolls(board, 12); + return small.length ? small[Math.floor(rng() * small.length)] : bag.rollPair(); + } + if (!fitOnlyNow(board, sides, mode)) return [bag.roll(), bag.roll()]; + const fitting = fittingRolls(board, sides); + if (!fitting.length) return [bag.roll(), bag.roll()]; + const ok = new Set(fitting.map(([a, b]) => `${a}x${b}`)); + for (let i = 0; i < 100; i++) { + const saved = { ...bag.remaining }; + const roll = [bag.roll(), bag.roll()]; + if (ok.has(`${roll[0]}x${roll[1]}`)) return roll; + Object.assign(bag.remaining, saved); + } + return fitting[Math.floor(rng() * fitting.length)]; +} + +export { + EMPTY, + createBoard, + fits, + isValidPlacement, + orientations, + validPlacements, + place, + removeRect, + emptyCount, + chooseComputerMove, + rollDie, + DIFFICULTIES, + LEGEND_TEEN_SHARE, + DIFFICULTY_SIDES, + TRICKY_FACES, + diceFaces, + usesPairDice, + createPairDice, + fitsDifficulty, + diceProfile, + createDice, + diceState, + diceFromState, + FIT_MODES, + fitThreshold, + fitOnlyNow, + fittingRolls, + rollForBoard, +}; diff --git a/packages/engine/src/match.js b/packages/engine/src/match.js new file mode 100644 index 0000000..8749391 --- /dev/null +++ b/packages/engine/src/match.js @@ -0,0 +1,281 @@ +// One game of Blockout, turn by turn, with no screen and no timers: the rules +// the multiplication game plays by (apps/web/src/multiplication/game/play.js), +// for clients that draw it their own way (apps/mobile). Every function takes +// the match and changes it; the client animates between the steps. +// +// roll → place (or pass when nothing fits) → answer (help after two misses, +// or time out) → endTurn → … → over +// +// The CPU's turn is one step (cpuTurn). Points for an answer are the squares +// plus a bonus that the caller works out (createMatch's answerBonus, normally +// @blockout/progress's), so this package stays free of the progress rules. + +import * as Core from './core.js'; + +const PHASES = ['roll', 'place', 'stuck', 'answer', 'scored', 'cpu', 'over']; +const noBonus = () => ({ total: 0, parts: [] }); + +// players: [{ name, cpu }]. mode: 'single' (you vs the CPU) or 'multi' (one screen). +// sides: numbered dice other than the difficulty's (a Journey level's d1 … d12). +function createMatch({ size = 6, difficulty = 'easy', sides = null, players, mode = 'single', expert = false, bonusMultiplier = 1, fitMode = 'always', weight = null, rng = Math.random, answerBonus = noBonus, now = Date.now } = {}) { + if (!players || !players.length) throw new Error('a match needs players'); + const pairDice = Core.usesPairDice(difficulty); + return { + size, + difficulty, + mode, + startedAt: now(), // (for the history: how long it took) + expert, // no "Help me count" + bonusMultiplier, // Expert doubles bonus points + answerBonus, + rng, + fitMode, + board: Core.createBoard(size), + faces: Core.diceFaces(difficulty, sides), + sides: pairDice ? null : sides || Core.DIFFICULTY_SIDES[difficulty] || 6, + diceBag: pairDice ? Core.createPairDice(size, rng, difficulty, weight) : Core.createDice(size, rng, sides || Core.DIFFICULTY_SIDES[difficulty] || 6), + players: players.map((p) => ({ + ...p, // (a client's own fields: colours, avatars, the name stats go under) + name: p.name, + cpu: Boolean(p.cpu), + score: 0, // squares + bonus + squares: 0, + bonus: 0, + streak: 0, // first-try answers in a row + bestStreak: 0, + answered: 0, + firstTry: 0, + log: [], + stats: { placed: 0, biggest: null, wrong: 0, helped: 0, timeouts: 0, passes: 0, times: [], missed: [] }, + })), + current: 0, + turns: 1, + passes: 0, // passes in a row: everyone passing ends the game + dice: null, + lastRect: null, + pending: null, // { rect, attempts, helped, miss } while a human answers + phase: players[0].cpu ? 'cpu' : 'roll', + }; +} + +const currentPlayer = (m) => m.players[m.current]; + +function expect(m, ...phases) { + if (!phases.includes(m.phase)) throw new Error(`not now: the match is at ${m.phase}, not ${phases.join(' or ')}`); +} + +// A human's roll (or the dice they rolled at the table: forced). Then they +// place, or pass when it fits nowhere ('stuck'). +function roll(m, forced = null) { + expect(m, 'roll'); + m.dice = forced ? [...forced] : Core.rollForBoard(m.board, m.diceBag, m.rng, m.fitMode); + m.phase = Core.validPlacements(m.board, ...m.dice).length ? 'place' : 'stuck'; + return m.dice; +} + +// How a drawn rectangle ({ x, y, w, h }) compares to the roll: the right size +// either way round, and in free squares. +function judge(m, { x, y, w, h }) { + const [a, b] = m.dice; + const rightSize = (w === a && h === b) || (w === b && h === a); + const fits = Core.fits(m.board, x, y, w, h); + return { rightSize, fits, ok: rightSize && fits }; +} + +function putDown(m, { x, y, w, h }) { + const [a, b] = m.dice; + const rect = Core.place(m.board, x, y, w, h, m.current); + Object.assign(rect, { a, b, solved: false }); + m.lastRect = rect; + m.passes = 0; + return rect; +} + +// Auto placing: where the CPU would put it (snug against the others) +const autoSpot = (m) => Core.chooseComputerMove(m.board, ...m.dice, m.rng); + +// The human's rectangle goes on the board; its points wait for the answer. +function place(m, rect) { + expect(m, 'place'); + const verdict = judge(m, rect); + if (!verdict.ok) return { ok: false, verdict }; + const placed = putDown(m, rect); + m.pending = { rect: placed, attempts: 0, helped: false, miss: null }; + m.phase = 'answer'; + return { ok: true, rect: placed }; +} + +function award(m, rect, firstTry, bonus = 0) { + const p = currentPlayer(m); + rect.solved = true; + p.squares += rect.area; + p.bonus += bonus; + p.score = p.squares + p.bonus; + p.answered++; + if (firstTry) p.firstTry++; + p.stats.placed++; + if (!p.stats.biggest || rect.area > p.stats.biggest.area) p.stats.biggest = rect; + p.log.push({ a: rect.a, b: rect.b, area: rect.area, bonus, star: !p.cpu && firstTry }); +} + +// A missed fact is noted once per turn, with every wrong answer given for it. +function noteMiss(m, { answer = null, timeout = false } = {}) { + const pend = m.pending; + if (!pend.miss) { + pend.miss = { fact: `${pend.rect.a} × ${pend.rect.b}`, answers: [], timeout: false }; + currentPlayer(m).stats.missed.push(pend.miss); + } + if (answer !== null) pend.miss.answers.push(answer); + if (timeout) pend.miss.timeout = true; +} + +// Can more typing still change the outcome? No once it matches, overshoots, +// or has as many digits as the answer: the keypad then checks by itself. +function answerDecided(entry, answer) { + return entry !== '' && (Number(entry) >= answer || entry.length >= String(answer).length); +} + +// The human's answer. ms: how long they took (help excluded); timer: { secs, +// left } when there's an answer timer. Right: points and bonus, 'scored'. +// Wrong: try again, and after two misses it's time to help (unless Expert). +// Right first time: no wrong answer, and no "Help me count". Only that earns +// bonus points (first try, speedy, streak, timer) and counts towards mastering +// the fact; a helped answer ends the streak. (Used by every client.) +const firstTry = (pend) => pend.attempts === 0 && !pend.helped; + +function answer(m, value, { ms = 0, timer = null } = {}) { + expect(m, 'answer'); + const pend = m.pending; + const p = currentPlayer(m); + const { rect } = pend; + if (Number(value) === rect.area) { + const first = firstTry(pend); + p.stats.times.push(ms); + p.streak = first ? p.streak + 1 : 0; + p.bestStreak = Math.max(p.bestStreak, p.streak); + const bonus = m.answerBonus({ firstTry: first, ms, streak: p.streak, timer }); + const total = bonus.total * m.bonusMultiplier; + award(m, rect, first, total); + m.phase = 'scored'; + const wrongAnswers = pend.miss ? pend.miss.answers : []; + m.pending = null; + return { correct: true, firstTry: first, rect, bonus: { ...bonus, total }, streak: p.streak, wrongAnswers }; + } + p.streak = 0; + pend.attempts++; + noteMiss(m, { answer: Number(value) }); + p.stats.wrong++; + return { correct: false, attempts: pend.attempts, helpNow: pend.attempts >= 2 && !pend.helped && !m.expert }; +} + +// "Help me count": how to count the rectangle out loud (once a turn, never in +// Expert). Legend's teen facts are split into tens and ones instead. +function help(m) { + expect(m, 'answer'); + const pend = m.pending; + if (pend.helped || m.expert) return null; + pend.helped = true; + currentPlayer(m).stats.helped++; + return legendSplit(pend.rect, m.difficulty) || { count: countPlan(pend.rect) }; +} + +// Count a × b as "a rows (or columns) of b", matching the order of the dice. +function countPlan(rect) { + const byRows = rect.w === rect.b; + return { byRows, group: rect.b, steps: rect.a, unit: byRows ? 'row' : 'column', totals: Array.from({ length: rect.a }, (_, i) => (i + 1) * rect.b) }; +} + +// Legend: a teen times a one-digit number, split into tens and ones +// (14 × 7 = 10 × 7 + 4 × 7). Null for anything else. +function legendSplit(rect, difficulty) { + if (difficulty !== 'legend') return null; + const teen = Math.max(rect.a, rect.b); + const other = Math.min(rect.a, rect.b); + if (teen < 11) return null; + const ones = teen - 10; + return { split: { teen, other, ones, tens: 10 * other, rest: ones * other } }; +} + +// Out of time: the rectangle comes off the board, no points. +function timeout(m) { + expect(m, 'answer'); + const p = currentPlayer(m); + const { rect } = m.pending; + noteMiss(m, { timeout: true }); + const wrongAnswers = m.pending.miss.answers; + p.stats.timeouts++; + p.streak = 0; + p.log.push({ timeout: true, a: rect.a, b: rect.b }); + Core.removeRect(m.board, rect); + m.lastRect = null; + m.passes = 0; // the roll did fit, so this isn't a pass + m.pending = null; + m.phase = 'scored'; + return { rect, wrongAnswers }; +} + +// Nothing fits: the turn passes. +function pass(m) { + expect(m, 'stuck', 'cpu'); + const p = currentPlayer(m); + const [a, b] = m.dice; + p.log.push({ pass: true, a, b }); + p.stats.passes++; + m.passes++; + m.phase = 'scored'; +} + +// The CPU rolls, places snugly and always answers right on the first try. +// Returns { dice, rect } (rect null when it had to pass). +function cpuTurn(m) { + expect(m, 'cpu'); + m.dice = Core.rollForBoard(m.board, m.diceBag, m.rng, m.fitMode); + const move = Core.chooseComputerMove(m.board, ...m.dice, m.rng); + if (!move) { + pass(m); + return { dice: m.dice, rect: null }; + } + const rect = putDown(m, move); + award(m, rect, true); + m.phase = 'scored'; + return { dice: m.dice, rect }; +} + +// Would the game end if the turn ended now? +const isOver = (m) => m.passes >= m.players.length || Core.emptyCount(m.board) === 0; + +// Next player, or the end: everyone passed in a row, or the board is full. +function endTurn(m) { + expect(m, 'scored'); + m.dice = null; + if (isOver(m)) { + m.phase = 'over'; + return results(m); + } + m.current = (m.current + 1) % m.players.length; + m.turns++; + m.phase = currentPlayer(m).cpu ? 'cpu' : 'roll'; + return null; +} + +// Ranked by score; winners share the top score (a tie when there are several). +function results(m) { + const ranked = m.players.map((p, index) => ({ ...p, index })).sort((p, q) => q.score - p.score); + const top = ranked[0].score; + const winners = ranked.filter((p) => p.score === top); + const humans = m.players.filter((p) => !p.cpu); + const asked = (p) => p.answered + p.stats.timeouts; + return { + ranked, + winners, + tie: winners.length > 1, + humanWon: winners.length === 1 && !winners[0].cpu, + empty: Core.emptyCount(m.board), + // for the end-of-game achievements + perfect: humans.some((p) => asked(p) >= 5 && p.firstTry === asked(p)), + noHelp: humans.some((p) => asked(p) >= 5 && p.stats.helped === 0), + bestScore: humans.length ? Math.max(...humans.map((p) => p.score)) : 0, + }; +} + +export { PHASES, createMatch, currentPlayer, firstTry, roll, judge, autoSpot, award, noteMiss, place, answer, answerDecided, help, countPlan, legendSplit, timeout, pass, cpuTurn, isOver, endTurn, results }; diff --git a/packages/engine/src/ops/boards.js b/packages/engine/src/ops/boards.js new file mode 100644 index 0000000..839f104 --- /dev/null +++ b/packages/engine/src/ops/boards.js @@ -0,0 +1,208 @@ +// Board models for the prototypes, independent of the UI. +// +// grid: a cols x rows grid of squares. Pieces are rectangles. +// - fill mode: rectangles go on empty squares (like Blockout). +// - clear mode: the board starts covered in stones and a piece knocks out +// a line of stones. +// - block: optional size of "wholes"; a rectangle can't cross from one whole +// into another (the thick lines). +// bars: fraction strips, each one whole long, measured in `units` (e.g. 120ths). +// - fill mode: pieces pack in from the left. +// - take mode: bars start full and pieces are taken off the right end. + +const EMPTY = -1; +const STONE = -2; + +// ------------------------------------------------------------------ grid + +function createGrid({ cols, rows, block = 0, mode = 'fill', stones = null }) { + const cells = new Array(cols * rows).fill(EMPTY); + // clear mode: `stones` of them (default all), laid out in reading order + // so a count like 43 shows as four rows of ten and three more + const n = mode === 'clear' ? (stones == null ? cols * rows : stones) : 0; + for (let i = 0; i < n; i++) cells[i] = STONE; + return { kind: 'grid', cols, rows, block, mode, cells, pieces: [], total: mode === 'clear' ? n : cols * rows }; +} + +// A square the piece may cover: empty when filling, a stone when clearing. +function usable(board, c, r) { + if (c < 0 || r < 0 || c >= board.cols || r >= board.rows) return false; + const v = board.cells[r * board.cols + c]; + return board.mode === 'clear' ? v === STONE : v === EMPTY; +} + +function sameBlock(board, x, y, w, h) { + const b = board.block; + if (!b) return true; + return Math.floor(x / b) === Math.floor((x + w - 1) / b) && Math.floor(y / b) === Math.floor((y + h - 1) / b); +} + +function gridFitsAt(board, x, y, w, h) { + if (x < 0 || y < 0 || x + w > board.cols || y + h > board.rows) return false; + if (!sameBlock(board, x, y, w, h)) return false; + for (let r = y; r < y + h; r++) for (let c = x; c < x + w; c++) if (!usable(board, c, r)) return false; + return true; +} + +function gridOrientations(piece) { + const { w, h } = piece; + return piece.rotatable === false || w === h ? [{ w, h, turned: false }] : [{ w, h, turned: false }, { w: h, h: w, turned: true }]; +} + +function gridPlacements(board, piece) { + const out = []; + for (const o of gridOrientations(piece)) { + for (let y = 0; y + o.h <= board.rows; y++) { + for (let x = 0; x + o.w <= board.cols; x++) if (gridFitsAt(board, x, y, o.w, o.h)) out.push({ x, y, w: o.w, h: o.h, turned: o.turned }); + } + } + return out; +} + +function gridFits(board, piece) { + for (const o of gridOrientations(piece)) { + for (let y = 0; y + o.h <= board.rows; y++) { + for (let x = 0; x + o.w <= board.cols; x++) if (gridFitsAt(board, x, y, o.w, o.h)) return true; + } + } + return false; +} + +function gridPlace(board, piece, p, owner, correct) { + if (!gridFitsAt(board, p.x, p.y, p.w, p.h)) throw new Error('Invalid placement'); + for (let r = p.y; r < p.y + p.h; r++) for (let c = p.x; c < p.x + p.w; c++) board.cells[r * board.cols + c] = owner; + const rec = { ...p, owner, correct, piece }; + board.pieces.push(rec); + return rec; +} + +// Edges and squares the next piece can't use count as contact; snug pieces +// (and, when clearing, knocking out stones from the edge of the pile) leave +// fewer awkward gaps. Squares walled in on all sides are penalised. +function gridCpuChoose(board, piece, rng = Math.random) { + const options = gridPlacements(board, piece); + if (!options.length) return null; + const open = (c, r, p) => usable(board, c, r) && inBlockOf(board, c, r, p) && + !(c >= p.x && c < p.x + p.w && r >= p.y && r < p.y + p.h); + let best = null; + let bestScore = -Infinity; + for (const p of options) { + let contact = 0; + for (let c = p.x; c < p.x + p.w; c++) contact += !open(c, p.y - 1, p) + !open(c, p.y + p.h, p); + for (let r = p.y; r < p.y + p.h; r++) contact += !open(p.x - 1, r, p) + !open(p.x + p.w, r, p); + let holes = 0; + for (let r = p.y - 1; r <= p.y + p.h; r++) { + for (let c = p.x - 1; c <= p.x + p.w; c++) { + if (!open(c, r, p)) continue; + if (!open(c - 1, r, p) && !open(c + 1, r, p) && !open(c, r - 1, p) && !open(c, r + 1, p)) holes++; + } + } + const score = contact / (2 * (p.w + p.h)) - 0.6 * holes + rng() * 0.05; + if (score > bestScore) { + bestScore = score; + best = p; + } + } + return best; +} + +// Is (c, r) inside the same whole as the placement? (Squares in the next +// whole over are behind a thick line, so they count as a wall.) +function inBlockOf(board, c, r, p) { + const b = board.block; + if (!b) return true; + return Math.floor(c / b) === Math.floor(p.x / b) && Math.floor(r / b) === Math.floor(p.y / b); +} + +function gridCount(board, value) { + return board.cells.reduce((n, v) => n + (v === value ? 1 : 0), 0); +} + +// 0..1: how much of the game is done +function gridProgress(board) { + if (board.mode === 'clear') return board.total ? 1 - gridCount(board, STONE) / board.total : 1; + return 1 - gridCount(board, EMPTY) / board.total; +} + +// ------------------------------------------------------------------ bars + +function createBars({ count, units, mode = 'fill', amounts = null }) { + const bars = []; + for (let i = 0; i < count; i++) { + const amount = amounts ? amounts[i] : mode === 'take' ? units : 0; + bars.push({ amount, start: amount, segments: [] }); + } + return { kind: 'bars', units, mode, bars, pieces: [] }; +} + +function barRoom(board, i) { + const bar = board.bars[i]; + return board.mode === 'take' ? bar.amount : board.units - bar.amount; +} + +function barsPlacements(board, piece) { + const out = []; + board.bars.forEach((bar, i) => { + if (barRoom(board, i) >= piece.len) out.push({ bar: i, start: board.mode === 'take' ? bar.amount - piece.len : bar.amount }); + }); + return out; +} + +const barsFits = (board, piece) => board.bars.some((_, i) => barRoom(board, i) >= piece.len); + +function barsPlace(board, piece, p, owner, correct) { + if (barRoom(board, p.bar) < piece.len) throw new Error('Invalid placement'); + const bar = board.bars[p.bar]; + const start = board.mode === 'take' ? bar.amount - piece.len : bar.amount; + bar.amount += board.mode === 'take' ? -piece.len : piece.len; + const rec = { bar: p.bar, start, len: piece.len, owner, correct, piece }; + bar.segments.push(rec); + board.pieces.push(rec); + return rec; +} + +// Finish a whole if possible, otherwise leave the smallest gap. +function barsCpuChoose(board, piece, rng = Math.random) { + const options = barsPlacements(board, piece); + if (!options.length) return null; + let best = null; + let bestScore = Infinity; + for (const p of options) { + const left = barRoom(board, p.bar) - piece.len; + const score = left + rng() * 0.5; + if (score < bestScore) { + bestScore = score; + best = p; + } + } + return best; +} + +function barsProgress(board) { + const total = board.bars.reduce((n, b) => n + (board.mode === 'take' ? b.start : board.units), 0); + const done = board.bars.reduce((n, b) => n + (board.mode === 'take' ? b.start - b.amount : b.amount), 0); + return total ? done / total : 1; +} + +// ------------------------------------------------------------------ shared + +const KINDS = { + grid: { placements: gridPlacements, fits: gridFits, place: gridPlace, cpuChoose: gridCpuChoose, progress: gridProgress }, + bars: { placements: barsPlacements, fits: barsFits, place: barsPlace, cpuChoose: barsCpuChoose, progress: barsProgress }, +}; + +export const placements = (b, piece) => KINDS[b.kind].placements(b, piece); +export const fits = (b, piece) => KINDS[b.kind].fits(b, piece); +export const place = (b, piece, p, owner, correct) => KINDS[b.kind].place(b, piece, p, owner, correct); +export const cpuChoose = (b, piece, rng) => KINDS[b.kind].cpuChoose(b, piece, rng); +export const progress = (b) => KINDS[b.kind].progress(b); + +export { + EMPTY, + STONE, + createGrid, + createBars, + gridFitsAt, + gridCount, + barRoom, +}; diff --git a/packages/engine/src/ops/fraction.js b/packages/engine/src/ops/fraction.js new file mode 100644 index 0000000..b11abbc --- /dev/null +++ b/packages/engine/src/ops/fraction.js @@ -0,0 +1,76 @@ +// Exact fractions for the prototypes. Always stored reduced, with a positive denominator. + +function gcd(a, b) { + a = Math.abs(a); + b = Math.abs(b); + while (b) [a, b] = [b, a % b]; + return a || 1; +} + +const lcm = (a, b) => (a / gcd(a, b)) * b; + +function F(n, d = 1) { + if (!Number.isInteger(n) || !Number.isInteger(d) || d === 0) throw new Error(`Bad fraction ${n}/${d}`); + if (d < 0) [n, d] = [-n, -d]; + const g = gcd(n, d); + return { n: n / g, d: d / g }; +} + +const add = (x, y) => F(x.n * y.d + y.n * x.d, x.d * y.d); +const sub = (x, y) => F(x.n * y.d - y.n * x.d, x.d * y.d); +const mul = (x, y) => F(x.n * y.n, x.d * y.d); +const div = (x, y) => F(x.n * y.d, x.d * y.n); +const cmp = (x, y) => x.n * y.d - y.n * x.d; +const eq = (x, y) => cmp(x, y) === 0; +const ZERO = F(0); +const ONE = F(1); + +// How many 1/units a fraction is (e.g. 3/4 of 24 units = 18). Must come out whole. +function toUnits(x, units) { + const u = (x.n * units) / x.d; + if (!Number.isInteger(u)) throw new Error(`${str(x)} is not a whole number of 1/${units}`); + return u; +} + +// "3/4", "2", "-1/2" +function str(x) { + return x.d === 1 ? String(x.n) : `${x.n}/${x.d}`; +} + +// "2 3/4" for scores; "3/4" and "2" as usual +function mixed(x) { + if (x.d === 1 || Math.abs(x.n) < x.d) return str(x); + const whole = Math.trunc(x.n / x.d); + const rest = Math.abs(x.n - whole * x.d); + return `${whole} ${rest}/${x.d}`; +} + +// What a child typed: numerator and (optional) denominator strings, unreduced. +// Returns { value, simplest } or null when it isn't a number. +function parseAnswer(num, den) { + if (!/^\d+$/.test(num)) return null; + if (den !== '' && den != null && !/^\d+$/.test(den)) return null; + const n = Number(num); + const d = den === '' || den == null ? 1 : Number(den); + if (d === 0) return null; + const value = F(n, d); + return { value, simplest: value.n === n && value.d === d }; +} + +export { + gcd, + lcm, + F, + add, + sub, + mul, + div, + cmp, + eq, + ZERO, + ONE, + toUnits, + str, + mixed, + parseAnswer, +}; diff --git a/packages/engine/src/ops/variants.js b/packages/engine/src/ops/variants.js new file mode 100644 index 0000000..19e106f --- /dev/null +++ b/packages/engine/src/ops/variants.js @@ -0,0 +1,623 @@ +// The seven Blockout prototypes: the rules and the maths for each, independent of the UI. +// +// Each variant describes: +// levels easy / medium / hard: the dice and the board +// newBoard(lvl) a fresh board for a game +// pool(lvl) every roll that can come up (specs); the engine only deals ones that fit +// task(spec) { dice, piece, label } for one roll +// question(task, board, placement) -> { text, answer } (asked about the board *before* the piece goes on) +// explain(...) the CPU's working, step by step; the last step is the whole sum +// points(...) a Fraction (whole numbers are n/1) +// practice families of facts to drill, and the board they're drilled on + +import * as Fr from './fraction.js'; +import * as Boards from './boards.js'; + +const { F } = Fr; +const s = Fr.str; + +const range = (a, b) => Array.from({ length: b - a + 1 }, (_, i) => a + i); +const pairs = (xs, ys = xs) => xs.flatMap((x) => ys.map((y) => [x, y])); + +// Every proper fraction (0 < f < 1) with one of these denominators, reduced and without repeats. +function properFractions(dens) { + const seen = new Map(); + for (const d of dens) for (let n = 1; n < d; n++) { const f = F(n, d); seen.set(s(f), f); } + return [...seen.values()].sort((x, y) => Fr.cmp(x, y)); +} +const unitFractions = (dens) => dens.map((d) => F(1, d)); + +// "is the denominator of one a multiple of the other" +const related = (x, y) => x.d !== y.d && (x.d % y.d === 0 || y.d % x.d === 0); + +// Make both fractions the same-sized pieces, as steps. +function commonSteps(x, y) { + if (x.d === y.d) return { L: x.d, steps: [`Both are counted in the same size pieces: 1/${x.d}s`] }; + const L = Fr.lcm(x.d, y.d); + const steps = [`Cut both into the same size pieces: 1/${L}s`]; + if (x.d !== L) steps.push(`${s(x)} = ${(x.n * L) / x.d}/${L}`); + if (y.d !== L) steps.push(`${s(y)} = ${(y.n * L) / y.d}/${L}`); + return { L, steps }; +} + +function simplifyStep(n, d) { + const f = F(n, d); + if (n === 0 || (f.n === n && f.d === d)) return []; + if (f.d === 1) return [`${n}/${d} is ${f.n} whole${f.n === 1 ? '' : 's'}`]; + return [`Simplify: ${n}/${d} = ${s(f)}`]; +} + +// ================================================================ addition + +const addition = { + id: 'addition', + name: 'Addition', + title: 'Line Up', + op: '+', + grade: 'Grades 1–3 · adding within 20', + tagline: 'Roll two dice, lay a bar that long, add the two parts.', + concept: + 'Each roll becomes a straight bar made of two coloured parts, one per die. Laying bars across a grid rewards spotting long gaps, and the two-colour bar is a number line you can see: 8 and 5 make a bar that crosses a row of 10.', + howto: [ + 'Roll two dice. Your piece is a bar made of both: a 5 and a 3 make a bar 5 + 3 squares long.', + 'Place it across or down on empty squares. Press R (or Rotate) to turn it.', + 'Add the two parts. Get it right to score those squares.', + 'When no bar fits for anyone, the game is over. Most squares wins.', + ], + answer: 'int', + unit: 'squares', + levels: { + easy: { label: 'Easy', sub: 'd6 · 12×12', sides: 6, size: 12 }, + medium: { label: 'Medium', sub: 'd8 · 16×16', sides: 8, size: 16 }, + hard: { label: 'Hard', sub: 'd10 · 20×20', sides: 10, size: 20 }, + }, + newBoard(lvl) { + const { size } = this.levels[lvl]; + return Boards.createGrid({ cols: size, rows: size }); + }, + pool(lvl) { + return pairs(range(1, this.levels[lvl].sides)); + }, + task([a, b], lvl) { + const sides = lvl ? this.levels[lvl].sides : 10; + return { + spec: [a, b], + dice: [{ type: 'pips', v: a, sides }, { type: 'pips', v: b, sides }], + piece: { w: a + b, h: 1, parts: [a, b] }, + label: `${a} + ${b}`, + }; + }, + question(t) { + const [a, b] = t.spec; + return { text: `${a} + ${b}`, answer: F(a + b) }; + }, + explain(t) { + const [a, b] = t.spec; + const sum = a + b; + const big = Math.max(a, b); + const small = Math.min(a, b); + let steps; + if (a === b) steps = [`Double ${a}: ${a} + ${a} = ${sum}`]; + else if (big - small === 1) steps = [`Near double: ${small} + ${small} = ${2 * small}`, `One more: ${2 * small} + 1 = ${sum}`]; + else if (sum > 10 && big < 10) { + const need = 10 - big; + steps = [`${big} needs ${need} more to make 10`, `Split ${small} into ${need} and ${small - need}`, `10 + ${small - need} = ${sum}`]; + } else if (big === 10) steps = [`10 and ${small} more is ${sum}`]; + else steps = [`Start at ${big} and count on ${small}: ${range(big + 1, sum).join(', ')}`]; + return [...steps, `${a} + ${b} = ${sum}`]; + }, + points(t) { + return F(t.spec[0] + t.spec[1]); + }, + practice: { + board: () => Boards.createGrid({ cols: 20, rows: 20 }), + seconds: 10, + families: [ + { id: 'to10', label: 'Sums to 10', facts: () => pairs(range(1, 9)).filter(([a, b]) => a + b <= 10) }, + { id: 'doubles', label: 'Doubles', facts: () => range(1, 10).map((a) => [a, a]) }, + { id: 'near', label: 'Near doubles', facts: () => pairs(range(1, 10)).filter(([a, b]) => Math.abs(a - b) === 1) }, + { id: 'maketen', label: 'Make a ten', facts: () => pairs(range(2, 9)).filter(([a, b]) => a + b > 10) }, + { id: 'all', label: 'Everything to 20', facts: () => pairs(range(1, 10)) }, + ], + defaults: ['maketen', 'near'], + }, +}; + +// ================================================================ subtraction + +const STONES_WIDE = 10; + +const subtraction = { + id: 'subtraction', + name: 'Subtraction', + title: 'Knock Out', + op: '−', + grade: 'Grades 2–3 · subtracting within 100', + tagline: 'The board starts full of blocks. Knock out a line and say how many are left.', + concept: + 'The board is a pile of blocks laid out in rows of ten, so the number left is always something you can see in tens and ones. Each roll knocks a straight line out of the pile, and the question is always “how many are left now?”, a real two-digit take-away. Take the last blocks to clear the board.', + howto: [ + 'The board starts covered in blocks, in rows of ten.', + 'Roll the die, then knock out a straight line of that many blocks, across or down. Press R to turn it.', + 'Say how many blocks are left on the board. Get it right to keep the blocks you knocked out.', + 'The game ends when the board is empty. Most blocks wins.', + ], + answer: 'int', + unit: 'blocks', + levels: { + easy: { label: 'Easy', sub: 'd6 · 100 blocks', sides: 6, rows: 10 }, + medium: { label: 'Medium', sub: 'd10 · 100 blocks', sides: 10, rows: 10 }, + hard: { label: 'Hard', sub: 'd12 · 150 blocks', sides: 12, rows: 15 }, + }, + newBoard(lvl) { + const L = this.levels[lvl]; + return Boards.createGrid({ cols: STONES_WIDE, rows: L.rows, mode: 'clear' }); + }, + pool(lvl) { + return range(1, this.levels[lvl].sides).map((n) => [n]); + }, + // practice facts are [left, take]; games are [take] and "left" is the board + task(spec, lvl) { + const n = spec[spec.length - 1]; + const sides = lvl ? this.levels[lvl].sides : 10; + return { spec, dice: [{ type: 'pips', v: n, sides }], piece: { w: n, h: 1 }, label: spec.length > 1 ? `${spec[0]} − ${n}` : `take ${n}` }; + }, + question(t, board) { + const left = Boards.gridCount(board, Boards.STONE); + const n = t.piece.w; + return { text: `${left} − ${n}`, answer: F(left - n) }; + }, + explain(t, board) { + const R = Boards.gridCount(board, Boards.STONE); + const n = t.piece.w; + const ones = R % 10; + let steps; + if (R <= 10 || n <= ones) { + steps = R <= 10 ? [`Count back ${n} from ${R}`] : [`${R} has ${ones} ones, so take the ${n} from the ones: ${ones} − ${n} = ${ones - n}`]; + } else if (ones === 0) { + steps = [`${R} is a whole number of tens, so break one ten: 10 − ${n} = ${10 - n}`, `${R - 10} + ${10 - n} = ${R - n}`]; + } else { + steps = [`Take ${ones} to get down to a ten: ${R} − ${ones} = ${R - ones}`, `Take the other ${n - ones}: ${R - ones} − ${n - ones} = ${R - n}`]; + } + return [...steps, `${R} − ${n} = ${R - n}`]; + }, + points(t) { + return F(t.piece.w); + }, + practice: { + // each fact gets its own pile, e.g. 43 blocks as four rows of ten and 3 more + boardFor: ([left]) => Boards.createGrid({ cols: STONES_WIDE, rows: Math.max(2, Math.ceil(left / STONES_WIDE)), mode: 'clear', stones: left }), + seconds: 12, + families: [ + { id: 'from10', label: 'Take from 10', facts: () => range(1, 9).map((n) => [10, n]) }, + { id: 'teens', label: 'Teens', facts: () => pairs(range(11, 19), range(2, 9)).filter(([R, n]) => n > R % 10) }, + { id: 'nocross', label: 'Two-digit, no regrouping', facts: () => pairs(range(21, 99), range(1, 9)).filter(([R, n]) => n <= R % 10) }, + { id: 'across', label: 'Across a ten', facts: () => pairs(range(21, 99), range(2, 9)).filter(([R, n]) => n > R % 10) }, + ], + defaults: ['teens', 'across'], + }, +}; + +// ================================================================ division + +const division = { + id: 'division', + name: 'Division', + title: 'Split It', + op: '÷', + grade: 'Grade 3 · division facts to 144 ÷ 12', + tagline: 'You get a number of squares and one side. Find the other side, then build it.', + concept: + 'This is Blockout backwards. The dice give the area and one side of a rectangle; you work out the missing side (the division fact) before you can build it. The rectangle on the board is the array that proves the answer: 3 rows of 6 make 18.', + howto: [ + 'Roll: you get a number of squares (like 18) and one side (like 3).', + 'Work out the missing side: 18 ÷ 3 = ?', + 'Then place your rectangle (3 by your answer) on empty squares. Press R to turn it.', + 'Get it right to score all its squares. Most squares wins.', + ], + answer: 'int', + answerFirst: true, + unit: 'squares', + levels: { + easy: { label: 'Easy', sub: 'd6 · 12×12', sides: 6, size: 12 }, + medium: { label: 'Medium', sub: 'd8 · 16×16', sides: 8, size: 16 }, + hard: { label: 'Hard', sub: 'd12 · 20×20', sides: 12, size: 20 }, + }, + newBoard(lvl) { + const { size } = this.levels[lvl]; + return Boards.createGrid({ cols: size, rows: size }); + }, + pool(lvl) { + return pairs(range(1, this.levels[lvl].sides)); + }, + // spec: [side you're given, missing side] + task([a, q], lvl) { + const sides = lvl ? this.levels[lvl].sides : 12; + return { + spec: [a, q], + dice: [{ type: 'total', v: a * q }, { type: 'pips', v: a, sides }], + piece: { w: a, h: q }, + label: `${a * q} ÷ ${a}`, + }; + }, + question(t) { + const [a, q] = t.spec; + return { text: `${a * q} ÷ ${a}`, answer: F(q) }; + }, + explain(t) { + const [a, q] = t.spec; + const N = a * q; + const steps = [`${N} squares in rows of ${a}: how many rows?`]; + if (q <= 12 && a > 1) steps.push(`Count by ${a}s: ${range(1, q).map((k) => k * a).join(', ')}`); + steps.push(`That's ${q} rows, because ${a} × ${q} = ${N}`); + return [...steps, `${N} ÷ ${a} = ${q}`]; + }, + points(t) { + return F(t.spec[0] * t.spec[1]); + }, + practice: { + board: () => Boards.createGrid({ cols: 20, rows: 20 }), + seconds: 10, + families: range(2, 12).map((a) => ({ id: `d${a}`, label: `÷${a}`, facts: () => range(1, 12).map((q) => [a, q]) })), + defaults: ['d6', 'd7', 'd8'], + }, +}; + +// ================================================================ fractions on bars + +const BAR_UNITS = 120; // 1/2 … 1/12, 1/5 and 1/10 all come out whole + +// A piece for the bars: its length, and the parts it's made of (each with the +// size of its chunks, for the tick marks, and a label). +const barPart = (f, tick = F(1, f.d)) => ({ len: Fr.toUnits(f, BAR_UNITS), tick: Fr.toUnits(tick, BAR_UNITS), label: s(f) }); +const barPiece = (parts) => ({ len: parts.reduce((n, p) => n + p.len, 0), parts }); +const fracDie = (f) => ({ type: 'frac', f }); + +// ---------------------------------------------------------------- adding fractions + +const ADD_DENS = [2, 3, 4, 5, 6, 8, 10, 12]; + +function addPairs(kind, dens = ADD_DENS) { + const fr = properFractions(dens); + return pairs(fr).filter(([x, y]) => { + if (Fr.cmp(Fr.add(x, y), Fr.ONE) > 0) return false; + if (kind === 'same') return x.d === y.d; + if (kind === 'related') return related(x, y); + return x.d !== y.d && !related(x, y); + }); +} + +const fractionAdd = { + id: 'fraction-add', + name: 'Adding fractions', + title: 'Fill the Whole', + op: '+', + grade: 'Grades 4–5 · like and unlike denominators', + tagline: 'Roll two fraction dice and slide both pieces into a fraction bar.', + concept: + 'The board is a stack of fraction bars, each one whole long. A roll gives two fraction pieces joined together; you choose which bar to slide them into. The bar has to have room, so you are always thinking about how much is left in each whole. Filling a bar exactly to 1 is a “Blockout”.', + howto: [ + 'Roll two fraction dice, like 1/3 and 1/4. Your piece is both of them joined together.', + 'Choose a bar with enough room. Pieces slide in from the left.', + 'Add the fractions: 1/3 + 1/4 = ? Any equal fraction counts (7/12).', + 'Get it right to score the piece. Fill a bar exactly to make a whole!', + ], + answer: 'frac', + unit: 'wholes', + levels: { + easy: { label: 'Easy', sub: 'same denominators', kind: 'same', bars: 10 }, + medium: { label: 'Medium', sub: 'related (1/3 + 1/6)', kind: 'related', bars: 10 }, + hard: { label: 'Hard', sub: 'unlike (1/3 + 1/4)', kind: 'unlike', bars: 10 }, + }, + newBoard(lvl) { + return Boards.createBars({ count: this.levels[lvl].bars, units: BAR_UNITS }); + }, + pool(lvl) { + return addPairs(this.levels[lvl].kind); + }, + task([x, y]) { + return { + spec: [x, y], + dice: [fracDie(x), fracDie(y)], + piece: barPiece([barPart(x), barPart(y)]), + label: `${s(x)} + ${s(y)}`, + }; + }, + question(t) { + const [x, y] = t.spec; + return { text: `${s(x)} + ${s(y)}`, answer: Fr.add(x, y) }; + }, + explain(t) { + const [x, y] = t.spec; + const { L, steps } = commonSteps(x, y); + const nx = (x.n * L) / x.d; + const ny = (y.n * L) / y.d; + return [...steps, `${nx}/${L} + ${ny}/${L} = ${nx + ny}/${L}`, ...simplifyStep(nx + ny, L), `${s(x)} + ${s(y)} = ${s(Fr.add(x, y))}`]; + }, + points(t) { + return Fr.add(...t.spec); + }, + practice: { + board: () => Boards.createBars({ count: 6, units: BAR_UNITS }), + seconds: 30, + families: [ + { id: 'same', label: 'Same denominator', facts: () => addPairs('same') }, + { id: 'related', label: 'Related (halves, quarters, eighths…)', facts: () => addPairs('related') }, + { id: 'unlike', label: 'Unlike denominators', facts: () => addPairs('unlike') }, + ], + defaults: ['same', 'related'], + }, +}; + +// ---------------------------------------------------------------- subtracting fractions + +const SUB_DICE = { + easy: properFractions([2, 4, 8]), + medium: properFractions([2, 3, 4, 6, 12]), + hard: properFractions([2, 3, 4, 5, 6, 8, 10]), +}; + +function subPairs(kind) { + const fr = properFractions([2, 3, 4, 5, 6, 8, 10, 12]); + if (kind === 'whole') return fr.map((r) => [Fr.ONE, r]); + return pairs(fr).filter(([A, r]) => { + if (Fr.cmp(A, r) <= 0) return false; + if (kind === 'same') return A.d === r.d; + if (kind === 'related') return related(A, r); + return A.d !== r.d && !related(A, r); + }); +} + +const fractionSub = { + id: 'fraction-subtract', + name: 'Subtracting fractions', + title: 'Empty the Whole', + op: '−', + grade: 'Grades 4–5 · like and unlike denominators', + tagline: 'Every bar starts full. Roll a fraction and take it off the bar you choose.', + concept: + 'All the fraction bars start full (1 whole each). A roll is one fraction to take away, and you choose the bar. The question is about that bar: “it has 3/4, take away 1/3, how much is left?” Picking a bar is the strategy, and emptying one exactly is a “Blockout”.', + howto: [ + 'Every bar starts as 1 whole.', + 'Roll a fraction, like 1/3. Choose a bar with at least that much left.', + 'Work out what’s left in that bar: 3/4 − 1/3 = ? Any equal fraction counts.', + 'Get it right to keep the piece you took. Empty a bar exactly for a Blockout!', + ], + answer: 'frac', + unit: 'wholes', + levels: { + easy: { label: 'Easy', sub: 'halves, quarters, eighths', bars: 8 }, + medium: { label: 'Medium', sub: 'thirds, sixths, twelfths too', bars: 8 }, + hard: { label: 'Hard', sub: 'fifths and tenths too', bars: 8 }, + }, + newBoard(lvl) { + return Boards.createBars({ count: this.levels[lvl].bars, units: BAR_UNITS, mode: 'take' }); + }, + pool(lvl) { + return SUB_DICE[lvl].map((r) => [r]); + }, + // practice facts are [amount in the bar, take]; games are [take] + task(spec) { + const r = spec[spec.length - 1]; + return { spec, dice: [fracDie(r)], piece: barPiece([barPart(r)]), label: spec.length > 1 ? `${s(spec[0])} − ${s(r)}` : `take ${s(r)}` }; + }, + barAmount(board, p) { + return F(board.bars[p.bar].amount, BAR_UNITS); + }, + question(t, board, p) { + const A = this.barAmount(board, p); + const r = t.spec[t.spec.length - 1]; + return { text: `${s(A)} − ${s(r)}`, answer: Fr.sub(A, r) }; + }, + explain(t, board, p) { + const A = this.barAmount(board, p); + const r = t.spec[t.spec.length - 1]; + const out = Fr.sub(A, r); + if (A.d === 1) { + return [`1 whole is ${r.d}/${r.d}`, `${r.d}/${r.d} − ${s(r)} = ${r.d - r.n}/${r.d}`, ...simplifyStep(r.d - r.n, r.d), `1 − ${s(r)} = ${s(out)}`]; + } + const { L, steps } = commonSteps(A, r); + const na = (A.n * L) / A.d; + const nr = (r.n * L) / r.d; + return [...steps, `${na}/${L} − ${nr}/${L} = ${na - nr}/${L}`, ...simplifyStep(na - nr, L), `${s(A)} − ${s(r)} = ${s(out)}`]; + }, + points(t) { + return t.spec[t.spec.length - 1]; + }, + practice: { + // one bar holding the starting amount + boardFor: ([A]) => Boards.createBars({ count: 1, units: BAR_UNITS, mode: 'take', amounts: [Fr.toUnits(A, BAR_UNITS)] }), + seconds: 30, + families: [ + { id: 'whole', label: '1 − a fraction', facts: () => subPairs('whole') }, + { id: 'same', label: 'Same denominator', facts: () => subPairs('same') }, + { id: 'related', label: 'Related denominators', facts: () => subPairs('related') }, + { id: 'unlike', label: 'Unlike denominators', facts: () => subPairs('unlike') }, + ], + defaults: ['whole', 'same'], + }, +}; + +// ---------------------------------------------------------------- multiplying fractions + +const WHOLE = 12; // each whole is 12 × 12 squares +const MUL_DICE = { + easy: unitFractions([2, 3, 4]), + medium: properFractions([2, 3, 4]), + hard: properFractions([2, 3, 4, 6, 12]), +}; + +function mulPairs(kind) { + const unit = unitFractions([2, 3, 4, 6]); + const proper = properFractions([2, 3, 4, 6]); + if (kind === 'unit') return pairs(unit); + if (kind === 'mixed') return pairs(unit, proper.filter((f) => f.n > 1)); + if (kind === 'proper') return pairs(proper.filter((f) => f.n > 1)); + return pairs(properFractions([2, 3, 4, 6, 12])).filter(([x, y]) => x.d === 12 || y.d === 12 || x.d === 6 || y.d === 6); +} + +const fractionMul = { + id: 'fraction-multiply', + name: 'Multiplying fractions', + title: 'Part of a Part', + op: '×', + grade: 'Grade 5 · the area model', + tagline: 'Two fraction dice give the sides. Draw the rectangle inside a whole square.', + concept: + 'This is the closest cousin of Blockout. The board is made of whole squares, each a 12 × 12 grid. Two fraction dice give a rectangle’s width and height as parts of a whole (2/3 wide, 3/4 tall), and it has to fit inside one whole. The rectangle is the area model: it shows 2/3 × 3/4 as 6 of the 12 parts.', + howto: [ + 'Roll two fraction dice, like 2/3 and 3/4. Your rectangle is 2/3 of a whole wide and 3/4 of a whole tall.', + 'Place it inside one of the whole squares (it can’t cross a thick line). Press R to turn it.', + 'Work out how much of a whole it covers: 2/3 × 3/4 = ? Any equal fraction counts.', + 'Get it right to score it. Most wholes covered wins.', + ], + answer: 'frac', + unit: 'wholes', + levels: { + easy: { label: 'Easy', sub: '1/2, 1/3, 1/4', grid: 2 }, + medium: { label: 'Medium', sub: '2/3, 3/4 too', grid: 2 }, + hard: { label: 'Hard', sub: 'sixths and twelfths too', grid: 2 }, + }, + newBoard(lvl) { + const n = this.levels[lvl].grid * WHOLE; + return Boards.createGrid({ cols: n, rows: n, block: WHOLE }); + }, + pool(lvl) { + return pairs(MUL_DICE[lvl]); + }, + task([x, y]) { + return { + spec: [x, y], + dice: [fracDie(x), fracDie(y)], + piece: { w: Fr.toUnits(x, WHOLE), h: Fr.toUnits(y, WHOLE), ticks: [WHOLE / x.d, WHOLE / y.d] }, + label: `${s(x)} × ${s(y)}`, + }; + }, + question(t) { + const [x, y] = t.spec; + return { text: `${s(x)} × ${s(y)}`, answer: Fr.mul(x, y) }; + }, + explain(t) { + const [x, y] = t.spec; + const parts = x.d * y.d; + const got = x.n * y.n; + return [ + `Cut the whole ${x.d} ways across and ${y.d} ways down: ${x.d} × ${y.d} = ${parts} equal parts`, + `The rectangle covers ${x.n} × ${y.n} = ${got} of them: ${got}/${parts}`, + ...simplifyStep(got, parts), + `${s(x)} × ${s(y)} = ${s(Fr.mul(x, y))}`, + ]; + }, + points(t) { + return Fr.mul(...t.spec); + }, + practice: { + board: () => Boards.createGrid({ cols: 2 * WHOLE, rows: 2 * WHOLE, block: WHOLE }), + seconds: 30, + families: [ + { id: 'unit', label: 'Unit × unit (1/2 × 1/3)', facts: () => mulPairs('unit') }, + { id: 'mixed', label: 'Unit × other (1/2 × 2/3)', facts: () => mulPairs('mixed') }, + { id: 'proper', label: 'Other × other (2/3 × 3/4)', facts: () => mulPairs('proper') }, + { id: 'small', label: 'Sixths and twelfths', facts: () => mulPairs('small') }, + ], + defaults: ['unit', 'mixed'], + }, +}; + +// ---------------------------------------------------------------- dividing fractions + +function divPairs(kind) { + const dens = [2, 3, 4, 6, 8, 12]; + const all = [...properFractions(dens), Fr.ONE]; + const unit = unitFractions(dens); + return pairs(all, [...properFractions(dens)]).filter(([D, p]) => { + const q = Fr.div(D, p); + if (Fr.cmp(q, Fr.ONE) <= 0 && kind !== 'fraction') return false; + if (kind === 'whole') return D.d === 1 && unit.some((u) => Fr.eq(u, p)); + if (kind === 'same') return D.d !== 1 && q.d === 1 && p.n === 1 && D.d === p.d; + if (kind === 'other') return D.d !== 1 && q.d === 1 && D.d !== p.d; + return q.d !== 1 && D.d <= 8 && p.d <= 8; + }); +} + +const fractionDiv = { + id: 'fraction-divide', + name: 'Dividing fractions', + title: 'How Many Fit?', + op: '÷', + grade: 'Grades 5–6 · measuring with pieces', + tagline: 'Roll a length and a piece size. How many pieces fit? Then lay the length on a bar.', + concept: + 'Division as measuring: 3/4 ÷ 1/8 asks “how many eighths fit in three quarters?”. The piece you place is the dividend, marked off in divisor-sized chunks, so the answer is literally the number of chunks you can count (and at Hard, a leftover part chunk that makes the answer a fraction).', + howto: [ + 'Roll a length (like 3/4) and a piece size (like 1/8).', + 'Choose a bar with enough room for the length. It is marked in piece-sized chunks.', + 'How many pieces fit? 3/4 ÷ 1/8 = ? (At Hard the answer can be a fraction, like 3/2.)', + 'Get it right to score the length you placed. Fill a bar exactly for a Blockout!', + ], + answer: 'frac', + unit: 'wholes', + levels: { + easy: { label: 'Easy', sub: 'same size pieces', kinds: ['whole', 'same'], bars: 10 }, + medium: { label: 'Medium', sub: 'different pieces', kinds: ['other'], bars: 10 }, + hard: { label: 'Hard', sub: 'fraction answers', kinds: ['fraction'], bars: 10 }, + }, + newBoard(lvl) { + return Boards.createBars({ count: this.levels[lvl].bars, units: BAR_UNITS }); + }, + pool(lvl) { + return this.levels[lvl].kinds.flatMap(divPairs); + }, + task([D, p]) { + return { + spec: [D, p], + dice: [fracDie(D), fracDie(p)], + piece: barPiece([barPart(D, p)]), + label: `${s(D)} ÷ ${s(p)}`, + }; + }, + question(t) { + const [D, p] = t.spec; + return { text: `${s(D)} ÷ ${s(p)}`, answer: Fr.div(D, p) }; + }, + explain(t) { + const [D, p] = t.spec; + const q = Fr.div(D, p); + const steps = [`How many ${s(p)}s fit in ${s(D)}?`]; + const L = Fr.lcm(D.d, p.d); + const nD = (D.n * L) / D.d; + const np = (p.n * L) / p.d; + if (D.d !== L || p.d !== L) steps.push(`In 1/${L}s: ${s(D)} = ${nD}/${L} and ${s(p)} = ${np}/${L}`); + if (q.d === 1) steps.push(`${nD} ÷ ${np} = ${q.n}: ${q.n} pieces fit`); + else { + const whole = Math.floor(q.n / q.d); + steps.push(`${nD} ÷ ${np} = ${s(q)}`); + steps.push(`${whole} whole piece${whole === 1 ? '' : 's'} and ${q.n - whole * q.d}/${q.d} of another`); + } + return [...steps, `${s(D)} ÷ ${s(p)} = ${s(q)}`]; + }, + points(t) { + return t.spec[0]; + }, + practice: { + board: () => Boards.createBars({ count: 6, units: BAR_UNITS }), + seconds: 30, + families: [ + { id: 'whole', label: '1 ÷ 1/n', facts: () => divPairs('whole') }, + { id: 'same', label: 'Same size pieces (5/8 ÷ 1/8)', facts: () => divPairs('same') }, + { id: 'other', label: 'Different pieces (3/4 ÷ 1/8)', facts: () => divPairs('other') }, + { id: 'fraction', label: 'Fraction answers (1/2 ÷ 1/3)', facts: () => divPairs('fraction') }, + ], + defaults: ['whole', 'same'], + }, +}; + +const VARIANTS = [addition, subtraction, division, fractionAdd, fractionSub, fractionMul, fractionDiv]; + +export const byId = (id) => VARIANTS.find((v) => v.id === id); + +export { + VARIANTS, + BAR_UNITS, + WHOLE, +}; diff --git a/test/core.test.js b/packages/engine/test/core.test.js similarity index 64% rename from test/core.test.js rename to packages/engine/test/core.test.js index 4154a54..1dca339 100644 --- a/test/core.test.js +++ b/packages/engine/test/core.test.js @@ -1,24 +1,15 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const Core = require('../js/core.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as Core from '../src/core.js'; -test('first rectangle may go anywhere by default', () => { +test('the first rectangle may go anywhere', () => { const b = Core.createBoard(12); - assert.equal(Core.mustUseCorner(b), false); assert.equal(Core.isValidPlacement(b, 3, 3, 2, 5), true); assert.equal(Core.validPlacements(b, 2, 5).length, 11 * 8 + 8 * 11); }); -test('corner rule: first rectangle must go in the top-left corner', () => { - const b = Core.createBoard(12, { firstInCorner: true }); - assert.equal(Core.isValidPlacement(b, 3, 3, 2, 5), false); - assert.equal(Core.isValidPlacement(b, 0, 0, 2, 5), true); - const opts = Core.validPlacements(b, 2, 5); - assert.deepEqual(opts, [{ x: 0, y: 0, w: 2, h: 5 }, { x: 0, y: 0, w: 5, h: 2 }]); -}); - test('rectangles cannot overlap or leave the board', () => { - const b = Core.createBoard(12, { firstInCorner: true }); + const b = Core.createBoard(12); Core.place(b, 0, 0, 3, 4, 0); assert.equal(Core.isValidPlacement(b, 2, 3, 2, 2), false); assert.equal(Core.isValidPlacement(b, 3, 0, 2, 2), true); @@ -164,12 +155,12 @@ test('"never" keeps the original rules (passes are possible)', () => { assert.ok(seen.size > 1, 'rolls that do not fit should still come up'); }); -test('with plenty of room, rolls are not filtered', () => { +test('with plenty of room, rolls are not filtered (but 1s are avoided while bigger rolls fit)', () => { const b = Core.createBoard(12); const bag = Core.createDice(12); const seen = new Set(); for (let i = 0; i < 2000; i++) for (const v of Core.rollForBoard(b, bag)) seen.add(v); - assert.deepEqual([...seen].sort(), [1, 2, 3, 4, 5, 6]); + assert.deepEqual([...seen].sort(), [2, 3, 4, 5, 6]); }); test('square rolls have a single orientation', () => { @@ -213,13 +204,19 @@ test('difficulties after Hard: Tricky, Master and Legend dice', () => { const [a, b] = tricky.rollPair(); assert.ok(Core.TRICKY_FACES.includes(a) && Core.TRICKY_FACES.includes(b), `${a} × ${b}`); } - // Legend: a teen times 2–9, and only on a board it fits - const legend = Core.createPairDice(12, Math.random, 'legend'); - for (let i = 0; i < 200; i++) { + // Legend: teen × 2–9 for about LEGEND_TEEN_SHARE of the rolls, Tricky's facts otherwise + const legend = Core.createPairDice(20, Math.random, 'legend'); + let teens = 0; + for (let i = 0; i < 2000; i++) { const [a, b] = legend.rollPair(); - assert.ok(a >= 11 && a <= 12 && b >= 2 && b <= 9, `${a} × ${b}`); + const teen = a >= 11 && a <= 19 && b >= 2 && b <= 9; + if (teen) teens++; + else assert.ok(Core.TRICKY_FACES.includes(a) && Core.TRICKY_FACES.includes(b), `${a} × ${b}`); } + assert.ok(Math.abs(teens / 2000 - Core.LEGEND_TEEN_SHARE) < 0.05, `teen share ${teens / 2000}`); assert.equal(Core.fitsDifficulty('legend', 8), false); + assert.equal(Core.fitsDifficulty('legend', 10), false); + assert.equal(Core.fitsDifficulty('legend', 12), true); assert.equal(Core.fitsDifficulty('legend', 20), true); assert.equal(Core.fitsDifficulty('hard', 6), true); // Master: the weight picks the facts @@ -232,3 +229,82 @@ test('difficulties after Hard: Tricky, Master and Legend dice', () => { const roll = Core.rollForBoard(board, tricky, Math.random, 'always'); assert.ok(Core.TRICKY_FACES.includes(roll[0])); }); + +// Plays a CPU-vs-CPU game with the difficulty's dice; returns how many rectangles went down. +function playOut(size, difficulty) { + const board = Core.createBoard(size); + const bag = Core.usesPairDice(difficulty) ? Core.createPairDice(size, Math.random, difficulty) : Core.createDice(size, Math.random, Core.DIFFICULTY_SIDES[difficulty]); + let passes = 0; + let placed = 0; + while (passes < 2 && Core.emptyCount(board) > 0) { + const [a, b] = Core.rollForBoard(board, bag, Math.random, 'end'); + const move = Core.chooseComputerMove(board, a, b); + if (!move) { + passes++; + continue; + } + passes = 0; + Core.place(board, move.x, move.y, move.w, move.h, placed % 2); + placed++; + } + return placed; +} + +test('Tricky, Master and Legend games last about as long as Hard (not 3 rectangles)', () => { + for (const difficulty of ['tricky', 'master', 'legend']) { + let total = 0; + for (let i = 0; i < 30; i++) total += playOut(12, difficulty); + assert.ok(total / 30 >= 7, `${difficulty} on 12×12: ${total / 30} rectangles`); + total = 0; + for (let i = 0; i < 20; i++) total += playOut(24, difficulty); + assert.ok(total / 20 >= 16, `${difficulty} on 24×24: ${total / 20} rectangles`); + } +}); + +test('tiny dice roll every face evenly', () => { + assert.deepEqual(Core.diceProfile(2, 2), { sides: 2, common: [1, 2], rare: {} }); + const bag = Core.createDice(3, Math.random, 3); + const seen = new Set(); + for (let i = 0; i < 300; i++) seen.add(bag.roll()); + assert.deepEqual([...seen].sort(), [1, 2, 3]); + assert.deepEqual(Core.diceFaces('easy', 4), { a: [1, 2, 3, 4], b: [1, 2, 3, 4] }); +}); + +test('no 1 × 1 while something bigger fits; a 1 × 1 when nothing else does', () => { + const ones = (board, bag, n = 2000) => { + let count = 0; + for (let i = 0; i < n; i++) if (key(Core.rollForBoard(board, bag)) === key([1, 1])) count++; + return count; + }; + assert.equal(ones(Core.createBoard(6), Core.createDice(6)), 0, '6×6, d6'); + assert.equal(ones(Core.createBoard(2), Core.createDice(2, Math.random, 2)), 0, '2×2, d2 (a 1 × 1 a quarter of the time before)'); + assert.equal(ones(Core.createBoard(12), Core.createPairDice(12, Math.random, 'master')), 0, 'Master’s pair dice'); + // only single squares left: 1 × 1 it is + assert.deepEqual(Core.rollForBoard(boardWithHoles([[0, 0], [5, 5]]), Core.createDice(12)), [1, 1]); + // a 1-sided die can't roll anything else + assert.deepEqual(Core.rollForBoard(Core.createBoard(1), Core.createDice(1, Math.random, 1)), [1, 1]); +}); + +test('no 1 × N while something without a 1 fits, and no repeats until every shape has come up', () => { + const board = Core.createBoard(24); // (room for anything a d6 rolls) + const bag = Core.createDice(24); + const shapes = []; + for (let i = 0; i < 15; i++) { + const [a, b] = Core.rollForBoard(board, bag); + assert.ok(a > 1 && b > 1, `${a} × ${b}`); + shapes.push(a <= b ? `${a}x${b}` : `${b}x${a}`); + } + assert.equal(new Set(shapes).size, 15, 'the 15 shapes from 2 × 2 to 6 × 6, each once'); + const [a, b] = Core.rollForBoard(board, bag); + assert.ok(a > 1 && b > 1, 'then a repeat, still without a 1'); + assert.equal(bag.seen.length, 16); + // a 1-wide gap: 1 × N it is + const strip = boardWithHoles([[0, 0], [1, 0], [2, 0]]); + const [x, y] = Core.rollForBoard(strip, Core.createDice(12)); + assert.ok(x === 1 || y === 1); + // a saved bag (a live class) remembers its rolls across rolls + const state = Core.diceState(12, 6); + Core.rollForBoard(Core.createBoard(12), Core.diceFromState(state)); + assert.equal(JSON.parse(JSON.stringify(state)).seen.length, 1); + assert.deepEqual(Core.diceFromState({ ...state, seen: undefined }).seen, [], 'bags saved before still roll'); +}); diff --git a/packages/engine/test/match.test.js b/packages/engine/test/match.test.js new file mode 100644 index 0000000..331276f --- /dev/null +++ b/packages/engine/test/match.test.js @@ -0,0 +1,193 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import * as Match from '../src/match.js'; + +// A repeatable "random" for dice and the CPU +function seeded(seed = 1) { + let s = seed; + return () => ((s = (s * 16807) % 2147483647) - 1) / 2147483646; +} + +const solo = (opts = {}) => Match.createMatch({ players: [{ name: 'Ann' }, { name: 'CPU', cpu: true }], rng: seeded(), ...opts }); + +// Places the rolled rectangle at the first spot it fits +function placeAnywhere(m) { + const [a, b] = m.dice; + for (let y = 0; y < m.size; y++) + for (let x = 0; x < m.size; x++) + for (const [w, h] of [[a, b], [b, a]]) { + if (Match.judge(m, { x, y, w, h }).ok) return Match.place(m, { x, y, w, h }); + } + throw new Error('nothing fits'); +} + +test('a human turn: roll, place, answer right, points', () => { + const m = solo({ answerBonus: ({ firstTry }) => ({ total: firstTry ? 2 : 0, parts: [] }) }); + assert.equal(m.phase, 'roll'); + const [a, b] = Match.roll(m); + assert.equal(m.phase, 'place'); + const { ok, rect } = placeAnywhere(m); + assert.ok(ok); + assert.equal(m.phase, 'answer'); + const r = Match.answer(m, a * b, { ms: 1500 }); + assert.equal(r.correct, true); + assert.equal(r.firstTry, true); + assert.equal(m.players[0].score, rect.area + 2); + assert.equal(m.players[0].streak, 1); + assert.equal(m.phase, 'scored'); + assert.equal(Match.endTurn(m), null); + assert.equal(m.phase, 'cpu'); +}); + +test('a wrong size or a clash is turned down with the reason', () => { + const m = solo(); + const [a, b] = Match.roll(m, [2, 3]); + assert.deepEqual([a, b], [2, 3]); + assert.equal(Match.place(m, { x: 0, y: 0, w: 3, h: 3 }).verdict.rightSize, false); + assert.equal(Match.place(m, { x: 11, y: 0, w: 3, h: 2 }).verdict.fits, false); + assert.equal(Match.place(m, { x: 1, y: 1, w: 3, h: 2 }).ok, true); // either way round, anywhere +}); + +test('two misses: help, once a turn, and the miss is remembered with its answers', () => { + const m = solo(); + Match.roll(m, [3, 4]); + placeAnywhere(m); + assert.deepEqual(Match.answer(m, 11), { correct: false, attempts: 1, helpNow: false }); + assert.equal(Match.answer(m, 13).helpNow, true); + const h = Match.help(m); + assert.deepEqual(h.count.totals, [4, 8, 12]); + assert.equal(Match.help(m), null, 'only once'); + const r = Match.answer(m, 12); + assert.equal(r.firstTry, false); + assert.deepEqual(r.wrongAnswers, [11, 13]); + assert.deepEqual(m.players[0].stats.missed, [{ fact: '3 × 4', answers: [11, 13], timeout: false }]); + assert.equal(m.players[0].streak, 0); +}); + +test('Expert: no help, and bonus points count double', () => { + const m = solo({ expert: true, bonusMultiplier: 2, answerBonus: () => ({ total: 3, parts: [] }) }); + Match.roll(m, [2, 2]); + placeAnywhere(m); + Match.answer(m, 5); + assert.equal(Match.answer(m, 6).helpNow, false); + assert.equal(Match.help(m), null); + assert.equal(Match.answer(m, 4).bonus.total, 6); +}); + +test('out of time: the rectangle comes off, no points, not a pass', () => { + const m = solo(); + Match.roll(m, [2, 3]); + placeAnywhere(m); + Match.timeout(m); + assert.equal(m.board.rects.length, 0); + assert.equal(m.players[0].score, 0); + assert.equal(m.players[0].stats.timeouts, 1); + assert.equal(m.passes, 0); +}); + +test('the keypad checks by itself once more typing can no longer change the outcome', () => { + assert.equal(Match.answerDecided('', 12), false); + assert.equal(Match.answerDecided('1', 12), false); + assert.equal(Match.answerDecided('12', 12), true); + assert.equal(Match.answerDecided('9', 8), true); // overshoots + assert.equal(Match.answerDecided('7', 8), true); // as many digits as the answer +}); + +test('counting goes by the dice: 3 × 4 is 3 rows of 4 when drawn 4 wide', () => { + assert.deepEqual(Match.countPlan({ a: 3, b: 4, w: 4, h: 3 }), { byRows: true, group: 4, steps: 3, unit: 'row', totals: [4, 8, 12] }); + assert.equal(Match.countPlan({ a: 3, b: 4, w: 3, h: 4 }).unit, 'column'); + assert.deepEqual(Match.legendSplit({ a: 14, b: 7 }, 'legend').split, { teen: 14, other: 7, ones: 4, tens: 70, rest: 28 }); + assert.equal(Match.legendSplit({ a: 4, b: 7 }, 'legend'), null); +}); + +test('steps out of turn are refused', () => { + const m = solo(); + assert.throws(() => Match.answer(m, 3), /not now/); + assert.throws(() => Match.cpuTurn(m), /not now/); +}); + +test('whole games end, with every square or every player passing, and the scores add up', () => { + for (let seed = 1; seed <= 40; seed++) { + const m = Match.createMatch({ size: 6 + (seed % 3) * 2, difficulty: ['easy', 'medium', 'hard'][seed % 3], players: [{ name: 'Ann' }, { name: 'CPU', cpu: true }], rng: seeded(seed) }); + let result = null; + for (let turn = 0; turn < 500 && !result; turn++) { + if (m.phase === 'cpu') Match.cpuTurn(m); + else { + Match.roll(m); + if (m.phase === 'stuck') Match.pass(m); + else { + const { rect } = placeAnywhere(m); + Match.answer(m, rect.area); + } + } + result = Match.endTurn(m); + } + assert.ok(result, `seed ${seed} ended`); + assert.equal(m.phase, 'over'); + assert.ok(result.empty === 0 || m.passes >= 2); + const area = m.board.rects.reduce((sum, r) => sum + r.area, 0); + assert.equal(m.players.reduce((sum, p) => sum + p.squares, 0), area); + assert.equal(result.ranked[0].score, Math.max(...m.players.map((p) => p.score))); + } +}); + +test('two players on one screen take turns; a CPU first starts with its turn', () => { + const m = Match.createMatch({ players: [{ name: 'Ann' }, { name: 'Ben' }], mode: 'multi', rng: seeded(3) }); + Match.roll(m); + if (m.phase === 'stuck') Match.pass(m); + else Match.answer(m, placeAnywhere(m).rect.area); + Match.endTurn(m); + assert.equal(m.current, 1); + assert.equal(m.phase, 'roll'); + assert.equal(Match.createMatch({ players: [{ name: 'CPU', cpu: true }, { name: 'Ann' }] }).phase, 'cpu'); +}); + +test('a match notes when it started (for the history)', () => { + assert.equal(Match.createMatch({ players: [{ name: 'Ann' }], now: () => 1234 }).startedAt, 1234); +}); + +test('auto placing picks a spot that fits', () => { + const m = solo(); + Match.roll(m, [2, 3]); + const spot = Match.autoSpot(m); + assert.ok(Match.judge(m, spot).ok); +}); + +test('“Help me count” first: right, but not first time (no bonus, no streak)', () => { + const m = Match.createMatch({ mode: 'single', difficulty: 'easy', size: 6, players: [{ name: 'You' }, { name: 'CPU', cpu: true }], answerBonus: ({ firstTry }) => (firstTry ? { total: 2, parts: [{ label: 'first try', points: 2 }] } : { total: 0, parts: [] }) }); + Match.roll(m, [2, 3]); + Match.place(m, { x: 0, y: 0, w: 3, h: 2 }); + assert.ok(Match.help(m)); + const res = Match.answer(m, 6, { ms: 500 }); + assert.equal(res.correct, true); + assert.equal(res.firstTry, false); + assert.equal(res.bonus.total, 0); + assert.equal(m.players[0].score, 6, 'just the squares'); + assert.equal(m.players[0].streak, 0); +}); + +test('Journey levels: tiny boards with an n-sided die play to the end', () => { + for (let n = 1; n <= 4; n++) { + for (let seed = 1; seed <= 20; seed++) { + const m = solo({ size: n, sides: n, rng: seeded(seed) }); + assert.equal(m.sides, n); + assert.deepEqual(m.faces.a, Array.from({ length: n }, (_, i) => i + 1)); + let res = null; + for (let step = 0; !res && step < 200; step++) { + if (m.phase === 'roll') { + const [a, b] = Match.roll(m); + assert.ok(a <= n && b <= n, `${a}×${b} on a d${n}`); + } else if (m.phase === 'place') placeAnywhere(m); + else if (m.phase === 'stuck') Match.pass(m); + else if (m.phase === 'answer') Match.answer(m, m.dice[0] * m.dice[1]); + else if (m.phase === 'cpu') Match.cpuTurn(m); + else if (m.phase === 'scored') res = Match.endTurn(m); + } + assert.ok(res, `the ${n}×${n} game ended`); + assert.equal(res.empty, 0, 'rolls that fit fill the board'); + } + } + // level 1: one 1×1 and it's yours + const one = solo({ size: 1, sides: 1 }); + assert.deepEqual(Match.roll(one), [1, 1]); +}); diff --git a/test/prototypes.test.js b/packages/engine/test/ops.test.js similarity index 95% rename from test/prototypes.test.js rename to packages/engine/test/ops.test.js index 2d57466..a65d832 100644 --- a/test/prototypes.test.js +++ b/packages/engine/test/ops.test.js @@ -1,8 +1,8 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const Fr = require('../prototypes/shared/fraction.js'); -const B = require('../prototypes/shared/boards.js'); -const { VARIANTS, byId, BAR_UNITS } = require('../prototypes/shared/variants.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as Fr from '../src/ops/fraction.js'; +import * as B from '../src/ops/boards.js'; +import { VARIANTS, byId, BAR_UNITS } from '../src/ops/variants.js'; const { F } = Fr; diff --git a/packages/game-kit/package.json b/packages/game-kit/package.json new file mode 100644 index 0000000..ae8e514 --- /dev/null +++ b/packages/game-kit/package.json @@ -0,0 +1,21 @@ +{ + "name": "@blockout/game-kit", + "version": "0.11.0", + "private": true, + "description": "The shared page engine for the other operations' games (addition, subtraction, division, fractions): one UI driven by an operation from @blockout/engine/variants.", + "type": "module", + "exports": { + ".": "./src/index.js", + "./meta": "./src/meta.js", + "./style.css": "./src/style.css" + }, + "dependencies": { + "@blockout/engine": "*", + "@blockout/ui": "*", + "@blockout/progress": "*" + }, + "scripts": { + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/prototypes/shared/proto.css b/packages/game-kit/src/games.css similarity index 87% rename from prototypes/shared/proto.css rename to packages/game-kit/src/games.css index 520380d..150db83 100644 --- a/prototypes/shared/proto.css +++ b/packages/game-kit/src/games.css @@ -1,4 +1,4 @@ -/* Prototype extras on top of ../css/style.css (same tokens, buttons, dice, keypad, dialogs) */ +/* The other games' extras on top of @blockout/ui (same tokens, buttons, dice, keypad, dialogs) */ :root { --stone: #d3c9b6; @@ -189,7 +189,7 @@ padding: 18px; } .hub-card h2 { margin: 0; font-size: 1.3rem; display: flex; align-items: center; gap: 10px; } -.hub-card .grade { margin: 0; font-size: 0.82rem; font-weight: 800; color: var(--accent); } +.hub-card .grade { margin: 0; font-size: 0.82rem; font-weight: 800; color: var(--accent-text); } .hub-card .tag { margin: 0; font-weight: 800; } .hub-card .idea { margin: 0; color: var(--muted); font-size: 0.92rem; line-height: 1.45; flex: 1; } .hub-card .btn { align-self: flex-start; text-decoration: none; color: var(--accent-ink); margin-top: 6px; } @@ -199,3 +199,15 @@ } .board-wrap { -webkit-tap-highlight-color: transparent; } + +/* ---------- Shop, stats: question chips coloured by how well they're known ---------- */ +.fact-chips-legend, .fact-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.fact-chips-legend { margin-bottom: 10px; } +.fact-family { margin-bottom: 12px; } +.fact-chip { padding: 2px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 800; background: var(--soft-2); color: var(--ink); } +.fact-chip.mastered { background: var(--good); color: #fff; } +.fact-chip.learning { background: var(--learning); color: var(--learning-ink); } +.fact-chip.practice { background: var(--danger); color: #fff; } +.shop-owned { font-weight: 800; color: var(--good-ink); white-space: nowrap; } +#shop-list .shop-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 8px 12px; margin-bottom: 6px; } +#shop-list .shop-text { display: flex; flex-direction: column; gap: 2px; text-align: left; } diff --git a/prototypes/shared/engine.js b/packages/game-kit/src/index.js similarity index 73% rename from prototypes/shared/engine.js rename to packages/game-kit/src/index.js index 450bd37..d3800d7 100644 --- a/prototypes/shared/engine.js +++ b/packages/game-kit/src/index.js @@ -1,14 +1,27 @@ -// UI for the Blockout prototypes: one engine, driven by the variant named on -// . Single player (you vs the CPU) and Practice only. -(function () { - 'use strict'; - - const Fr = window.Fraction; - const B = window.ProtoBoards; - const V = window.ProtoVariants.byId(document.body.dataset.variant); +// The shared page engine for Blockout's other games (addition, subtraction, +// division and the four fraction games): one UI, driven by an operation's rules +// from @blockout/engine/variants. Single player (you vs the CPU), Home +// multiplayer (two players on one screen) and Practice, with points, a shop, +// achievements and stats (./meta.js). +// import { startGame } from '@blockout/game-kit'; +// startGame('addition'); +import * as Fr from '@blockout/engine/fraction'; +import * as B from '@blockout/engine/boards'; +import * as Variants from '@blockout/engine/variants'; +import { toast, queueAchievements, confettiFullScreen } from '@blockout/ui/rewards'; +import * as M from './meta.js'; + +export function startGame(variantId) { + + const V = Variants.byId(variantId); + if (!V) throw new Error(`No game called ${variantId}`); const COLORS = ['#e4572e', '#2e86de']; - const NAMES = ['You', 'CPU']; + const CPU_NAMES = ['You', 'CPU']; + const HOME_NAMES = ['Player 1', 'Player 2']; + const META_KEY = `blockout.game.${V.id}`; // points, unlocks, achievements, stats + const ITEMS = M.shopFor(V); + const ACHIEVEMENTS = M.achievementsFor(V); const CPU_STEP_MS = window.PROTO_FAST ? 30 : 1000; // PROTO_FAST: quick CPU for automated checks const ROLL_MS = 550; const PIPS = { 1: [4], 2: [0, 8], 3: [0, 4, 8], 4: [0, 2, 6, 8], 5: [0, 2, 4, 6, 8], 6: [0, 2, 3, 5, 6, 8] }; @@ -32,6 +45,25 @@ const wait = (ms) => new Promise((r) => setTimeout(r, ms)); const fmtScore = (f) => Fr.mixed(f); + // ---------------------------------------------------------------- points, unlocks, achievements + + const meta = (() => { + try { + return M.normalizeMeta(JSON.parse(localStorage.getItem(META_KEY))); + } catch (e) { + return M.createMeta(); + } + })(); + function saveMeta() { + try { + localStorage.setItem(META_KEY, JSON.stringify(meta)); + } catch (e) {} + const w = document.getElementById('wallet-amount'); + if (w) w.textContent = meta.wallet; + } + const names = () => (game && game.multi ? HOME_NAMES : CPU_NAMES); + const celebrateAll = (earned) => earned.length && queueAchievements(earned); + function loadSettings() { const d = { mode: 'cpu', level: 'easy', count: 10, practiceLevel: 'learn', families: V.practice.defaults }; try { @@ -50,33 +82,26 @@ document.title = `${V.name}: ${V.title} · Blockout prototype`; document.body.insertAdjacentHTML('afterbegin', ` -
    - ${icon('arrow-left')} All prototypes + ${icon('arrow-left')} More games +
    🪙 0 points

    BLOCKOUT

    ${esc(V.op)} ${esc(V.name)} · ${esc(V.title)}

    ${esc(V.tagline)}

    -
    +
    + - + +

    @@ -115,8 +140,11 @@
    -
    @@ -179,6 +207,45 @@
    + + + + + + + +