From 58e131d0b2edf911f7083f873f556d77ea3b96d5 Mon Sep 17 00:00:00 2001 From: Warren H Date: Tue, 23 Jun 2026 21:11:43 -0400 Subject: [PATCH] I Want To Heal 2 web/server v1.0.0 --- .gitignore | 4 + AGENTS.md | 9 + Action Mode Documents/README.md | 9 + Action Mode Documents/combat-mechanics.md | 57 + Action Mode Documents/implementation-notes.md | 25 + Action Mode Documents/progression.md | 44 + README.md | 49 + docker-compose.truenas.yml | 38 + docs/truenas-deploy.md | 125 + eslint.config.js | 25 + index.html | 12 + package-lock.json | 2904 +++++++++++++++++ package.json | 35 + server/db-init.mjs | 79 + server/db.mjs | 32 + server/index.mjs | 219 ++ src/actionBoss/BulldromeScene.ts | 530 +++ src/actionBoss/actionEncounterConfig.ts | 175 + src/actionBoss/bulldromeSimulation.ts | 1652 ++++++++++ src/actionMode.ts | 448 +++ src/components/ActionModeScreen.tsx | 790 +++++ src/components/BulldromeBossSlice.tsx | 346 ++ src/main.tsx | 10 + src/styles.css | 2171 ++++++++++++ tsconfig.app.json | 21 + tsconfig.json | 7 + tsconfig.node.json | 18 + vite.config.ts | 6 + 28 files changed, 9840 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Action Mode Documents/README.md create mode 100644 Action Mode Documents/combat-mechanics.md create mode 100644 Action Mode Documents/implementation-notes.md create mode 100644 Action Mode Documents/progression.md create mode 100644 README.md create mode 100644 docker-compose.truenas.yml create mode 100644 docs/truenas-deploy.md create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/db-init.mjs create mode 100644 server/db.mjs create mode 100644 server/index.mjs create mode 100644 src/actionBoss/BulldromeScene.ts create mode 100644 src/actionBoss/actionEncounterConfig.ts create mode 100644 src/actionBoss/bulldromeSimulation.ts create mode 100644 src/actionMode.ts create mode 100644 src/components/ActionModeScreen.tsx create mode 100644 src/components/BulldromeBossSlice.tsx create mode 100644 src/main.tsx create mode 100644 src/styles.css create mode 100644 tsconfig.app.json create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d70bb9c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +dist +.DS_Store +*.local diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ec134c4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,9 @@ +# Project Notes + +- AYN Thor main display: 6-inch AMOLED, 1920 x 1080, 120Hz. +- AYN Thor secondary display: 3.92-inch AMOLED, 1240 x 1080, 60Hz. +- AYN Thor UI sizing must be designed against Android CSS/layout viewport, not physical framebuffer pixels. +- Approximate Thor CSS viewports: main display 960 x 540, secondary display 620 x 540. +- Test top-screen UI only against the main display viewport, and bottom-screen UI only against the secondary display viewport. +- User rebuilds app; do not rebuild APK unless explicitly requested. +- Apply game changes to both web version and mobile app version. diff --git a/Action Mode Documents/README.md b/Action Mode Documents/README.md new file mode 100644 index 0000000..2ce7240 --- /dev/null +++ b/Action Mode Documents/README.md @@ -0,0 +1,9 @@ +# Action Mode Documents + +This folder tracks the Action Mode design and implementation decisions. + +## Documents + +- `progression.md`: Action Mode character, dungeon tiers, coins, loot, and gear upgrade rules. +- `combat-mechanics.md`: Current boss and mob mechanics, how reusable attack modules work, and admin tuning notes. +- `implementation-notes.md`: Code ownership, main files, and next-step guidelines. diff --git a/Action Mode Documents/combat-mechanics.md b/Action Mode Documents/combat-mechanics.md new file mode 100644 index 0000000..c387418 --- /dev/null +++ b/Action Mode Documents/combat-mechanics.md @@ -0,0 +1,57 @@ +# Action Mode Combat Mechanics + +## Current Dungeons + +### Bulldrome Hunting Grounds + +- iLvl 1 starts with one Bullfango pack, then Bulldrome. +- Higher tiers start with Bulldrome plus two Bullfangos. +- Bulldrome can charge, attack the tank, and ground slam after configured charge counts. +- Bullfangos can charge and attack the tank. + +### Yian Kut-Ku Roost + +- Trash fight: three Birds. +- Boss fight: Yian Kut-Ku plus two Birds. +- Yian Kut-Ku fires three bouncing fireballs: northeast, northwest, and south. +- Fireballs bounce off walls and players. +- Fireballs leave fire on wall/player impact. +- Fireballs persist until roughly one second before the next volley. +- Birds fly to the left side, dive across the arena, then return to the tank. + +## Reusable Attack Modules + +Action Mode mobs and bosses use configurable attack modules: + +- `tankMelee` +- `charge` +- `groundSlam` +- `fireballVolley` +- `birdDive` +- `bodyContact` + +Each module can define: + +- enabled/disabled state +- frequency +- damage +- windup +- recovery +- speed +- radius +- special counters such as every N charges + +The simulation reads from the mechanic registry at run start and during combat. Disabling an attack removes that behavior from new runs. + +## Admin Tuning + +The Action Mode Settings screen includes a mechanic admin panel. It can: + +- show each mob and boss +- edit attack frequency and damage +- edit attack timing values +- remove attacks +- add removed attacks back +- reset defaults + +Changes persist in local storage and apply to new Action Mode runs. diff --git a/Action Mode Documents/implementation-notes.md b/Action Mode Documents/implementation-notes.md new file mode 100644 index 0000000..1cf0d3b --- /dev/null +++ b/Action Mode Documents/implementation-notes.md @@ -0,0 +1,25 @@ +# Action Mode Implementation Notes + +## Main Files + +- `src/actionMode.ts`: Action Mode character save, tier data, coins, rewards, gear stats, and upgrade rules. +- `src/actionBoss/actionEncounterConfig.ts`: reusable mob/boss attack module defaults and local admin persistence. +- `src/actionBoss/bulldromeSimulation.ts`: Action Mode combat simulation and enemy state machines. +- `src/actionBoss/BulldromeScene.ts`: Phaser rendering/input adapter. +- `src/components/BulldromeBossSlice.tsx`: browser fight shell, party frames, boss bar, spell bar, and result handling. +- `src/components/ActionModeScreen.tsx`: Action Mode menus, dungeons, customization, and mechanics admin UI. +- `src/App.css`: Action Mode layout and visual styling. + +## Architecture Rules + +- Keep combat rules in simulation files, not Phaser scene rendering code. +- Keep text-heavy UI in React/DOM. +- Add new boss mechanics as reusable attack modules before wiring them to a specific boss. +- New dungeon tiers should be added to `ACTION_DIFFICULTY_TIERS`. +- New boss coin families should extend the Action Mode coin wallet instead of reusing Normal Mode currencies. + +## Current Follow-Ups + +- Add Rathian dungeon mechanics. +- Move Action Mode mechanic config from local storage into SQL tables when the backend persistence pass starts. +- Add admin CRUD for new attacks, not just enable/disable existing modules. diff --git a/Action Mode Documents/progression.md b/Action Mode Documents/progression.md new file mode 100644 index 0000000..97217e1 --- /dev/null +++ b/Action Mode Documents/progression.md @@ -0,0 +1,44 @@ +# Action Mode Progression + +## Mode Separation + +Action Mode has its own character, inventory, coins, experience, and dungeon progress. Normal Mode data remains separate. + +## Dungeon Item-Level Tiers + +Action Mode dungeons use four item-level tiers: + +| Tier | Coin Color | Boss Health | Boss Damage | Loot Multiplier | +| --- | --- | ---: | ---: | ---: | +| iLvl 1 | White | 1.00x | 1.00x | 1x | +| iLvl 10 | Green | 1.55x | 1.28x | 2x | +| iLvl 20 | Blue | 2.25x | 1.62x | 3x | +| iLvl 30 | Purple | 3.10x | 2.05x | 4x | + +The selected tier controls: + +- enemy health scaling +- enemy attack damage scaling +- coin color dropped +- item level of dropped gear +- XP reward + +## Coins + +Coins are tracked per boss family and per tier color. + +- Bulldrome: White/Green/Blue/Purple Bulldrome Coins +- Yian Kut-Ku: White/Green/Blue/Purple Yian Kut-Ku Coins + +Legacy white coin totals are migrated into the new wallet. + +## Gear + +Boss gear drops at the selected dungeon tier: + +- iLvl 1 tier drops iLvl 1 gear +- iLvl 10 tier drops iLvl 10 gear +- iLvl 20 tier drops iLvl 20 gear +- iLvl 30 tier drops iLvl 30 gear + +Gear can upgrade within its tier band. Current rule: a piece can upgrade up to four item levels above its base tier using coins from that tier color. diff --git a/README.md b/README.md new file mode 100644 index 0000000..307b4b4 --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +# Action Mode + +Standalone browser-only Action Mode project. + +## Run + +```bash +npm install +npm run dev +``` + +## Build + +```bash +npm run build +``` + +## TrueNAS Custom App + +Use `docker-compose.truenas.yml` for the new app deployment. Copy this repo into: + +```text +/mnt/usbssds/apps/iwanttoheal2/app +``` + +The compose file runs the web app on port `4173` with a Postgres database stored under: + +```text +/mnt/usbssds/apps/iwanttoheal2/db +``` + +It initializes tables for auth users, cloud save slots, mechanic configs, PvP queue, and PvP matches. + +Set a real `POSTGRES_PASSWORD` in the TrueNAS app environment before first launch. The compose file includes the production origins: + +```text +https://iwanttoheal.phenomrom.com +https://auth.phenomrom.com +``` + +Server save/PvP endpoints require authenticated users. Header-based auth stays disabled until `TRUST_AUTH_HEADERS=1` is set and the reverse proxy/auth service is configured to strip incoming `x-auth-*` headers and inject trusted values from `auth.phenomrom.com`. + +Step-by-step local Git, Gitea release, and TrueNAS update commands live in [docs/truenas-deploy.md](docs/truenas-deploy.md). + +## Notes + +- This project does not import or depend on the Normal Mode game. +- Browser UI still needs the next wiring pass to sync local saves/mechanic edits through the server API. +- Design/architecture notes live in `Action Mode Documents/`. diff --git a/docker-compose.truenas.yml b/docker-compose.truenas.yml new file mode 100644 index 0000000..ef8e8c0 --- /dev/null +++ b/docker-compose.truenas.yml @@ -0,0 +1,38 @@ +services: + iwanttoheal: + command: sh -lc "npm ci && npm run db:init && npm run build && npm start" + depends_on: + iwanttoheal-db: + condition: service_healthy + environment: + AUTH_ISSUER: https://auth.phenomrom.com + COOKIE_SECURE: '1' + CORS_ORIGINS: >- + http://localhost,https://localhost,capacitor://localhost,https://iwanttoheal.phenomrom.com,https://auth.phenomrom.com + DATABASE_URL: postgres://iwanttoheal:${POSTGRES_PASSWORD:-change-me-iwanttoheal2}@iwanttoheal-db:5432/iwanttoheal + HOST: 0.0.0.0 + PORT: '4173' + TRUST_PROXY: '1' + image: node:24-bookworm-slim + ports: + - '4173:4173' + restart: unless-stopped + volumes: + - /mnt/usbssds/apps/iwanttoheal2/app:/app + - /mnt/usbssds/apps/iwanttoheal2/data:/app/data + working_dir: /app + + iwanttoheal-db: + environment: + POSTGRES_DB: iwanttoheal + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me-iwanttoheal2} + POSTGRES_USER: iwanttoheal + healthcheck: + test: ["CMD-SHELL", "pg_isready -U iwanttoheal -d iwanttoheal"] + interval: 10s + timeout: 5s + retries: 10 + image: postgres:17-bookworm + restart: unless-stopped + volumes: + - /mnt/usbssds/apps/iwanttoheal2/db:/var/lib/postgresql/data diff --git a/docs/truenas-deploy.md b/docs/truenas-deploy.md new file mode 100644 index 0000000..f6b58d0 --- /dev/null +++ b/docs/truenas-deploy.md @@ -0,0 +1,125 @@ +# I Want To Heal 2 Deployment + +## Current Limits + +This repo currently deploys the web/server app. It does not yet contain an Android/Capacitor project, so the old APK build step cannot run here until mobile scaffolding is added. + +Do not hard-code Gitea tokens in repo files. Export `GITEA_TOKEN` in your shell. + +## One-Time Local Git Setup + +```bash +cd /Users/warren/Documents/action-mode + +git init +git branch -M main +git remote add origin https://git.whoagland.com/phenom/i-want-to-heal-2.git +``` + +If the remote already exists: + +```bash +cd /Users/warren/Documents/action-mode +git remote set-url origin https://git.whoagland.com/phenom/i-want-to-heal-2.git +``` + +## Step 1: Web/Server Build Check + +```bash +set -e + +cd /Users/warren/Documents/action-mode + +npm ci +npm run lint +npm run build +``` + +## Step 2: Commit And Push Code + +```bash +set -e + +cd /Users/warren/Documents/action-mode + +VERSION="1.0.0" + +git add . +git commit -m "I Want To Heal 2 web/server v$VERSION" +git push origin main +``` + +## Step 3: Create Gitea Release + +```bash +set -e + +cd /Users/warren/Documents/action-mode + +export GITEA_URL="https://git.whoagland.com" +export GITEA_OWNER="phenom" +export GITEA_REPO="i-want-to-heal-2" +# export GITEA_TOKEN="paste-current-token-in-shell-only" + +VERSION="1.0.0" + +RELEASE_JSON=$(curl -sS -X POST "$GITEA_URL/api/v1/repos/$GITEA_OWNER/$GITEA_REPO/releases" \ + -H "Authorization: token $GITEA_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"v$VERSION\",\"target_commitish\":\"main\",\"name\":\"v$VERSION\",\"body\":\"I Want To Heal 2 web/server build v$VERSION\",\"draft\":false,\"prerelease\":false}") + +RELEASE_ID=$(python3 -c 'import sys,json; data=json.load(sys.stdin); print(data.get("id") or data)' <<< "$RELEASE_JSON") +echo "Release ID: $RELEASE_ID" +``` + +## Step 4: First TrueNAS Install + +In the TrueNAS shell: + +```bash +set -e + +mkdir -p /mnt/usbssds/apps/iwanttoheal2 +cd /mnt/usbssds/apps/iwanttoheal2 + +git clone https://git.whoagland.com/phenom/i-want-to-heal-2.git app +mkdir -p data db +``` + +Create the TrueNAS custom app from `docker-compose.truenas.yml`, or paste that compose file into the custom app UI. + +Set these app environment values: + +```text +POSTGRES_PASSWORD= +``` + +Only set this after the reverse proxy/auth layer strips spoofed inbound auth headers and injects trusted user headers: + +```text +TRUST_AUTH_HEADERS=1 +``` + +## Step 5: Update TrueNAS + +In the TrueNAS shell: + +```bash +set -e + +cd /mnt/usbssds/apps/iwanttoheal2/app +git pull origin main +``` + +Then restart the TrueNAS custom app. + +## Admin Changes + +There is no separate admin server yet. Mechanic admin is currently inside the web app under Action Mode settings. This command serves the same app on port `4174` for local admin work: + +After server-side admin is split out, keep this local command shape: + +```bash +cd /Users/warren/Documents/action-mode +DATABASE_URL="postgres://iwanttoheal:@127.0.0.1:5432/iwanttoheal" PORT=4174 npm run admin:start +``` diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..1a00096 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,25 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2022, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + }, + }, +) diff --git a/index.html b/index.html new file mode 100644 index 0000000..e9d2478 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + Action Mode + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..be32901 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2904 @@ +{ + "name": "action-mode", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "action-mode", + "version": "0.1.0", + "dependencies": { + "pg": "^8.22.0", + "phaser": "^4.2.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "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==", + "dev": true, + "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/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@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-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==", + "dev": true, + "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-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "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==", + "dev": true, + "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-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/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==", + "dev": true, + "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.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "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.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "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.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "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/@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/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", + "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==", + "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.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz", + "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==", + "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.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz", + "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==", + "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.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz", + "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==", + "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.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz", + "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==", + "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.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz", + "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz", + "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz", + "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz", + "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz", + "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz", + "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz", + "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz", + "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz", + "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==", + "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.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz", + "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==", + "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/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.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/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/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.62.0.tgz", + "integrity": "sha512-o+mpz7EYiMzXoySXiKmzlabIvTVqUuK5yLrAedRPRDA0IpPFMUV1IXt6OqljIxX/kumN6EjUYp41Hqelh6p/Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/type-utils": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.62.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.62.0.tgz", + "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.62.0.tgz", + "integrity": "sha512-wexnCqiTg7BOGtbLDftYpRWlmLq4xfoMd7BKFR6Y75sZS3QmRKLdN3yWLhmIYgqMmP/OXWpj3H8odkb5nGURCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.62.0", + "@typescript-eslint/types": "^8.62.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.62.0.tgz", + "integrity": "sha512-1lX38kNxXIRb8mEc3lbq5mdHq1Pf2+U0nFU65KfT18mtPxxl0fvjuEE92mHuXPuCtElJhOrddOpyMlM3Z0umEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.62.0.tgz", + "integrity": "sha512-y2GAdB6ykaXUvuspbYnizQc4oDDz0Tz/Yc7iWrXf9mx8vm/L/0vLHCe0tS2boG96Zy+DivnVDQ9ZUEWoHqqx1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.62.0.tgz", + "integrity": "sha512-+g5O3j0w2ldzC86Pv6fvbO/xhAonbJFIdf/MKQ1d30gndlsVzUOE83ldfSE15Qrl9fhFjK6AovHs5Wpp6vx86w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.62.0.tgz", + "integrity": "sha512-KvAclkktORPvM54TgLgA4z9HIV1M8zOgw9ZVNXl9f/8dLYfXYX1wkMXP7qmabpijQRV5bHJLOmoyGQbLMaUYeg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.62.0.tgz", + "integrity": "sha512-+hVbNxtW64pIcZWDPGbyaKF7vp2IBTVY5ma1blwwksrjdsbdqqEKvJWMGbBofei4F6Dovx1M0RJgoFeNu2279A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.62.0", + "@typescript-eslint/tsconfig-utils": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/visitor-keys": "8.62.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.62.0.tgz", + "integrity": "sha512-82r66fi9zYwZ+mTq3vKgwjbZ1PVk/DJzrXFLpG6RnBbdvH8TEGVHIs9H4d2drhkOzf0syZuD/OZvvlu6GDbP4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.62.0", + "@typescript-eslint/types": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.62.0.tgz", + "integrity": "sha512-CY3uyFSRbcQv3nnSv8S0+lDftMVz6P963PoRlxrV7ew/Md564g9ut60PYzdLM5qW4jFn93GBF+Soi90ISAN+GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.62.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-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/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/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.378", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz", + "integrity": "sha512-VinvOAuuPmdD1guEgGv5f2Qp7/vlfqOrUOMYNnOD4wj3pit8kRsQHzfIf6teyUGWo15Tg5+bOJaRunvyltpVWQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "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.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@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": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "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-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "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/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/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": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/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/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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "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.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/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/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-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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/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/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==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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/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/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/phaser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/phaser/-/phaser-4.2.0.tgz", + "integrity": "sha512-9nSQZs4CJ9+V96i2mRC3BBStBcsQWGJJBRpdFYSbpL40/i/QM+wLofaCYMsxNyDk8WJOlHGZUh3uVRKa7lj6yg==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.4" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/rolldown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz", + "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.2", + "@rolldown/binding-darwin-arm64": "1.1.2", + "@rolldown/binding-darwin-x64": "1.1.2", + "@rolldown/binding-freebsd-x64": "1.1.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.2", + "@rolldown/binding-linux-arm64-gnu": "1.1.2", + "@rolldown/binding-linux-arm64-musl": "1.1.2", + "@rolldown/binding-linux-ppc64-gnu": "1.1.2", + "@rolldown/binding-linux-s390x-gnu": "1.1.2", + "@rolldown/binding-linux-x64-gnu": "1.1.2", + "@rolldown/binding-linux-x64-musl": "1.1.2", + "@rolldown/binding-openharmony-arm64": "1.1.2", + "@rolldown/binding-wasm32-wasi": "1.1.2", + "@rolldown/binding-win32-arm64-msvc": "1.1.2", + "@rolldown/binding-win32-x64-msvc": "1.1.2" + } + }, + "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": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "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/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.62.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.62.0.tgz", + "integrity": "sha512-8QxXi+ZACKX0kaqO4gY8kn0RSD9gFfaHDWwjqtEN48aWCBkX4MJaufWN+c3BzlrXLOxfywDL8CaoqUwcRq4j4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.62.0", + "@typescript-eslint/parser": "8.62.0", + "@typescript-eslint/typescript-estree": "8.62.0", + "@typescript-eslint/utils": "8.62.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "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.3.0", + "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/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/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/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "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": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7bbcb67 --- /dev/null +++ b/package.json @@ -0,0 +1,35 @@ +{ + "name": "action-mode", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "admin:start": "PORT=4174 node server/index.mjs", + "db:init": "node server/db-init.mjs", + "dev": "vite", + "build": "tsc -b --noEmit && vite build", + "lint": "eslint .", + "preview": "vite preview", + "start": "node server/index.mjs" + }, + "dependencies": { + "pg": "^8.22.0", + "phaser": "^4.2.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/server/db-init.mjs b/server/db-init.mjs new file mode 100644 index 0000000..ba95a64 --- /dev/null +++ b/server/db-init.mjs @@ -0,0 +1,79 @@ +import { createPool, waitForDatabase } from './db.mjs' + +const pool = createPool() + +try { + await waitForDatabase(pool) + await pool.query('begin') + await pool.query(` + create extension if not exists pgcrypto; + + create table if not exists app_users ( + id uuid primary key default gen_random_uuid(), + auth_issuer text not null, + auth_subject text not null, + display_name text not null default 'Healer', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (auth_issuer, auth_subject) + ); + + create table if not exists save_slots ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references app_users(id) on delete cascade, + slot_key text not null default 'default', + save_json jsonb not null, + save_version integer not null default 1, + client_updated_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, slot_key) + ); + + create table if not exists action_mechanic_configs ( + id uuid primary key default gen_random_uuid(), + user_id uuid references app_users(id) on delete cascade, + config_key text not null default 'default', + config_json jsonb not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, config_key) + ); + + create table if not exists pvp_queue ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references app_users(id) on delete cascade, + mode text not null default 'action-healer', + rating integer not null default 1000, + status text not null default 'queued', + queued_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (user_id, mode, status) + ); + + create table if not exists pvp_matches ( + id uuid primary key default gen_random_uuid(), + mode text not null default 'action-healer', + status text not null default 'pending', + player_one_id uuid not null references app_users(id) on delete cascade, + player_two_id uuid references app_users(id) on delete cascade, + match_state jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + completed_at timestamptz + ); + + create index if not exists save_slots_user_id_idx on save_slots(user_id); + create index if not exists pvp_queue_mode_status_idx on pvp_queue(mode, status, queued_at); + create index if not exists pvp_matches_player_one_idx on pvp_matches(player_one_id); + create index if not exists pvp_matches_player_two_idx on pvp_matches(player_two_id); + `) + await pool.query('commit') + console.log('Database initialized.') +} catch (error) { + await pool.query('rollback').catch(() => {}) + console.error(error) + process.exitCode = 1 +} finally { + await pool.end() +} diff --git a/server/db.mjs b/server/db.mjs new file mode 100644 index 0000000..4b46cb0 --- /dev/null +++ b/server/db.mjs @@ -0,0 +1,32 @@ +import pg from 'pg' + +const { Pool } = pg + +export function getDatabaseUrl() { + const databaseUrl = process.env.DATABASE_URL + if (!databaseUrl) { + throw new Error('DATABASE_URL is required for server database access.') + } + return databaseUrl +} + +export function createPool() { + return new Pool({ + connectionString: getDatabaseUrl(), + max: Number(process.env.DB_POOL_MAX ?? 10), + }) +} + +export async function waitForDatabase(pool, attempts = 30) { + let lastError + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + await pool.query('select 1') + return + } catch (error) { + lastError = error + await new Promise((resolve) => setTimeout(resolve, Math.min(5000, attempt * 500))) + } + } + throw lastError +} diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..83f0862 --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,219 @@ +import { createReadStream, existsSync, statSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { createServer } from 'node:http' +import { extname, join, normalize } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createPool, waitForDatabase } from './db.mjs' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) +const rootDir = normalize(join(__dirname, '..')) +const distDir = join(rootDir, 'dist') +const host = process.env.HOST ?? '0.0.0.0' +const port = Number(process.env.PORT ?? 4173) +const pool = createPool() + +const corsOrigins = new Set( + (process.env.CORS_ORIGINS ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean), +) + +const mimeTypes = { + '.css': 'text/css; charset=utf-8', + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.map': 'application/json; charset=utf-8', + '.png': 'image/png', + '.svg': 'image/svg+xml', + '.webp': 'image/webp', +} + +await waitForDatabase(pool) + +const server = createServer(async (request, response) => { + try { + applyCors(request, response) + if (request.method === 'OPTIONS') { + response.writeHead(204) + response.end() + return + } + + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`) + if (url.pathname.startsWith('/api/')) { + await handleApi(request, response, url) + return + } + + await serveStatic(response, url.pathname) + } catch (error) { + console.error(error) + sendJson(response, 500, { error: 'internal_error' }) + } +}) + +server.listen(port, host, () => { + console.log(`Action Mode server listening on ${host}:${port}`) +}) + +function applyCors(request, response) { + const origin = request.headers.origin + if (origin && corsOrigins.has(origin)) { + response.setHeader('Access-Control-Allow-Origin', origin) + response.setHeader('Vary', 'Origin') + } + response.setHeader('Access-Control-Allow-Credentials', 'true') + response.setHeader('Access-Control-Allow-Headers', 'authorization,content-type,x-auth-issuer,x-auth-subject,x-display-name') + response.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,OPTIONS') +} + +async function handleApi(request, response, url) { + if (request.method === 'GET' && url.pathname === '/api/health') { + const db = await pool.query('select now() as now') + sendJson(response, 200, { ok: true, database: 'ok', now: db.rows[0].now }) + return + } + + if (request.method === 'GET' && url.pathname === '/api/me') { + const user = await requireUser(request, response) + if (!user) return + sendJson(response, 200, { user }) + return + } + + if (request.method === 'GET' && url.pathname.startsWith('/api/save/')) { + const user = await requireUser(request, response) + if (!user) return + const slotKey = decodeURIComponent(url.pathname.replace('/api/save/', '')) || 'default' + const save = await pool.query( + 'select slot_key, save_json, save_version, client_updated_at, updated_at from save_slots where user_id = $1 and slot_key = $2', + [user.id, slotKey], + ) + sendJson(response, 200, { save: save.rows[0] ?? null }) + return + } + + if (request.method === 'PUT' && url.pathname.startsWith('/api/save/')) { + const user = await requireUser(request, response) + if (!user) return + const slotKey = decodeURIComponent(url.pathname.replace('/api/save/', '')) || 'default' + const body = await readJson(request) + if (!body || typeof body.save !== 'object') { + sendJson(response, 400, { error: 'invalid_save_payload' }) + return + } + const saved = await pool.query( + ` + insert into save_slots (user_id, slot_key, save_json, save_version, client_updated_at, updated_at) + values ($1, $2, $3, $4, $5, now()) + on conflict (user_id, slot_key) + do update set + save_json = excluded.save_json, + save_version = excluded.save_version, + client_updated_at = excluded.client_updated_at, + updated_at = now() + returning slot_key, save_json, save_version, client_updated_at, updated_at + `, + [user.id, slotKey, body.save, Number(body.saveVersion ?? 1), body.clientUpdatedAt ?? null], + ) + sendJson(response, 200, { save: saved.rows[0] }) + return + } + + if (request.method === 'POST' && url.pathname === '/api/pvp/queue') { + const user = await requireUser(request, response) + if (!user) return + const body = await readJson(request) + const mode = String(body?.mode ?? 'action-healer') + const rating = Number(body?.rating ?? 1000) + const queued = await pool.query( + ` + insert into pvp_queue (user_id, mode, rating, status, updated_at) + values ($1, $2, $3, 'queued', now()) + on conflict (user_id, mode, status) + do update set rating = excluded.rating, updated_at = now() + returning id, mode, rating, status, queued_at, updated_at + `, + [user.id, mode, rating], + ) + sendJson(response, 200, { queue: queued.rows[0] }) + return + } + + if (request.method === 'DELETE' && url.pathname === '/api/pvp/queue') { + const user = await requireUser(request, response) + if (!user) return + const mode = url.searchParams.get('mode') ?? 'action-healer' + await pool.query('delete from pvp_queue where user_id = $1 and mode = $2 and status = $3', [user.id, mode, 'queued']) + sendJson(response, 200, { ok: true }) + return + } + + sendJson(response, 404, { error: 'not_found' }) +} + +async function requireUser(request, response) { + if (process.env.TRUST_AUTH_HEADERS !== '1') { + sendJson(response, 401, { error: 'auth_not_configured' }) + return null + } + + const authSubject = request.headers['x-auth-subject'] + if (!authSubject || Array.isArray(authSubject)) { + sendJson(response, 401, { error: 'missing_auth_subject' }) + return null + } + + const authIssuerHeader = request.headers['x-auth-issuer'] + const displayNameHeader = request.headers['x-display-name'] + const authIssuer = Array.isArray(authIssuerHeader) + ? authIssuerHeader[0] + : authIssuerHeader || process.env.AUTH_ISSUER || 'https://auth.phenomrom.com' + const displayName = Array.isArray(displayNameHeader) + ? displayNameHeader[0] + : displayNameHeader || 'Healer' + + const result = await pool.query( + ` + insert into app_users (auth_issuer, auth_subject, display_name, updated_at) + values ($1, $2, $3, now()) + on conflict (auth_issuer, auth_subject) + do update set display_name = excluded.display_name, updated_at = now() + returning id, auth_issuer, auth_subject, display_name, created_at, updated_at + `, + [authIssuer, authSubject, displayName], + ) + return result.rows[0] +} + +async function readJson(request) { + const chunks = [] + for await (const chunk of request) chunks.push(chunk) + if (chunks.length === 0) return null + return JSON.parse(Buffer.concat(chunks).toString('utf8')) +} + +function sendJson(response, status, payload) { + response.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) + response.end(JSON.stringify(payload)) +} + +async function serveStatic(response, pathname) { + const safePath = normalize(pathname).replace(/^(\.\.[/\\])+/, '') + const candidatePath = join(distDir, safePath === '/' ? 'index.html' : safePath) + const filePath = candidatePath.startsWith(distDir) && existsSync(candidatePath) && statSync(candidatePath).isFile() + ? candidatePath + : join(distDir, 'index.html') + + const contentType = mimeTypes[extname(filePath)] ?? 'application/octet-stream' + response.writeHead(200, { 'Content-Type': contentType }) + + if (filePath.endsWith('index.html')) { + response.end(await readFile(filePath)) + return + } + + createReadStream(filePath).pipe(response) +} diff --git a/src/actionBoss/BulldromeScene.ts b/src/actionBoss/BulldromeScene.ts new file mode 100644 index 0000000..0b67e45 --- /dev/null +++ b/src/actionBoss/BulldromeScene.ts @@ -0,0 +1,530 @@ +import Phaser from 'phaser' +import type { ActionDifficulty } from '../actionMode' +import { + type ActionDungeonId, + type ActionRunMode, + createBulldromeState, + getAllEnemies, + getTargetableUnits, + updateBulldromeState, + type BossInput, + type BulldromeState, + type EnemyState, + type HealTextEvent, + type NoticeTextEvent, + type TargetableState, +} from './bulldromeSimulation' + +type SceneCallbacks = { + difficulty: ActionDifficulty + dungeonId: ActionDungeonId + runMode: ActionRunMode + onStateChange: (state: BulldromeState) => void +} + +type KeyMap = { + W: Phaser.Input.Keyboard.Key + A: Phaser.Input.Keyboard.Key + S: Phaser.Input.Keyboard.Key + D: Phaser.Input.Keyboard.Key + R: Phaser.Input.Keyboard.Key + ONE: Phaser.Input.Keyboard.Key + TWO: Phaser.Input.Keyboard.Key + THREE: Phaser.Input.Keyboard.Key + FOUR: Phaser.Input.Keyboard.Key + FIVE: Phaser.Input.Keyboard.Key +} + +export class BulldromeScene extends Phaser.Scene { + private callbacks: SceneCallbacks + private state: BulldromeState + private keys: KeyMap | null = null + private arenaGraphics?: Phaser.GameObjects.Graphics + private telegraphGraphics?: Phaser.GameObjects.Graphics + private hazardGraphics?: Phaser.GameObjects.Graphics + private bossGraphics?: Phaser.GameObjects.Graphics + private partyGraphics?: Phaser.GameObjects.Graphics + private playerGraphics?: Phaser.GameObjects.Graphics + private fxGraphics?: Phaser.GameObjects.Graphics + private statusText?: Phaser.GameObjects.Text + private lastResetDown = false + private lastOneDown = false + private lastTwoDown = false + private lastThreeDown = false + private lastFourDown = false + private lastFiveDown = false + private queuedTargetId: string | null = null + private queuedSpell: 1 | 2 | 3 | 4 | 5 | null = null + private hudPublishTimer = 0 + private seenHealEventIds = new Set() + private seenNoticeEventIds = new Set() + + constructor(callbacks: SceneCallbacks) { + super('BulldromeScene') + this.callbacks = callbacks + this.state = createBulldromeState(callbacks.difficulty, callbacks.dungeonId, callbacks.runMode) + } + + create() { + this.keys = this.input.keyboard?.addKeys('W,A,S,D,R,ONE,TWO,THREE,FOUR,FIVE') as KeyMap + this.arenaGraphics = this.add.graphics() + this.telegraphGraphics = this.add.graphics() + this.hazardGraphics = this.add.graphics() + this.bossGraphics = this.add.graphics() + this.partyGraphics = this.add.graphics() + this.playerGraphics = this.add.graphics() + this.fxGraphics = this.add.graphics() + this.statusText = this.add.text(238, 58, '', { + color: '#f4eed8', + fontFamily: 'monospace', + fontSize: '16px', + }) + this.input.on('pointerdown', (pointer: Phaser.Input.Pointer) => { + this.selectTargetAt(pointer.worldX, pointer.worldY) + }) + this.cameras.main.setRoundPixels(true) + this.callbacks.onStateChange(this.state) + } + + selectTarget(targetId: string) { + this.queuedTargetId = targetId + } + + castSpell(slot: 1 | 2 | 3 | 4 | 5) { + this.queuedSpell = slot + } + + override update(_time: number, delta: number) { + const input = this.readInput() + this.state = updateBulldromeState(this.state, input, Math.min(delta / 1000, 0.05)) + if (input.reset) { + this.seenHealEventIds.clear() + this.seenNoticeEventIds.clear() + } + this.spawnHealTexts() + this.spawnNoticeTexts() + this.renderState() + + this.hudPublishTimer -= delta + if (this.hudPublishTimer <= 0 || this.state.result !== 'playing' || this.state.lastHit || this.state.noticeEvents.length > 0) { + this.callbacks.onStateChange(this.state) + this.hudPublishTimer = 80 + } + } + + private readInput(): BossInput { + const keys = this.keys + if (!keys) { + return { + xAxis: 0, + yAxis: 0, + reset: false, + targetDelta: 0, + targetId: this.consumeQueuedTarget(), + castSpell: this.consumeQueuedSpell(), + } + } + + const resetDown = keys.R.isDown + const oneDown = keys.ONE.isDown + const twoDown = keys.TWO.isDown + const threeDown = keys.THREE.isDown + const fourDown = keys.FOUR.isDown + const fiveDown = keys.FIVE.isDown + const pressedSpell = this.consumeQueuedSpell() + ?? (oneDown && !this.lastOneDown ? 1 + : twoDown && !this.lastTwoDown ? 2 + : threeDown && !this.lastThreeDown ? 3 + : fourDown && !this.lastFourDown ? 4 + : fiveDown && !this.lastFiveDown ? 5 + : null) + const input = { + xAxis: Number(keys.D.isDown) - Number(keys.A.isDown), + yAxis: Number(keys.S.isDown) - Number(keys.W.isDown), + reset: resetDown && !this.lastResetDown, + targetDelta: 0 as const, + targetId: this.consumeQueuedTarget(), + castSpell: pressedSpell, + } + this.lastResetDown = resetDown + this.lastOneDown = oneDown + this.lastTwoDown = twoDown + this.lastThreeDown = threeDown + this.lastFourDown = fourDown + this.lastFiveDown = fiveDown + return input + } + + private renderState() { + this.drawArena() + this.drawHazards() + this.drawTelegraph() + this.drawBoss() + this.drawParty() + this.drawPlayer() + this.drawFx() + this.statusText?.setText(this.state.message) + } + + private spawnHealTexts() { + for (const event of this.state.healEvents) { + if (this.seenHealEventIds.has(event.id)) continue + this.seenHealEventIds.add(event.id) + this.spawnHealText(event) + } + } + + private spawnHealText(event: HealTextEvent) { + const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId) + if (!target) return + + const text = this.add.text(target.x, target.y - target.radius - 18, `+${Math.ceil(event.amount)}`, { + color: '#7dff9d', + fontFamily: 'monospace', + fontSize: '18px', + fontStyle: 'bold', + stroke: '#07110b', + strokeThickness: 4, + }) + text.setOrigin(0.5) + this.tweens.add({ + targets: text, + y: text.y - 30, + alpha: 0, + duration: 850, + ease: 'Cubic.easeOut', + onComplete: () => text.destroy(), + }) + } + + private spawnNoticeTexts() { + for (const event of this.state.noticeEvents) { + if (this.seenNoticeEventIds.has(event.id)) continue + this.seenNoticeEventIds.add(event.id) + this.spawnNoticeText(event) + } + } + + private spawnNoticeText(event: NoticeTextEvent) { + const target = getTargetableUnits(this.state).find((unit) => unit.id === event.targetId) ?? this.state.player + const text = this.add.text(target.x, target.y - target.radius - 24, event.text, { + color: '#ffd36d', + fontFamily: 'monospace', + fontSize: '16px', + fontStyle: 'bold', + stroke: '#120b03', + strokeThickness: 4, + }) + text.setOrigin(0.5) + this.tweens.add({ + targets: text, + y: text.y - 24, + alpha: 0, + duration: 950, + ease: 'Cubic.easeOut', + onComplete: () => text.destroy(), + }) + } + + private consumeQueuedTarget() { + const targetId = this.queuedTargetId + this.queuedTargetId = null + return targetId + } + + private consumeQueuedSpell() { + const spell = this.queuedSpell + this.queuedSpell = null + return spell + } + + private selectTargetAt(x: number, y: number) { + const units = [this.state.player, ...this.state.party].filter((unit) => unit.hp > 0) + const target = units + .map((unit) => ({ unit, distance: Phaser.Math.Distance.Between(x, y, unit.x, unit.y) })) + .filter(({ unit, distance }) => distance <= unit.radius + 14) + .sort((a, b) => a.distance - b.distance)[0]?.unit + if (target) this.selectTarget(target.id) + } + + private drawArena() { + const graphics = this.arenaGraphics + if (!graphics) return + + const { width, height, padding } = this.state.arena + graphics.clear() + graphics.fillStyle(0x11151c, 1) + graphics.fillRect(0, 0, width, height) + + graphics.fillStyle(0x18202a, 1) + graphics.fillRect(padding, padding, width - padding * 2, height - padding * 2) + + graphics.lineStyle(3, 0x565066, 1) + graphics.strokeRect(padding, padding, width - padding * 2, height - padding * 2) + + graphics.lineStyle(1, 0x252b35, 0.65) + for (let x = padding + 48; x < width - padding; x += 48) { + graphics.lineBetween(x, padding, x, height - padding) + } + for (let y = padding + 48; y < height - padding; y += 48) { + graphics.lineBetween(padding, y, width - padding, y) + } + } + + private drawTelegraph() { + const graphics = this.telegraphGraphics + if (!graphics) return + + graphics.clear() + for (const enemy of getAllEnemies(this.state)) { + if (enemy.hp <= 0) continue + const telegraph = enemy.telegraph + const slam = enemy.slamTelegraph + + if (slam.active) { + graphics.fillStyle(0xdc5162, 0.18) + graphics.fillCircle(enemy.x, enemy.y, slam.radius) + graphics.lineStyle(4, 0xff8b8b, 0.9) + graphics.strokeCircle(enemy.x, enemy.y, slam.radius) + } + + if (!telegraph.active) continue + + graphics.lineStyle(telegraph.width, 0xdc5162, 0.22) + graphics.lineBetween(telegraph.start.x, telegraph.start.y, telegraph.end.x, telegraph.end.y) + graphics.lineStyle(3, 0xff8b8b, 0.82) + graphics.lineBetween(telegraph.start.x, telegraph.start.y, telegraph.end.x, telegraph.end.y) + } + } + + private drawHazards() { + const graphics = this.hazardGraphics + if (!graphics) return + + graphics.clear() + for (const spot of this.state.fireSpots) { + const alpha = Math.max(0.18, Math.min(0.48, spot.remaining / 20)) + graphics.fillStyle(0xff7a1a, alpha) + graphics.fillCircle(spot.x, spot.y, spot.radius) + graphics.lineStyle(2, 0xffd36d, 0.72) + graphics.strokeCircle(spot.x, spot.y, spot.radius) + } + + for (const fireball of this.state.fireballs) { + graphics.fillStyle(0xffd36d, 1) + graphics.fillCircle(fireball.x, fireball.y, fireball.radius + 4) + graphics.fillStyle(0xdc5162, 1) + graphics.fillCircle(fireball.x, fireball.y, fireball.radius) + graphics.lineStyle(2, 0x090a0d, 1) + graphics.strokeCircle(fireball.x, fireball.y, fireball.radius + 4) + } + } + + private drawBoss() { + const graphics = this.bossGraphics + if (!graphics) return + + graphics.clear() + for (const enemy of getAllEnemies(this.state)) { + if (enemy.hp <= 0) continue + this.drawEnemy(graphics, enemy) + } + } + + private drawEnemy(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) { + if (enemy.kind === 'yian-kut-ku') { + this.drawYianKutKu(graphics, enemy) + return + } + + if (enemy.kind === 'bird') { + this.drawBird(graphics, enemy) + return + } + + const isCharging = enemy.phase === 'charging' + const isRecovering = enemy.phase === 'recovering' + const isSlam = enemy.phase === 'slamWindup' + const isBullfango = enemy.kind === 'bullfango' + const bodyWidth = isBullfango ? enemy.radius * 2.25 : enemy.radius * 2.15 + const bodyHeight = isBullfango ? enemy.radius * 1.55 : enemy.radius * 1.65 + const hornScale = isBullfango ? 0.68 : 1 + + graphics.fillStyle(isCharging ? 0xdc5162 : isSlam ? 0xe5b95f : isRecovering ? 0x8e68c4 : 0x7d4b38, 1) + graphics.fillEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight) + graphics.fillStyle(0x3b211b, 1) + graphics.fillTriangle( + enemy.x - 20 * hornScale, + enemy.y - 18 * hornScale, + enemy.x - 44 * hornScale, + enemy.y - 34 * hornScale, + enemy.x - 28 * hornScale, + enemy.y - 5 * hornScale, + ) + graphics.fillTriangle( + enemy.x + 20 * hornScale, + enemy.y - 18 * hornScale, + enemy.x + 44 * hornScale, + enemy.y - 34 * hornScale, + enemy.x + 28 * hornScale, + enemy.y - 5 * hornScale, + ) + graphics.fillStyle(0xf4eed8, 1) + graphics.fillCircle(enemy.x - 12 * hornScale, enemy.y - 9 * hornScale, isBullfango ? 3 : 4) + graphics.fillCircle(enemy.x + 12 * hornScale, enemy.y - 9 * hornScale, isBullfango ? 3 : 4) + graphics.lineStyle(2, 0x090a0d, 1) + graphics.strokeEllipse(enemy.x, enemy.y, bodyWidth, bodyHeight) + } + + private drawYianKutKu(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) { + const isCasting = enemy.phase === 'windup' + graphics.fillStyle(isCasting ? 0xff9d3d : 0xd8772f, 1) + graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 2.05, enemy.radius * 1.85) + graphics.fillStyle(0xffd36d, 1) + graphics.fillTriangle(enemy.x, enemy.y - 8, enemy.x + 45, enemy.y - 2, enemy.x, enemy.y + 10) + graphics.fillStyle(0xb83b2f, 1) + graphics.fillTriangle(enemy.x - 18, enemy.y - 8, enemy.x - 42, enemy.y - 25, enemy.x - 30, enemy.y + 7) + graphics.fillTriangle(enemy.x + 10, enemy.y - 22, enemy.x + 22, enemy.y - 44, enemy.x + 31, enemy.y - 12) + graphics.fillStyle(0xf4eed8, 1) + graphics.fillCircle(enemy.x + 14, enemy.y - 9, 4) + graphics.lineStyle(2, 0x090a0d, 1) + graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 2.05, enemy.radius * 1.85) + } + + private drawBird(graphics: Phaser.GameObjects.Graphics, enemy: EnemyState) { + const isFlying = enemy.phase === 'windup' || enemy.phase === 'charging' || enemy.phase === 'recovering' + graphics.fillStyle(isFlying ? 0xf4eed8 : 0xb8e7ff, 1) + graphics.fillEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25) + graphics.fillStyle(0x5d91ff, 1) + graphics.fillTriangle(enemy.x - 8, enemy.y, enemy.x - 34, enemy.y - 12, enemy.x - 22, enemy.y + 10) + graphics.fillTriangle(enemy.x + 8, enemy.y, enemy.x + 34, enemy.y - 12, enemy.x + 22, enemy.y + 10) + graphics.fillStyle(0xffd36d, 1) + graphics.fillTriangle(enemy.x + 13, enemy.y - 2, enemy.x + 28, enemy.y + 2, enemy.x + 13, enemy.y + 7) + graphics.fillStyle(0x090a0d, 1) + graphics.fillCircle(enemy.x + 7, enemy.y - 5, 3) + graphics.lineStyle(2, 0x090a0d, 1) + graphics.strokeEllipse(enemy.x, enemy.y, enemy.radius * 1.7, enemy.radius * 1.25) + } + + private drawPlayer() { + const graphics = this.playerGraphics + if (!graphics) return + + const player = this.state.player + graphics.clear() + this.drawTargetRing(graphics, player) + this.drawUnitIcon(graphics, player, player.stunTimer > 0 ? 0xe5b95f : 0x30ff7a) + + if (player.shield > 0) { + graphics.lineStyle(2, 0xb8e7ff, 0.85) + graphics.strokeCircle(player.x, player.y, player.radius + 9) + } + + if (player.stunTimer > 0) { + graphics.fillStyle(0xe5b95f, 1) + graphics.fillCircle(player.x - 10, player.y - 24, 3) + graphics.fillCircle(player.x + 2, player.y - 29, 3) + graphics.fillCircle(player.x + 13, player.y - 23, 3) + } + } + + private drawParty() { + const graphics = this.partyGraphics + if (!graphics) return + + graphics.clear() + for (const member of this.state.party) { + this.drawTargetRing(graphics, member) + this.drawUnitIcon(graphics, member, this.getUnitColor(member), member.hp > 0 ? 1 : 0.38) + + if (member.renewTimer > 0) { + graphics.lineStyle(2, 0x3f9a66, 0.9) + graphics.strokeCircle(member.x, member.y, member.radius + 5) + } + + if (member.shield > 0) { + graphics.lineStyle(2, 0xb8e7ff, 0.85) + graphics.strokeCircle(member.x, member.y, member.radius + 9) + } + + if (member.stunTimer > 0) { + graphics.fillStyle(0xe5b95f, 1) + graphics.fillCircle(member.x - 8, member.y - 22, 3) + graphics.fillCircle(member.x + 6, member.y - 24, 3) + } + } + } + + private drawTargetRing(graphics: Phaser.GameObjects.Graphics, unit: TargetableState) { + if (this.state.targetId !== unit.id) return + graphics.lineStyle(3, 0xe5b95f, 1) + graphics.strokeCircle(unit.x, unit.y, unit.radius + 9) + } + + private drawUnitIcon( + graphics: Phaser.GameObjects.Graphics, + unit: TargetableState, + color: number, + alpha = 1, + ) { + graphics.fillStyle(color, alpha) + graphics.fillCircle(unit.x, unit.y, unit.radius) + graphics.lineStyle(2, unit.invulnerableTimer > 0 ? 0xffffff : 0x090a0d, alpha) + graphics.strokeCircle(unit.x, unit.y, unit.radius) + + if (unit.role === 'healer') { + graphics.lineStyle(3, 0x082815, alpha) + graphics.lineBetween(unit.x - 7, unit.y, unit.x + 7, unit.y) + graphics.lineBetween(unit.x, unit.y - 7, unit.x, unit.y + 7) + return + } + + if (unit.role === 'tank') { + graphics.fillStyle(0x3b2d12, alpha) + graphics.fillTriangle(unit.x, unit.y - 10, unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4) + graphics.fillTriangle(unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4, unit.x, unit.y + 10) + graphics.lineStyle(2, 0xfff0a8, alpha) + graphics.strokeTriangle(unit.x, unit.y - 10, unit.x - 9, unit.y - 4, unit.x + 9, unit.y - 4) + graphics.lineBetween(unit.x - 9, unit.y - 4, unit.x, unit.y + 10) + graphics.lineBetween(unit.x + 9, unit.y - 4, unit.x, unit.y + 10) + return + } + + if (unit.role === 'melee') { + graphics.lineStyle(3, 0x16090b, alpha) + graphics.lineBetween(unit.x - 7, unit.y + 7, unit.x + 7, unit.y - 7) + graphics.lineBetween(unit.x - 4, unit.y - 7, unit.x + 7, unit.y + 4) + return + } + + graphics.lineStyle(3, 0x10091c, alpha) + graphics.strokeCircle(unit.x, unit.y, 7) + graphics.lineBetween(unit.x - 9, unit.y, unit.x + 9, unit.y) + } + + private getUnitColor(unit: TargetableState) { + if (unit.role === 'tank') return 0xe5b95f + if (unit.id === 'melee-1') return 0xdc5162 + if (unit.id === 'melee-2') return 0xf08a4b + if (unit.id === 'ranged-1') return 0x8e68c4 + if (unit.id === 'ranged-2') return 0x5d91ff + return 0x30ff7a + } + + private drawFx() { + const graphics = this.fxGraphics + if (!graphics) return + + graphics.clear() + if (this.state.lastHit === 'boss') { + graphics.lineStyle(4, 0xb8e7ff, 0.85) + for (const enemy of getAllEnemies(this.state)) { + if (enemy.hp > 0) graphics.strokeCircle(enemy.x, enemy.y, enemy.radius + 12) + } + } + if (this.state.lastHit === 'player') { + graphics.lineStyle(5, 0xdc5162, 0.8) + graphics.strokeCircle(this.state.player.x, this.state.player.y, this.state.player.radius + 16) + } + } +} diff --git a/src/actionBoss/actionEncounterConfig.ts b/src/actionBoss/actionEncounterConfig.ts new file mode 100644 index 0000000..0401b20 --- /dev/null +++ b/src/actionBoss/actionEncounterConfig.ts @@ -0,0 +1,175 @@ +import type { EnemyKind } from './bulldromeSimulation' + +export type ActionAttackKind = + | 'tankMelee' + | 'charge' + | 'groundSlam' + | 'fireballVolley' + | 'birdDive' + | 'bodyContact' + +export type ActionAttackConfig = { + id: string + label: string + kind: ActionAttackKind + enabled: boolean + frequencySeconds: number + damage: number + windupSeconds?: number + recoverSeconds?: number + speed?: number + radius?: number + everyNthCharge?: number +} + +export type ActionEnemyMechanicConfig = { + kind: EnemyKind + label: string + role: 'boss' | 'mob' + attacks: ActionAttackConfig[] +} + +export type ActionMechanicConfig = Record + +export const ACTION_MECHANIC_CONFIG_KEY = 'i-want-to-heal:action-mode-mechanics:v1' + +export const DEFAULT_ACTION_MECHANIC_CONFIG: ActionMechanicConfig = { + bulldrome: { + kind: 'bulldrome', + label: 'Bulldrome', + role: 'boss', + attacks: [ + createAttack('bulldrome-tank-melee', 'Tank Melee', 'tankMelee', 0.9, 13), + createAttack('bulldrome-charge', 'Charge', 'charge', 2.8, 24, { + windupSeconds: 0.82, + recoverSeconds: 0.86, + speed: 650, + }), + createAttack('bulldrome-ground-slam', 'Ground Slam', 'groundSlam', 3, 28, { + windupSeconds: 1.25, + radius: 142, + everyNthCharge: 3, + }), + createAttack('bulldrome-body-contact', 'Body Contact', 'bodyContact', 0, 10), + ], + }, + bullfango: { + kind: 'bullfango', + label: 'Bullfango', + role: 'mob', + attacks: [ + createAttack('bullfango-tank-melee', 'Tank Melee', 'tankMelee', 1.35, 6), + createAttack('bullfango-charge', 'Charge', 'charge', 1.6, 13, { + windupSeconds: 0.68, + recoverSeconds: 1.05, + speed: 510, + }), + createAttack('bullfango-body-contact', 'Body Contact', 'bodyContact', 0, 4), + ], + }, + 'yian-kut-ku': { + kind: 'yian-kut-ku', + label: 'Yian Kut-Ku', + role: 'boss', + attacks: [ + createAttack('yian-tank-melee', 'Tank Peck', 'tankMelee', 1, 11), + createAttack('yian-fireballs', 'Fireball Volley', 'fireballVolley', 2.4, 16, { + windupSeconds: 1, + recoverSeconds: 1.1, + speed: 275, + }), + createAttack('yian-body-contact', 'Body Contact', 'bodyContact', 0, 8), + ], + }, + bird: { + kind: 'bird', + label: 'Bird', + role: 'mob', + attacks: [ + createAttack('bird-tank-melee', 'Tank Claw', 'tankMelee', 1.15, 5), + createAttack('bird-dive', 'Dive Flight', 'birdDive', 3.2, 12, { + speed: 340, + }), + createAttack('bird-body-contact', 'Body Contact', 'bodyContact', 0, 4), + ], + }, +} + +export function loadActionMechanicConfig(): ActionMechanicConfig { + if (typeof window === 'undefined') return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG) + + const saved = window.localStorage.getItem(ACTION_MECHANIC_CONFIG_KEY) + if (!saved) return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG) + + try { + return mergeMechanicConfig(JSON.parse(saved) as Partial) + } catch { + return cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG) + } +} + +export function saveActionMechanicConfig(config: ActionMechanicConfig) { + if (typeof window === 'undefined') return + window.localStorage.setItem(ACTION_MECHANIC_CONFIG_KEY, JSON.stringify(config)) +} + +export function resetActionMechanicConfig() { + const config = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG) + saveActionMechanicConfig(config) + return config +} + +export function getActionEnemyMechanic(kind: EnemyKind) { + return loadActionMechanicConfig()[kind] +} + +export function getActionAttack(kind: EnemyKind, attackKind: ActionAttackKind) { + return getActionEnemyMechanic(kind).attacks.find((attack) => attack.kind === attackKind) +} + +export function getEnabledActionAttack(kind: EnemyKind, attackKind: ActionAttackKind) { + const attack = getActionAttack(kind, attackKind) + return attack?.enabled ? attack : null +} + +function createAttack( + id: string, + label: string, + kind: ActionAttackKind, + frequencySeconds: number, + damage: number, + options: Partial> = {}, +): ActionAttackConfig { + return { + id, + label, + kind, + enabled: true, + frequencySeconds, + damage, + ...options, + } +} + +function mergeMechanicConfig(saved: Partial) { + const merged = cloneConfig(DEFAULT_ACTION_MECHANIC_CONFIG) + for (const kind of Object.keys(merged) as EnemyKind[]) { + const savedEnemy = saved[kind] + if (!savedEnemy) continue + const savedAttacks = Array.isArray(savedEnemy.attacks) ? savedEnemy.attacks : [] + merged[kind] = { + ...merged[kind], + ...savedEnemy, + kind, + attacks: merged[kind].attacks.map((attack) => ({ + ...attack, + ...savedAttacks.find((candidate) => candidate.id === attack.id), + })), + } + } + return merged +} + +function cloneConfig(config: ActionMechanicConfig): ActionMechanicConfig { + return structuredClone(config) +} diff --git a/src/actionBoss/bulldromeSimulation.ts b/src/actionBoss/bulldromeSimulation.ts new file mode 100644 index 0000000..bac6e4a --- /dev/null +++ b/src/actionBoss/bulldromeSimulation.ts @@ -0,0 +1,1652 @@ +import { getActionAttack, getEnabledActionAttack, type ActionAttackKind } from './actionEncounterConfig' +import { getActionDifficultyTier, type ActionDifficulty } from '../actionMode' + +export type ActionRunMode = 'hunt' | 'marathon' +export type BossPhase = 'tracking' | 'windup' | 'charging' | 'recovering' | 'mauling' | 'slamWindup' | 'defeated' | 'victory' +export type ActionDungeonId = 'bulldrome' | 'yian-kut-ku' +export type PartyRole = 'healer' | 'tank' | 'melee' | 'ranged' +export type SpellSlot = 1 | 2 | 3 | 4 | 5 +export type EnemyKind = 'bulldrome' | 'bullfango' | 'yian-kut-ku' | 'bird' +export type EncounterStep = 'trash' | 'boss' +type BirdFlightStep = 'ground' | 'toLeft' | 'across' | 'toTank' + +export type FighterState = { + x: number + y: number + radius: number + hp: number + maxHp: number +} + +export type TargetableState = FighterState & { + id: string + name: string + role: PartyRole + shield: number + stunTimer: number + invulnerableTimer: number + renewTimer: number + renewTickTimer: number + burnTimer: number + burnTickTimer: number +} + +export type CastState = { + spell: SpellSlot + targetId: string + remaining: number + total: number +} + +export type PartyMemberState = TargetableState & { + aiSlot: number +} + +export type Point = { + x: number + y: number +} + +export type Telegraph = { + active: boolean + start: Point + end: Point + width: number +} + +export type EnemyState = FighterState & { + id: string + name: string + kind: EnemyKind + phase: BossPhase + phaseTimer: number + chargeVector: Point + targetId: string + chargeCount: number + tankSwingTimer: number + flightStep: BirdFlightStep + flightY: number + telegraph: Telegraph + slamTelegraph: { + active: boolean + radius: number + } +} + +export type BulldromeState = { + dungeonId: ActionDungeonId + difficulty: ActionDifficulty + runMode: ActionRunMode + encounterStep: EncounterStep + arena: { + width: number + height: number + padding: number + } + player: TargetableState & { + currentCast: CastState | null + spellCooldowns: Record + } + party: PartyMemberState[] + targetId: string + boss: EnemyState + adds: EnemyState[] + fireballs: FireballState[] + fireSpots: FireSpotState[] + nextHazardId: number + bossKills: number + respawnTimer: number + healEvents: HealTextEvent[] + noticeEvents: NoticeTextEvent[] + elapsed: number + message: string + result: 'playing' | 'win' | 'loss' + lastHit: 'player' | 'party' | 'boss' | 'heal' | null +} + +export type FireballState = { + id: string + x: number + y: number + radius: number + vx: number + vy: number + bouncesRemaining: number +} + +export type FireSpotState = { + id: string + x: number + y: number + radius: number + remaining: number +} + +export type BossInput = { + xAxis: number + yAxis: number + reset: boolean + targetDelta: -1 | 0 | 1 + targetId: string | null + castSpell: SpellSlot | null +} + +export type RaidFrame = { + id: string + name: string + role: PartyRole + hp: number + maxHp: number + renewTimer: number + selected: boolean + shield: number +} + +export type EnemyFrame = { + id: string + name: string + kind: EnemyKind + hp: number + maxHp: number + phase: BossPhase +} + +export type HealTextEvent = { + id: string + targetId: string + amount: number +} + +export type NoticeTextEvent = { + id: string + targetId: string + text: string +} + +export type SpellDefinition = { + slot: SpellSlot + name: string + cooldown: number + castTime: number +} + +export const SPELLS: Record = { + 1: { slot: 1, name: 'Mend', cooldown: 0.5, castTime: 0.75 }, + 2: { slot: 2, name: 'Renew', cooldown: 0.5, castTime: 0 }, + 3: { slot: 3, name: 'Radiance', cooldown: 8, castTime: 0.75 }, + 4: { slot: 4, name: 'Sun Ward', cooldown: 7, castTime: 0 }, + 5: { slot: 5, name: 'Purify', cooldown: 5, castTime: 0 }, +} + +const PLAYER_SPEED = 245 +const PLAYER_STUN_SECONDS = 0.92 +const PLAYER_INVULNERABLE_SECONDS = 0.7 +const MEND_HEAL = 34 +const RENEW_SECONDS = 8 +const RENEW_TICK_SECONDS = 1 +const RENEW_HEAL = 7 +const RADIANCE_HEAL = 22 +const SUN_WARD_SHIELD = 36 +const PURIFY_HEAL = 14 +const ARENA_SQUARE_SIZE = 48 +const HEAL_RANGE = ARENA_SQUARE_SIZE * 6 +const RANGED_ATTACK_RANGE = ARENA_SQUARE_SIZE * 6 +const BOSS_TRACK_SECONDS = 1.15 +const BOSS_WINDUP_SECONDS = 0.82 +const BOSS_CHARGE_SECONDS = 0.74 +const BOSS_RECOVER_SECONDS = 0.86 +const BOSS_TANK_FOCUS_SECONDS = 2.8 +const BOSS_TANK_SWING_SECONDS = 0.9 +const BOSS_TANK_SWING_DAMAGE = 13 +const BOSS_SLAM_CAST_SECONDS = 1.25 +const BOSS_SLAM_RADIUS = 142 +const BOSS_CHARGE_SPEED = 650 +const BOSS_BODY_DAMAGE = 10 +const BOSS_CHARGE_DAMAGE = 24 +const BULLFANGO_TRACK_SECONDS = 2.35 +const BULLFANGO_WINDUP_SECONDS = 0.68 +const BULLFANGO_CHARGE_SECONDS = 0.58 +const BULLFANGO_RECOVER_SECONDS = 1.05 +const BULLFANGO_TANK_FOCUS_SECONDS = 1.6 +const BULLFANGO_TANK_SWING_SECONDS = 1.35 +const BULLFANGO_TANK_SWING_DAMAGE = 6 +const BULLFANGO_CHARGE_SPEED = 510 +const BULLFANGO_BODY_DAMAGE = 4 +const BULLFANGO_CHARGE_DAMAGE = 13 +const YIAN_TRACK_SECONDS = 2.1 +const YIAN_FIREBALL_WINDUP_SECONDS = 1 +const YIAN_RECOVER_SECONDS = 1.1 +const YIAN_TANK_FOCUS_SECONDS = 2.4 +const YIAN_TANK_SWING_SECONDS = 1 +const YIAN_TANK_SWING_DAMAGE = 11 +const YIAN_BODY_DAMAGE = 8 +const FIREBALL_SPEED = 275 +const FIREBALL_DAMAGE = 16 +const FIRE_DOT_SECONDS = 5 +const FIRE_DOT_TICK_SECONDS = 1 +const FIRE_DOT_DAMAGE = 5 +const FIRE_SPOT_SECONDS = 20 +const BIRD_FLIGHT_TIMER_SECONDS = 3.2 +const BIRD_FLIGHT_SPEED = 340 +const BIRD_TANK_SWING_SECONDS = 1.15 +const BIRD_TANK_SWING_DAMAGE = 5 +const BIRD_BODY_DAMAGE = 4 +const BIRD_CHARGE_DAMAGE = 12 +const AI_MOVE_SPEED = 132 +const TANK_DPS = 4 +const MELEE_DPS = 10.5 +const RANGED_DPS = 8.75 + +export function createBulldromeState( + difficulty: ActionDifficulty = 'ilvl-1', + dungeonId: ActionDungeonId = 'bulldrome', + runMode: ActionRunMode = 'hunt', +): BulldromeState { + const state: BulldromeState = { + dungeonId, + difficulty, + runMode, + encounterStep: dungeonId === 'bulldrome' || dungeonId === 'yian-kut-ku' ? 'trash' : 'boss', + arena: { + width: 960, + height: 540, + padding: 34, + }, + player: { + id: 'player', + name: 'You', + role: 'healer', + x: 480, + y: 404, + radius: 15, + hp: 100, + maxHp: 100, + shield: 0, + stunTimer: 0, + invulnerableTimer: 0, + renewTimer: 0, + renewTickTimer: 0, + burnTimer: 0, + burnTickTimer: 0, + currentCast: null, + spellCooldowns: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + }, + party: [ + createPartyMember('tank', 'Brakka', 'tank', 120, 480, 232, 0), + createPartyMember('melee-1', 'Kael', 'melee', 82, 480, 274, 1), + createPartyMember('ranged-1', 'Sera', 'ranged', 74, 360, 376, 3), + createPartyMember('ranged-2', 'Nix', 'ranged', 72, 600, 376, 4), + ], + targetId: 'tank', + boss: dungeonId === 'yian-kut-ku' + ? createBird('bird-1', 'Bird', 368, 148, 0, 150) + : createBullfango('bullfango-1', 'Bullfango A', 368, 148, 0), + adds: dungeonId === 'yian-kut-ku' + ? [ + createBird('bird-2', 'Bird', 480, 126, 0, 234), + createBird('bird-3', 'Bird', 592, 148, 0, 318), + ] + : [ + createBullfango('bullfango-2', 'Bullfango B', 480, 126, 0.55), + createBullfango('bullfango-3', 'Bullfango C', 592, 148, 1.05), + ], + fireballs: [], + fireSpots: [], + nextHazardId: 0, + bossKills: 0, + respawnTimer: 0, + healEvents: [], + noticeEvents: [], + elapsed: 0, + message: dungeonId === 'yian-kut-ku' + ? 'Bird flock guards the roost.' + : 'Bullfango pack blocks the hunting grounds.', + result: 'playing', + lastHit: null, + } + + if (dungeonId === 'yian-kut-ku') { + state.adds = [ + createBird('bird-2', 'Bird', 480, 126, 0, 234), + createBird('bird-3', 'Bird', 592, 148, 0, 318), + ] + } else if (isHighActionTier(difficulty)) { + state.boss = createBulldrome() + state.adds = [ + createBullfango('hard-bullfango-1', 'Bullfango A', 342, 188, 0.55), + createBullfango('hard-bullfango-2', 'Bullfango B', 618, 188, 1.1), + ] + state.message = 'Hard hunt. Bulldrome brought two Bullfangos.' + } + + applyDifficultyScaling(state) + + return state +} + +export function updateBulldromeState( + state: BulldromeState, + input: BossInput, + deltaSeconds: number, +): BulldromeState { + if (input.reset) return createBulldromeState(state.difficulty, state.dungeonId, state.runMode) + + const next = cloneState(state) + next.elapsed += deltaSeconds + next.lastHit = null + next.healEvents = [] + next.noticeEvents = [] + + if (input.targetId) setTarget(next, input.targetId) + if (input.targetDelta !== 0) cycleTarget(next, input.targetDelta) + + if (next.result !== 'playing') return next + + tickTargetable(next.player, deltaSeconds) + tickSpellCooldowns(next.player.spellCooldowns, deltaSeconds) + tickCurrentCast(next, deltaSeconds) + for (const member of next.party) tickTargetable(member, deltaSeconds) + + movePlayer(next, input, deltaSeconds) + + if (next.respawnTimer > 0) { + next.respawnTimer = Math.max(0, next.respawnTimer - deltaSeconds) + if (next.respawnTimer <= 0) respawnBossEncounter(next) + else next.message = `Marathon break. Next boss in ${Math.ceil(next.respawnTimer)}.` + startPlayerSpell(next, input.castSpell) + resolveResult(next) + return next + } + + updatePartyAi(next, deltaSeconds) + resolvePartyDamage(next, deltaSeconds) + updateEnemies(next, deltaSeconds) + updateFireHazards(next, deltaSeconds) + resolveContact(next) + startPlayerSpell(next, input.castSpell) + resolveResult(next) + + return next +} + +export function getRaidFrames(state: BulldromeState): RaidFrame[] { + return [state.player, ...state.party].map((unit) => ({ + id: unit.id, + name: unit.name, + role: unit.role, + hp: unit.hp, + maxHp: unit.maxHp, + renewTimer: unit.renewTimer, + shield: unit.shield, + selected: unit.id === state.targetId, + })) +} + +export function getEnemyFrames(state: BulldromeState): EnemyFrame[] { + return getLivingEnemies(state).map((enemy) => ({ + id: enemy.id, + name: enemy.name, + kind: enemy.kind, + hp: enemy.hp, + maxHp: enemy.maxHp, + phase: enemy.phase, + })) +} + +export function getEncounterTitle(state: BulldromeState) { + if (state.dungeonId === 'yian-kut-ku' && state.encounterStep === 'trash') return 'Bird Flock' + if (state.dungeonId === 'yian-kut-ku') return 'Yian Kut-Ku + Birds' + if (state.encounterStep === 'trash') return 'Bullfango Pack' + if (isHighActionTier(state.difficulty)) return 'Bulldrome + Bullfangos' + return 'Bulldrome' +} + +export function getEncounterHp(state: BulldromeState) { + const enemies = getAllEnemies(state) + const hp = enemies.reduce((sum, enemy) => sum + Math.max(0, enemy.hp), 0) + const maxHp = enemies.reduce((sum, enemy) => sum + enemy.maxHp, 0) + return { hp, maxHp } +} + +export function getTargetableUnits(state: BulldromeState): TargetableState[] { + return [state.player, ...state.party] +} + +export function getAllEnemies(state: BulldromeState): EnemyState[] { + return [state.boss, ...state.adds] +} + +export function getLivingEnemies(state: BulldromeState): EnemyState[] { + return getAllEnemies(state).filter((enemy) => enemy.hp > 0) +} + +export function distance(a: Point, b: Point) { + return Math.hypot(a.x - b.x, a.y - b.y) +} + +function createPartyMember( + id: string, + name: string, + role: Exclude, + maxHp: number, + x: number, + y: number, + aiSlot: number, +): PartyMemberState { + return { + id, + name, + role, + x, + y, + radius: role === 'tank' ? 17 : 14, + hp: maxHp, + maxHp, + shield: 0, + stunTimer: 0, + invulnerableTimer: 0, + renewTimer: 0, + renewTickTimer: 0, + burnTimer: 0, + burnTickTimer: 0, + aiSlot, + } +} + +function createBulldrome(): EnemyState { + return createEnemy('bulldrome', 'Bulldrome', 'bulldrome', 520, 34, 480, 154, 0) +} + +function createYianKutKu(): EnemyState { + return createEnemy('yian-kut-ku', 'Yian Kut-Ku', 'yian-kut-ku', 620, 32, 480, 154, 0) +} + +function createBullfango(id: string, name: string, x: number, y: number, timerOffset: number): EnemyState { + return createEnemy(id, name, 'bullfango', 125, 22, x, y, timerOffset) +} + +function createBird(id: string, name: string, x: number, y: number, timerOffset: number, flightY: number): EnemyState { + const bird = createEnemy(id, name, 'bird', 112, 18, x, y, timerOffset) + bird.phase = 'mauling' + bird.phaseTimer = BIRD_FLIGHT_TIMER_SECONDS + bird.flightY = flightY + return bird +} + +function createEnemy( + id: string, + name: string, + kind: EnemyKind, + maxHp: number, + radius: number, + x: number, + y: number, + timerOffset: number, +): EnemyState { + const trackSeconds = getTrackSeconds(kind) + + return { + id, + name, + kind, + x, + y, + radius, + hp: maxHp, + maxHp, + phase: 'tracking', + phaseTimer: trackSeconds + timerOffset, + chargeVector: { x: 0, y: 1 }, + targetId: 'tank', + chargeCount: 0, + tankSwingTimer: getTankSwingSeconds(kind), + flightStep: 'ground', + flightY: y, + telegraph: { + active: false, + start: { x, y }, + end: { x, y: 470 }, + width: kind === 'bulldrome' ? 86 : 48, + }, + slamTelegraph: { + active: false, + radius: BOSS_SLAM_RADIUS, + }, + } +} + +function tickSpellCooldowns(cooldowns: Record, deltaSeconds: number) { + for (const slot of [1, 2, 3, 4, 5] as const) { + cooldowns[slot] = Math.max(0, cooldowns[slot] - deltaSeconds) + } +} + +function tickCurrentCast(state: BulldromeState, deltaSeconds: number) { + const cast = state.player.currentCast + if (!cast) return + + cast.remaining = Math.max(0, cast.remaining - deltaSeconds) + state.message = `Casting ${SPELLS[cast.spell].name} on ${findTarget(state, cast.targetId)?.name ?? 'target'}.` + if (cast.remaining > 0) return + + const completed = { ...cast } + state.player.currentCast = null + completePlayerSpell(state, completed.spell, completed.targetId) +} + +function tickTargetable(unit: TargetableState, deltaSeconds: number) { + unit.stunTimer = Math.max(0, unit.stunTimer - deltaSeconds) + unit.invulnerableTimer = Math.max(0, unit.invulnerableTimer - deltaSeconds) + if (unit.burnTimer > 0) { + unit.burnTimer = Math.max(0, unit.burnTimer - deltaSeconds) + unit.burnTickTimer -= deltaSeconds + if (unit.burnTickTimer <= 0) { + applyDamage(unit, FIRE_DOT_DAMAGE) + unit.burnTickTimer += FIRE_DOT_TICK_SECONDS + } + } + if (unit.renewTimer <= 0) return + + unit.renewTimer = Math.max(0, unit.renewTimer - deltaSeconds) + unit.renewTickTimer -= deltaSeconds + if (unit.renewTickTimer <= 0) { + unit.hp = Math.min(unit.maxHp, unit.hp + RENEW_HEAL) + unit.renewTickTimer += RENEW_TICK_SECONDS + } +} + +function movePlayer(state: BulldromeState, input: BossInput, deltaSeconds: number) { + if (state.player.stunTimer > 0) { + state.message = 'Stunned. Heal after the stars clear.' + return + } + + const length = Math.hypot(input.xAxis, input.yAxis) + if (length <= 0) return + + const speed = PLAYER_SPEED * deltaSeconds + state.player.x += (input.xAxis / length) * speed + state.player.y += (input.yAxis / length) * speed + clampToArena(state, state.player) +} + +function updatePartyAi(state: BulldromeState, deltaSeconds: number) { + for (const member of state.party) { + if (member.hp <= 0 || member.stunTimer > 0) continue + const desired = getAiDesiredPoint(state, member) + moveToward(state, member, desired, AI_MOVE_SPEED * deltaSeconds) + } +} + +function getAiDesiredPoint(state: BulldromeState, member: PartyMemberState): Point { + const target = getAiTargetForMember(state, member) + const dodgePoint = getTelegraphDodgePoint(state, member) + if (dodgePoint) return dodgePoint + + if (!target) return { x: member.x, y: member.y } + + if (member.role === 'ranged') { + const enemyDistance = distance(member, target) + if (enemyDistance > RANGED_ATTACK_RANGE - 18) { + const dx = member.x - target.x + const dy = member.y - target.y + const length = Math.max(1, Math.hypot(dx, dy)) + return clampPointToArena(state, { + x: target.x + (dx / length) * (RANGED_ATTACK_RANGE - 36), + y: target.y + (dy / length) * (RANGED_ATTACK_RANGE - 36), + }, member.radius) + } + + const home = member.aiSlot === 3 + ? { x: 320, y: 376 } + : { x: 640, y: 376 } + return distance(home, target) <= RANGED_ATTACK_RANGE ? home : { x: member.x, y: member.y } + } + + const bossDistance = distance(member, target) + if (member.role === 'tank') { + if (bossDistance >= 46 && bossDistance <= 86) return { x: member.x, y: member.y } + return offsetFromEnemy(state, target, Math.PI / 2, target.radius + 28) + } + + if (bossDistance >= 54 && bossDistance <= 98) return { x: member.x, y: member.y } + const side = member.aiSlot === 1 ? 2.45 : 0.7 + return offsetFromEnemy(state, target, side, target.radius + 42) +} + +function offsetFromEnemy(state: BulldromeState, enemy: FighterState, angle: number, range: number): Point { + const point = { + x: enemy.x + Math.cos(angle) * range, + y: enemy.y + Math.sin(angle) * range, + } + return clampPointToArena(state, point, 14) +} + +function moveToward(state: BulldromeState, unit: TargetableState, point: Point, maxDistance: number) { + const dx = point.x - unit.x + const dy = point.y - unit.y + const length = Math.hypot(dx, dy) + if (length <= 2) return + const distanceThisFrame = Math.min(length, maxDistance) + unit.x += (dx / length) * distanceThisFrame + unit.y += (dy / length) * distanceThisFrame + clampToArena(state, unit) +} + +function updateEnemies(state: BulldromeState, deltaSeconds: number) { + for (const enemy of getLivingEnemies(state)) { + updateEnemy(state, enemy, deltaSeconds) + } +} + +function updateEnemy(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { + enemy.phaseTimer -= deltaSeconds + + if (enemy.kind === 'yian-kut-ku') { + updateYianKutKu(state, enemy, deltaSeconds) + return + } + + if (enemy.kind === 'bird') { + updateBird(state, enemy, deltaSeconds) + return + } + + if (enemy.phase === 'tracking') { + enemy.targetId = getTankTargetId(state, enemy) + const target = findTarget(state, enemy.targetId) ?? state.player + driftTowardTarget(state, enemy, target, deltaSeconds) + enemy.telegraph.active = false + state.message = `${enemy.name} tracks ${target.name}.` + if (enemy.phaseTimer <= 0) { + if (getEnabledActionAttack(enemy.kind, 'charge')) { + enemy.targetId = selectChargeTarget(state, enemy) + startWindup(state, enemy) + } else { + startTankFocus(enemy) + } + } + return + } + + if (enemy.phase === 'mauling') { + enemy.telegraph.active = false + enemy.slamTelegraph.active = false + enemy.targetId = 'tank' + const tank = findTarget(state, 'tank') ?? state.player + driftTowardTarget(state, enemy, tank, deltaSeconds) + enemy.tankSwingTimer -= deltaSeconds + state.message = `${enemy.name} attacks ${tank.name}.` + if (enemy.tankSwingTimer <= 0) { + applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) + tank.invulnerableTimer = 0.12 + state.lastHit = tank.id === 'player' ? 'player' : 'party' + state.message = `${enemy.name} hit ${tank.name}.` + enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) + } + if (enemy.phaseTimer <= 0) { + const slam = getEnabledActionAttack(enemy.kind, 'groundSlam') + const charge = getEnabledActionAttack(enemy.kind, 'charge') + if ( + slam + && enemy.chargeCount > 0 + && enemy.chargeCount % Math.max(1, Math.round(slam.everyNthCharge ?? 3)) === 0 + ) { + startSlam(enemy) + } else if (charge) { + enemy.targetId = selectChargeTarget(state, enemy) + startWindup(state, enemy) + } else { + enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + } + } + return + } + + if (enemy.phase === 'windup') { + enemy.telegraph.active = true + const target = findTarget(state, enemy.targetId) + state.message = `${enemy.name} charges ${target?.name ?? 'the party'}.` + if (enemy.phaseTimer <= 0) { + enemy.phase = 'charging' + enemy.phaseTimer = getChargeSeconds(enemy.kind) + enemy.telegraph.active = false + state.message = `${enemy.name} charges.` + } + return + } + + if (enemy.phase === 'charging') { + enemy.telegraph.active = true + enemy.x += enemy.chargeVector.x * getChargeSpeed(enemy.kind) * deltaSeconds + enemy.y += enemy.chargeVector.y * getChargeSpeed(enemy.kind) * deltaSeconds + clampToArena(state, enemy) + if (enemy.phaseTimer <= 0 || hitArenaWall(state, enemy)) { + enemy.phase = 'recovering' + enemy.phaseTimer = getRecoverSeconds(enemy.kind) + state.message = `${enemy.name} is recovering.` + } + return + } + + if (enemy.phase === 'slamWindup') { + enemy.telegraph.active = false + enemy.slamTelegraph.active = true + state.message = 'Ground slam. Get away from Bulldrome.' + if (enemy.phaseTimer <= 0) { + resolveGroundSlam(state, enemy) + enemy.slamTelegraph.active = false + enemy.targetId = selectChargeTarget(state, enemy) + startWindup(state, enemy) + } + return + } + + if (enemy.phase === 'recovering') { + enemy.telegraph.active = false + enemy.slamTelegraph.active = false + state.message = `${enemy.name} is recovering.` + if (enemy.phaseTimer <= 0) { + startTankFocus(enemy) + } + } +} + +function updateYianKutKu(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { + const fireballAttack = getEnabledActionAttack(enemy.kind, 'fireballVolley') + + if (enemy.phase === 'tracking') { + enemy.targetId = getTankTargetId(state, enemy) + const tank = findTarget(state, enemy.targetId) ?? state.player + driftTowardTarget(state, enemy, tank, deltaSeconds) + state.message = 'Yian Kut-Ku watches the party.' + if (enemy.phaseTimer <= 0) { + if (!fireballAttack) { + startTankFocus(enemy) + return + } + state.fireballs = [] + enemy.phase = 'windup' + enemy.phaseTimer = fireballAttack.windupSeconds ?? YIAN_FIREBALL_WINDUP_SECONDS + state.message = 'Yian Kut-Ku inhales. Fireballs incoming.' + } + return + } + + if (enemy.phase === 'windup') { + state.message = 'Fireballs: northeast, northwest, south.' + if (enemy.phaseTimer <= 0) { + spawnYianFireballs(state, enemy) + enemy.phase = 'recovering' + enemy.phaseTimer = getRecoverSeconds(enemy.kind) + state.message = 'Yian Kut-Ku spews bouncing fireballs.' + } + return + } + + if (enemy.phase === 'mauling') { + enemy.targetId = 'tank' + const tank = findTarget(state, 'tank') ?? state.player + driftTowardTarget(state, enemy, tank, deltaSeconds) + enemy.tankSwingTimer -= deltaSeconds + state.message = `Yian Kut-Ku pecks ${tank.name}.` + if (enemy.tankSwingTimer <= 0) { + applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) + tank.invulnerableTimer = 0.12 + state.lastHit = tank.id === 'player' ? 'player' : 'party' + enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) + } + if (enemy.phaseTimer <= 0) { + enemy.phase = 'tracking' + enemy.phaseTimer = getTrackSeconds(enemy.kind) + } + return + } + + if (enemy.phase === 'recovering') { + state.message = 'Yian Kut-Ku recovers from the fireball spray.' + if (enemy.phaseTimer <= 0) { + enemy.phase = 'mauling' + enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + enemy.tankSwingTimer = 0.35 + } + } +} + +function spawnYianFireballs(state: BulldromeState, enemy: EnemyState) { + const fireballAttack = getEnabledActionAttack(enemy.kind, 'fireballVolley') + if (!fireballAttack) return + + const directions = [ + { x: 0.72, y: -0.7 }, + { x: -0.72, y: -0.7 }, + { x: 0, y: 1 }, + ] + for (const direction of directions) { + state.fireballs.push({ + id: `fireball-${state.nextHazardId}`, + x: enemy.x, + y: enemy.y, + radius: 13, + vx: direction.x * (fireballAttack.speed ?? FIREBALL_SPEED), + vy: direction.y * (fireballAttack.speed ?? FIREBALL_SPEED), + bouncesRemaining: 9999, + }) + state.nextHazardId += 1 + } +} + +function updateBird(state: BulldromeState, enemy: EnemyState, deltaSeconds: number) { + enemy.telegraph.active = false + enemy.slamTelegraph.active = false + const diveAttack = getEnabledActionAttack(enemy.kind, 'birdDive') + + if (enemy.phase === 'mauling' || enemy.phase === 'tracking') { + enemy.flightStep = 'ground' + enemy.targetId = 'tank' + const tank = findTarget(state, 'tank') ?? state.player + driftTowardTarget(state, enemy, tank, deltaSeconds) + enemy.tankSwingTimer -= deltaSeconds + state.message = `${enemy.name} claws ${tank.name}.` + if (enemy.tankSwingTimer <= 0) { + applyDamage(tank, scaleDamageForDifficulty(state, getTankSwingDamage(enemy.kind))) + tank.invulnerableTimer = 0.12 + state.lastHit = tank.id === 'player' ? 'player' : 'party' + enemy.tankSwingTimer += getTankSwingSeconds(enemy.kind) + } + if (enemy.phaseTimer <= 0) { + if (diveAttack) startBirdFlight(state, enemy) + else enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + } + return + } + + if (enemy.phase === 'windup') { + enemy.flightStep = 'toLeft' + moveEnemyTowardPoint(state, enemy, { + x: state.arena.padding + enemy.radius, + y: enemy.flightY, + }, getChargeSpeed(enemy.kind) * deltaSeconds) + state.message = `${enemy.name} lifts off.` + if (enemy.x <= state.arena.padding + enemy.radius + 3) { + enemy.phase = 'charging' + enemy.flightStep = 'across' + enemy.chargeVector = { x: 1, y: 0 } + enemy.phaseTimer = 12 + enemy.telegraph = { + active: true, + start: { x: state.arena.padding, y: enemy.y }, + end: { x: state.arena.width - state.arena.padding, y: enemy.y }, + width: 42, + } + } + return + } + + if (enemy.phase === 'charging') { + enemy.flightStep = 'across' + enemy.telegraph.active = true + enemy.x += getChargeSpeed(enemy.kind) * deltaSeconds + enemy.y = enemy.flightY + clampToArena(state, enemy) + state.message = `${enemy.name} dives across the arena.` + if (enemy.x >= state.arena.width - state.arena.padding - enemy.radius - 3) { + enemy.phase = 'recovering' + enemy.flightStep = 'toTank' + enemy.telegraph.active = false + } + return + } + + if (enemy.phase === 'recovering') { + enemy.flightStep = 'toTank' + const tank = findTarget(state, 'tank') ?? state.player + moveEnemyTowardPoint(state, enemy, tank, getChargeSpeed(enemy.kind) * deltaSeconds) + state.message = `${enemy.name} returns to ${tank.name}.` + if (distance(enemy, tank) <= enemy.radius + tank.radius + 18) { + enemy.phase = 'mauling' + enemy.flightStep = 'ground' + enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + enemy.tankSwingTimer = 0.35 + } + } +} + +function startBirdFlight(state: BulldromeState, enemy: EnemyState) { + enemy.phase = 'windup' + enemy.flightStep = 'toLeft' + enemy.telegraph.active = true + enemy.telegraph = { + active: true, + start: { x: state.arena.padding, y: enemy.flightY }, + end: { x: state.arena.width - state.arena.padding, y: enemy.flightY }, + width: 42, + } + state.message = `${enemy.name} starts a dive path.` +} + +function startTankFocus(enemy: EnemyState) { + enemy.phase = 'mauling' + enemy.phaseTimer = getTankFocusSeconds(enemy.kind) + enemy.targetId = 'tank' + enemy.tankSwingTimer = 0.35 + enemy.telegraph.active = false + enemy.slamTelegraph.active = false +} + +function startSlam(enemy: EnemyState) { + const slam = getEnabledActionAttack(enemy.kind, 'groundSlam') + enemy.phase = 'slamWindup' + enemy.phaseTimer = slam?.windupSeconds ?? BOSS_SLAM_CAST_SECONDS + enemy.slamTelegraph.radius = slam?.radius ?? BOSS_SLAM_RADIUS + enemy.slamTelegraph.active = true + enemy.telegraph.active = false +} + +function resolveGroundSlam(state: BulldromeState, enemy: EnemyState) { + const slam = getEnabledActionAttack(enemy.kind, 'groundSlam') + if (!slam) return + + let hitCount = 0 + for (const unit of getTargetableUnits(state)) { + if (unit.hp <= 0 || distance(unit, enemy) > (slam.radius ?? BOSS_SLAM_RADIUS)) continue + applyDamage(unit, scaleDamageForDifficulty(state, slam.damage)) + unit.stunTimer = 0.35 + unit.invulnerableTimer = PLAYER_INVULNERABLE_SECONDS + hitCount += 1 + } + state.lastHit = hitCount > 0 ? 'party' : null + state.message = hitCount > 0 ? `Ground slam hit ${hitCount}.` : 'Ground slam missed.' +} + +function resolvePartyDamage(state: BulldromeState, deltaSeconds: number) { + if (state.result !== 'playing') return + + for (const member of state.party) { + if (member.hp <= 0 || member.stunTimer > 0) continue + const target = getAiTargetForMember(state, member) + if (!target) continue + + const enemyDistance = distance(member, target) + let damage = 0 + if (member.role === 'tank' && enemyDistance <= target.radius + 58) damage = TANK_DPS * deltaSeconds + if (member.role === 'melee' && enemyDistance <= target.radius + 72) damage = MELEE_DPS * deltaSeconds + if (member.role === 'ranged' && enemyDistance <= RANGED_ATTACK_RANGE) damage = RANGED_DPS * deltaSeconds + if (damage <= 0) continue + + target.hp = Math.max(0, target.hp - damage) + if (target.hp <= 0) { + target.phase = 'defeated' + target.telegraph.active = false + target.slamTelegraph.active = false + state.message = `${target.name} defeated.` + } + } +} + +function updateFireHazards(state: BulldromeState, deltaSeconds: number) { + state.fireSpots = state.fireSpots + .map((spot) => ({ ...spot, remaining: spot.remaining - deltaSeconds })) + .filter((spot) => spot.remaining > 0) + + const nextFireballs: FireballState[] = [] + for (const fireball of state.fireballs) { + const next = { ...fireball } + next.x += next.vx * deltaSeconds + next.y += next.vy * deltaSeconds + + let bounced = false + const minX = state.arena.padding + next.radius + const maxX = state.arena.width - state.arena.padding - next.radius + const minY = state.arena.padding + next.radius + const maxY = state.arena.height - state.arena.padding - next.radius + + if (next.x <= minX || next.x >= maxX) { + next.x = Math.max(minX, Math.min(maxX, next.x)) + next.vx *= -1 + bounced = true + } + if (next.y <= minY || next.y >= maxY) { + next.y = Math.max(minY, Math.min(maxY, next.y)) + next.vy *= -1 + bounced = true + } + + if (bounced) { + addFireSpot(state, next.x, next.y) + } + + for (const unit of getTargetableUnits(state)) { + if (unit.hp <= 0 || unit.invulnerableTimer > 0 || distance(unit, next) > unit.radius + next.radius) continue + applyDamage(unit, scaleDamageForDifficulty(state, getAttackDamage('yian-kut-ku', 'fireballVolley', FIREBALL_DAMAGE))) + igniteUnit(unit) + addFireSpot(state, next.x, next.y) + bounceFireballOffUnit(next, unit) + unit.invulnerableTimer = PLAYER_INVULNERABLE_SECONDS + state.lastHit = unit.id === 'player' ? 'player' : 'party' + state.message = `${unit.name} was hit by a fireball.` + } + nextFireballs.push(next) + } + state.fireballs = nextFireballs + + for (const spot of state.fireSpots) { + for (const unit of getTargetableUnits(state)) { + if (unit.hp <= 0 || unit.burnTimer > 0 || distance(unit, spot) > unit.radius + spot.radius) continue + igniteUnit(unit) + state.lastHit = unit.id === 'player' ? 'player' : 'party' + state.message = `${unit.name} is standing in fire.` + } + } +} + +function bounceFireballOffUnit(fireball: FireballState, unit: TargetableState) { + const dx = fireball.x - unit.x + const dy = fireball.y - unit.y + const length = Math.max(1, Math.hypot(dx, dy)) + const nx = dx / length + const ny = dy / length + const dot = fireball.vx * nx + fireball.vy * ny + fireball.vx -= 2 * dot * nx + fireball.vy -= 2 * dot * ny + fireball.x = unit.x + nx * (unit.radius + fireball.radius + 2) + fireball.y = unit.y + ny * (unit.radius + fireball.radius + 2) +} + +function addFireSpot(state: BulldromeState, x: number, y: number) { + state.fireSpots.push({ + id: `firespot-${state.nextHazardId}`, + x, + y, + radius: 34, + remaining: FIRE_SPOT_SECONDS, + }) + state.nextHazardId += 1 +} + +function igniteUnit(unit: TargetableState) { + unit.burnTimer = FIRE_DOT_SECONDS + unit.burnTickTimer = FIRE_DOT_TICK_SECONDS +} + +function getAiTargetForMember(state: BulldromeState, member: PartyMemberState): EnemyState | null { + const enemies = getLivingEnemies(state) + if (enemies.length === 0) return null + + if (member.role === 'tank') return getPrimaryTankEnemy(state) + + if (state.encounterStep === 'trash') { + return enemies[member.aiSlot % enemies.length] ?? enemies[0] + } + + if (isHighActionTier(state.difficulty) && state.dungeonId === 'bulldrome') { + if (member.id === 'melee-1') return enemies.find((enemy) => enemy.id === 'hard-bullfango-1') ?? enemies[0] + if (member.id === 'ranged-1') return enemies.find((enemy) => enemy.id === 'hard-bullfango-2') ?? enemies[0] + } + + return enemies.find((enemy) => enemy.kind === 'bulldrome' || enemy.kind === 'yian-kut-ku') ?? enemies[0] +} + +function getPrimaryTankEnemy(state: BulldromeState): EnemyState | null { + const enemies = getLivingEnemies(state) + return enemies.find((enemy) => enemy.kind === 'bulldrome' || enemy.kind === 'yian-kut-ku') ?? enemies[0] ?? null +} + +function getTankTargetId(state: BulldromeState, enemy: EnemyState) { + const tank = findTarget(state, 'tank') + return tank && tank.hp > 0 ? tank.id : selectChargeTarget(state, enemy) +} + +function driftTowardTarget( + state: BulldromeState, + enemy: EnemyState, + target: TargetableState, + deltaSeconds: number, +) { + const dx = target.x - enemy.x + const dy = target.y - enemy.y + const length = Math.hypot(dx, dy) + if (length <= 1) return + + const driftSpeed = (enemy.kind === 'bulldrome' ? 82 : enemy.kind === 'yian-kut-ku' ? 72 : 62) * deltaSeconds + enemy.x += (dx / length) * driftSpeed + enemy.y += (dy / length) * driftSpeed + clampToArena(state, enemy) +} + +function moveEnemyTowardPoint(state: BulldromeState, enemy: EnemyState, point: Point, maxDistance: number) { + const dx = point.x - enemy.x + const dy = point.y - enemy.y + const length = Math.hypot(dx, dy) + if (length <= 2) return + const distanceThisFrame = Math.min(length, maxDistance) + enemy.x += (dx / length) * distanceThisFrame + enemy.y += (dy / length) * distanceThisFrame + clampToArena(state, enemy) +} + +function startWindup(state: BulldromeState, enemy: EnemyState) { + const target = findTarget(state, enemy.targetId) ?? state.player + const dx = target.x - enemy.x + const dy = target.y - enemy.y + const length = Math.max(1, Math.hypot(dx, dy)) + const chargeVector = { x: dx / length, y: dy / length } + const end = projectToArenaWall(state, enemy, chargeVector) + + enemy.phase = 'windup' + enemy.phaseTimer = getWindupSeconds(enemy.kind) + enemy.chargeVector = chargeVector + enemy.telegraph = { + active: true, + start: { x: enemy.x, y: enemy.y }, + end, + width: enemy.kind === 'bulldrome' ? 86 : 48, + } + enemy.chargeCount += 1 +} + +function selectChargeTarget(state: BulldromeState, enemy: EnemyState) { + const living = getTargetableUnits(state).filter((unit) => unit.hp > 0) + const preferred = enemy.kind === 'bulldrome' + ? ['player', 'tank', 'melee-1', 'ranged-1', 'ranged-2'] + : ['tank', 'melee-1', 'player', 'ranged-1', 'ranged-2'] + const offset = enemy.chargeCount % preferred.length + const ordered = preferred.slice(offset).concat(preferred.slice(0, offset)) + return ordered.find((id) => living.some((unit) => unit.id === id)) ?? 'player' +} + +function resolveContact(state: BulldromeState) { + for (const enemy of getLivingEnemies(state)) { + for (const unit of getTargetableUnits(state)) { + const overlap = unit.hp > 0 && distance(unit, enemy) < unit.radius + enemy.radius + if (!overlap || unit.invulnerableTimer > 0) continue + + const isCharge = enemy.phase === 'charging' + applyDamage(unit, scaleDamageForDifficulty(state, isCharge ? getChargeDamage(enemy.kind) : getBodyDamage(enemy.kind))) + unit.stunTimer = isCharge ? PLAYER_STUN_SECONDS : 0.25 + unit.invulnerableTimer = PLAYER_INVULNERABLE_SECONDS + state.lastHit = unit.id === 'player' ? 'player' : 'party' + state.message = isCharge + ? `${unit.name} ate ${enemy.name}'s charge.` + : `${unit.name} was trampled.` + + const dx = unit.x - enemy.x + const dy = unit.y - enemy.y + const length = Math.max(1, Math.hypot(dx, dy)) + unit.x += (dx / length) * 34 + unit.y += (dy / length) * 34 + clampToArena(state, unit) + } + } +} + +function startPlayerSpell(state: BulldromeState, spell: SpellSlot | null) { + if (!spell || state.player.currentCast || state.player.spellCooldowns[spell] > 0 || state.player.stunTimer > 0) return + const target = findTarget(state, state.targetId) + if (!target || target.hp <= 0) { + state.message = 'No living target selected.' + return + } + + if (!isInHealRange(state, target)) { + showNotice(state, target.id, 'Not in range') + state.message = 'Not in range.' + return + } + + const definition = SPELLS[spell] + if (definition.castTime > 0) { + state.player.currentCast = { + spell, + targetId: target.id, + remaining: definition.castTime, + total: definition.castTime, + } + state.message = `Casting ${definition.name} on ${target.name}.` + return + } + + completePlayerSpell(state, spell, target.id) +} + +function completePlayerSpell(state: BulldromeState, spell: SpellSlot, targetId: string) { + const target = findTarget(state, targetId) + if (!target || target.hp <= 0) { + state.message = 'Spell fizzled. Target is down.' + return + } + + if (!isInHealRange(state, target)) { + showNotice(state, target.id, 'Not in range') + state.message = 'Not in range.' + return + } + + state.player.spellCooldowns[spell] = SPELLS[spell].cooldown + if (spell === 1) { + healTarget(state, target, MEND_HEAL) + state.lastHit = 'heal' + state.message = `Mend healed ${target.name}.` + return + } + + if (spell === 2) { + target.renewTimer = RENEW_SECONDS + target.renewTickTimer = RENEW_TICK_SECONDS + healTarget(state, target, 4) + state.lastHit = 'heal' + state.message = `Renew on ${target.name}.` + return + } + + if (spell === 3) { + const injured = getTargetableUnits(state) + .filter((unit) => unit.hp > 0 && unit.hp < unit.maxHp && isInHealRange(state, unit)) + .sort((a, b) => (b.maxHp - b.hp) - (a.maxHp - a.hp)) + .slice(0, 4) + for (const unit of injured) healTarget(state, unit, RADIANCE_HEAL) + state.lastHit = 'heal' + state.message = `Radiance healed ${injured.length || 0}.` + return + } + + if (spell === 4) { + target.shield = Math.min(60, target.shield + SUN_WARD_SHIELD) + state.lastHit = 'heal' + state.message = `Sun Ward shields ${target.name}.` + return + } + + healTarget(state, target, PURIFY_HEAL) + target.stunTimer = 0 + state.lastHit = 'heal' + state.message = `Purify steadied ${target.name}.` +} + +function healTarget(state: BulldromeState, target: TargetableState, amount: number) { + const before = target.hp + target.hp = Math.min(target.maxHp, target.hp + amount) + const healed = target.hp - before + if (healed <= 0) return + + state.healEvents.push({ + id: `${state.elapsed.toFixed(3)}-${target.id}-${state.healEvents.length}`, + targetId: target.id, + amount: healed, + }) +} + +function isInHealRange(state: BulldromeState, target: TargetableState) { + return distance(state.player, target) <= HEAL_RANGE +} + +function showNotice(state: BulldromeState, targetId: string, text: string) { + state.noticeEvents.push({ + id: `${state.elapsed.toFixed(3)}-${targetId}-${state.noticeEvents.length}-${text}`, + targetId, + text, + }) +} + +function applyDamage(unit: TargetableState, amount: number) { + const absorbed = Math.min(unit.shield, amount) + unit.shield -= absorbed + unit.hp = Math.max(0, unit.hp - (amount - absorbed)) +} + +function getTelegraphDodgePoint(state: BulldromeState, member: PartyMemberState): Point | null { + const telegraphEnemy = getLivingEnemies(state).find((enemy) => ( + enemy.telegraph.active + && enemy.phase === 'windup' + && isPointNearSegment(member, enemy.telegraph.start, enemy.telegraph.end, enemy.telegraph.width * 0.42) + )) + if (!telegraphEnemy) return null + + const vector = telegraphEnemy.chargeVector + const perpendicular = { x: -vector.y, y: vector.x } + const side = member.aiSlot % 2 === 0 ? 1 : -1 + return clampPointToArena(state, { + x: member.x + perpendicular.x * side * 112, + y: member.y + perpendicular.y * side * 112, + }, member.radius) +} + +function isPointNearSegment(point: Point, start: Point, end: Point, width: number) { + const dx = end.x - start.x + const dy = end.y - start.y + const lengthSquared = dx * dx + dy * dy + if (lengthSquared <= 0) return distance(point, start) <= width + const t = Math.max(0, Math.min(1, ((point.x - start.x) * dx + (point.y - start.y) * dy) / lengthSquared)) + const projected = { + x: start.x + dx * t, + y: start.y + dy * t, + } + return distance(point, projected) <= width +} + +function resolveResult(state: BulldromeState) { + if (state.respawnTimer > 0) return + + const livingEnemies = getLivingEnemies(state) + if (livingEnemies.length <= 0) { + if (state.encounterStep === 'trash') { + startBossEncounter(state) + return + } + + if (state.runMode === 'marathon') { + state.bossKills += 1 + state.respawnTimer = 5 + state.fireballs = [] + state.fireSpots = [] + state.message = `Boss ${state.bossKills} down. Next boss in 5.` + return + } + + state.result = 'win' + state.message = state.dungeonId === 'yian-kut-ku' + ? 'Yian Kut-Ku defeated. Hunt complete.' + : isHighActionTier(state.difficulty) + ? 'Hard hunt complete. Bulldrome and Bullfangos down.' + : 'Bulldrome broken. Hunt complete.' + } + + if (state.player.hp <= 0) { + for (const enemy of getLivingEnemies(state)) { + enemy.phase = 'victory' + enemy.telegraph.active = false + enemy.slamTelegraph.active = false + } + state.result = 'loss' + state.message = 'You were carted. Reset and keep yourself alive.' + } +} + +function startBossEncounter(state: BulldromeState) { + state.encounterStep = 'boss' + if (state.dungeonId === 'yian-kut-ku') { + state.boss = createYianKutKu() + state.adds = [ + createBird('boss-bird-1', 'Bird', 342, 188, 0, 188), + createBird('boss-bird-2', 'Bird', 618, 188, 0, 318), + ] + scaleEnemyForDifficulty(state.difficulty, state.boss) + state.adds.forEach((enemy) => scaleEnemyForDifficulty(state.difficulty, enemy)) + state.message = 'Birds scatter. Yian Kut-Ku lands with two birds.' + state.fireballs = [] + state.fireSpots = [] + return + } + + state.boss = createBulldrome() + state.adds = [] + scaleEnemyForDifficulty(state.difficulty, state.boss) + state.fireballs = [] + state.fireSpots = [] + state.message = 'Bullfangos down. Bulldrome crashes into the grounds.' +} + +function respawnBossEncounter(state: BulldromeState) { + state.encounterStep = 'boss' + if (state.dungeonId === 'yian-kut-ku') { + state.boss = createYianKutKu() + state.adds = [ + createBird(`marathon-bird-${state.bossKills}-1`, 'Bird', 342, 188, 0, 188), + createBird(`marathon-bird-${state.bossKills}-2`, 'Bird', 618, 188, 0, 318), + ] + scaleEnemyForDifficulty(state.difficulty, state.boss) + state.adds.forEach((enemy) => scaleEnemyForDifficulty(state.difficulty, enemy)) + state.message = 'Marathon continues. Yian Kut-Ku lands with two birds.' + } else { + state.boss = createBulldrome() + state.adds = isHighActionTier(state.difficulty) + ? [ + createBullfango(`marathon-bullfango-${state.bossKills}-1`, 'Bullfango A', 342, 188, 0.55), + createBullfango(`marathon-bullfango-${state.bossKills}-2`, 'Bullfango B', 618, 188, 1.1), + ] + : [] + scaleEnemyForDifficulty(state.difficulty, state.boss) + state.adds.forEach((enemy) => scaleEnemyForDifficulty(state.difficulty, enemy)) + state.message = isHighActionTier(state.difficulty) + ? 'Marathon continues. Bulldrome returns with Bullfangos.' + : 'Marathon continues. Bulldrome returns.' + } + state.fireballs = [] + state.fireSpots = [] + state.respawnTimer = 0 +} + +function findTarget(state: BulldromeState, id: string) { + return getTargetableUnits(state).find((unit) => unit.id === id) +} + +function setTarget(state: BulldromeState, id: string) { + const target = findTarget(state, id) + if (target) state.targetId = target.id +} + +function cycleTarget(state: BulldromeState, delta: -1 | 1) { + const frames = getRaidFrames(state) + const currentIndex = Math.max(0, frames.findIndex((frame) => frame.id === state.targetId)) + const nextIndex = (currentIndex + delta + frames.length) % frames.length + state.targetId = frames[nextIndex].id +} + +function clampToArena(state: BulldromeState, fighter: FighterState) { + fighter.x = Math.max( + state.arena.padding + fighter.radius, + Math.min(state.arena.width - state.arena.padding - fighter.radius, fighter.x), + ) + fighter.y = Math.max( + state.arena.padding + fighter.radius, + Math.min(state.arena.height - state.arena.padding - fighter.radius, fighter.y), + ) +} + +function clampPointToArena(state: BulldromeState, point: Point, radius: number): Point { + return { + x: Math.max( + state.arena.padding + radius, + Math.min(state.arena.width - state.arena.padding - radius, point.x), + ), + y: Math.max( + state.arena.padding + radius, + Math.min(state.arena.height - state.arena.padding - radius, point.y), + ), + } +} + +function hitArenaWall(state: BulldromeState, fighter: FighterState) { + return ( + fighter.x <= state.arena.padding + fighter.radius + 1 + || fighter.x >= state.arena.width - state.arena.padding - fighter.radius - 1 + || fighter.y <= state.arena.padding + fighter.radius + 1 + || fighter.y >= state.arena.height - state.arena.padding - fighter.radius - 1 + ) +} + +function projectToArenaWall(state: BulldromeState, start: Point, vector: Point): Point { + const minX = state.arena.padding + const maxX = state.arena.width - state.arena.padding + const minY = state.arena.padding + const maxY = state.arena.height - state.arena.padding + const candidates: number[] = [] + + if (vector.x > 0) candidates.push((maxX - start.x) / vector.x) + if (vector.x < 0) candidates.push((minX - start.x) / vector.x) + if (vector.y > 0) candidates.push((maxY - start.y) / vector.y) + if (vector.y < 0) candidates.push((minY - start.y) / vector.y) + + const distanceToWall = Math.min(...candidates.filter((candidate) => candidate > 0)) + return { + x: start.x + vector.x * distanceToWall, + y: start.y + vector.y * distanceToWall, + } +} + +function getWindupSeconds(kind: EnemyKind) { + const fallback = kind === 'bulldrome' ? BOSS_WINDUP_SECONDS : BULLFANGO_WINDUP_SECONDS + return getAttackNumber(kind, 'charge', 'windupSeconds', fallback) +} + +function getChargeSeconds(kind: EnemyKind) { + if (kind === 'yian-kut-ku' || kind === 'bird') return 0 + return kind === 'bulldrome' ? BOSS_CHARGE_SECONDS : BULLFANGO_CHARGE_SECONDS +} + +function getChargeSpeed(kind: EnemyKind) { + if (kind === 'yian-kut-ku') return 0 + if (kind === 'bird') return getAttackNumber(kind, 'birdDive', 'speed', BIRD_FLIGHT_SPEED) + const fallback = kind === 'bulldrome' ? BOSS_CHARGE_SPEED : BULLFANGO_CHARGE_SPEED + return getAttackNumber(kind, 'charge', 'speed', fallback) +} + +function getRecoverSeconds(kind: EnemyKind) { + if (kind === 'yian-kut-ku') return getAttackNumber(kind, 'fireballVolley', 'recoverSeconds', YIAN_RECOVER_SECONDS) + if (kind === 'bird') return 0 + const fallback = kind === 'bulldrome' ? BOSS_RECOVER_SECONDS : BULLFANGO_RECOVER_SECONDS + return getAttackNumber(kind, 'charge', 'recoverSeconds', fallback) +} + +function getTankFocusSeconds(kind: EnemyKind) { + if (kind === 'yian-kut-ku') return getAttackFrequency(kind, 'fireballVolley', YIAN_TANK_FOCUS_SECONDS) + if (kind === 'bird') return getAttackFrequency(kind, 'birdDive', BIRD_FLIGHT_TIMER_SECONDS) + const fallback = kind === 'bulldrome' ? BOSS_TANK_FOCUS_SECONDS : BULLFANGO_TANK_FOCUS_SECONDS + return getAttackFrequency(kind, 'charge', fallback) +} + +function getTankSwingSeconds(kind: EnemyKind) { + const fallback = kind === 'yian-kut-ku' + ? YIAN_TANK_SWING_SECONDS + : kind === 'bird' + ? BIRD_TANK_SWING_SECONDS + : kind === 'bulldrome' + ? BOSS_TANK_SWING_SECONDS + : BULLFANGO_TANK_SWING_SECONDS + return getAttackFrequency(kind, 'tankMelee', fallback) +} + +function getTankSwingDamage(kind: EnemyKind) { + const fallback = kind === 'yian-kut-ku' + ? YIAN_TANK_SWING_DAMAGE + : kind === 'bird' + ? BIRD_TANK_SWING_DAMAGE + : kind === 'bulldrome' + ? BOSS_TANK_SWING_DAMAGE + : BULLFANGO_TANK_SWING_DAMAGE + return getAttackDamage(kind, 'tankMelee', fallback) +} + +function getChargeDamage(kind: EnemyKind) { + if (kind === 'yian-kut-ku') return 0 + if (kind === 'bird') return getAttackDamage(kind, 'birdDive', BIRD_CHARGE_DAMAGE) + const fallback = kind === 'bulldrome' ? BOSS_CHARGE_DAMAGE : BULLFANGO_CHARGE_DAMAGE + return getAttackDamage(kind, 'charge', fallback) +} + +function getBodyDamage(kind: EnemyKind) { + const fallback = kind === 'yian-kut-ku' + ? YIAN_BODY_DAMAGE + : kind === 'bird' + ? BIRD_BODY_DAMAGE + : kind === 'bulldrome' + ? BOSS_BODY_DAMAGE + : BULLFANGO_BODY_DAMAGE + return getAttackDamage(kind, 'bodyContact', fallback) +} + +function getTrackSeconds(kind: EnemyKind) { + if (kind === 'yian-kut-ku') return getAttackFrequency(kind, 'fireballVolley', YIAN_TRACK_SECONDS) + if (kind === 'bird') return getAttackFrequency(kind, 'birdDive', BIRD_FLIGHT_TIMER_SECONDS) + const fallback = kind === 'bulldrome' ? BOSS_TRACK_SECONDS : BULLFANGO_TRACK_SECONDS + return getAttackFrequency(kind, 'charge', fallback) +} + +function getAttackFrequency(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) { + return getActionAttack(kind, attackKind)?.frequencySeconds ?? fallback +} + +function getAttackDamage(kind: EnemyKind, attackKind: ActionAttackKind, fallback: number) { + const attack = getActionAttack(kind, attackKind) + if (!attack) return fallback + return attack.enabled ? attack.damage : 0 +} + +function applyDifficultyScaling(state: BulldromeState) { + for (const enemy of getAllEnemies(state)) { + scaleEnemyForDifficulty(state.difficulty, enemy) + } +} + +function scaleEnemyForDifficulty(difficulty: ActionDifficulty, enemy: EnemyState) { + const multiplier = getActionDifficultyTier(difficulty).healthMultiplier + enemy.maxHp = Math.ceil(enemy.maxHp * multiplier) + enemy.hp = enemy.maxHp +} + +function scaleDamageForDifficulty(state: BulldromeState, damage: number) { + return Math.ceil(damage * getActionDifficultyTier(state.difficulty).damageMultiplier) +} + +function isHighActionTier(difficulty: ActionDifficulty) { + return getActionDifficultyTier(difficulty).itemLevel > 1 +} + +function getAttackNumber( + kind: EnemyKind, + attackKind: ActionAttackKind, + field: 'windupSeconds' | 'recoverSeconds' | 'speed' | 'radius' | 'everyNthCharge', + fallback: number, +) { + return getActionAttack(kind, attackKind)?.[field] ?? fallback +} + +function cloneEnemy(enemy: EnemyState): EnemyState { + return { + ...enemy, + chargeVector: { ...enemy.chargeVector }, + slamTelegraph: { ...enemy.slamTelegraph }, + telegraph: { + active: enemy.telegraph.active, + start: { ...enemy.telegraph.start }, + end: { ...enemy.telegraph.end }, + width: enemy.telegraph.width, + }, + } +} + +function cloneState(state: BulldromeState): BulldromeState { + return { + dungeonId: state.dungeonId, + difficulty: state.difficulty, + runMode: state.runMode, + encounterStep: state.encounterStep, + arena: { ...state.arena }, + player: { + ...state.player, + currentCast: state.player.currentCast ? { ...state.player.currentCast } : null, + spellCooldowns: { ...state.player.spellCooldowns }, + }, + party: state.party.map((member) => ({ ...member })), + targetId: state.targetId, + boss: cloneEnemy(state.boss), + adds: state.adds.map(cloneEnemy), + fireballs: state.fireballs.map((fireball) => ({ ...fireball })), + fireSpots: state.fireSpots.map((spot) => ({ ...spot })), + nextHazardId: state.nextHazardId, + bossKills: state.bossKills, + respawnTimer: state.respawnTimer, + healEvents: state.healEvents.map((event) => ({ ...event })), + noticeEvents: state.noticeEvents.map((event) => ({ ...event })), + elapsed: state.elapsed, + message: state.message, + result: state.result, + lastHit: state.lastHit, + } +} diff --git a/src/actionMode.ts b/src/actionMode.ts new file mode 100644 index 0000000..9d55d3a --- /dev/null +++ b/src/actionMode.ts @@ -0,0 +1,448 @@ +export type ActionDifficulty = 'ilvl-1' | 'ilvl-10' | 'ilvl-20' | 'ilvl-30' +export type ActionDungeonId = 'bulldrome' | 'yian-kut-ku' | 'rathian' +export type ActionRunMode = 'hunt' | 'marathon' +export type ActionGearSource = 'bulldrome' | 'yian-kut-ku' +export type ActionCoinColor = 'white' | 'green' | 'blue' | 'purple' +export type ActionCoinWallet = Record> + +export type ActionDifficultyTier = { + id: ActionDifficulty + label: string + itemLevel: 1 | 10 | 20 | 30 + coinColor: ActionCoinColor + coinLabel: string + healthMultiplier: number + damageMultiplier: number + lootMultiplier: number + experience: number +} + +export type ActionGearSlot = + | 'weapon' + | 'helmet' + | 'chest' + | 'gloves' + | 'boots' + | 'pants' + | 'ring' + | 'necklace' + | 'trinket' + +export type ActionGearPiece = { + id: string + slug: string + name: string + source: ActionGearSource + slot: ActionGearSlot + itemLevel: number +} + +export type ActionGearStats = { + healingPower: number + stamina: number +} + +export type ActionRunReward = { + coins: number + coinName: string + experience: number + gear: ActionGearPiece[] + leveledUp: boolean +} + +export type ActionCharacter = { + id: string + name: string + level: number + experience: number + actionCoins: ActionCoinWallet + bulldromeCoins: number + yianKutKuCoins: number + bulldromeNormalClears: number + bulldromeHardClears: number + yianKutKuNormalClears: number + yianKutKuHardClears: number + inventory: ActionGearPiece[] +} + +export const ACTION_GEAR_SLOTS: Array<{ + slot: ActionGearSlot + label: string + glyph: string +}> = [ + { slot: 'weapon', label: 'Weapon', glyph: '/' }, + { slot: 'helmet', label: 'Helmet', glyph: 'H' }, + { slot: 'chest', label: 'Chest', glyph: 'C' }, + { slot: 'gloves', label: 'Gloves', glyph: 'G' }, + { slot: 'boots', label: 'Boots', glyph: 'B' }, + { slot: 'pants', label: 'Pants', glyph: 'P' }, + { slot: 'ring', label: 'Ring', glyph: 'O' }, + { slot: 'necklace', label: 'Necklace', glyph: 'N' }, + { slot: 'trinket', label: 'Trinket', glyph: 'T' }, +] + +export const ACTION_DIFFICULTY_TIERS: ActionDifficultyTier[] = [ + { + id: 'ilvl-1', + label: 'iLvl 1', + itemLevel: 1, + coinColor: 'white', + coinLabel: 'White Coins', + healthMultiplier: 1, + damageMultiplier: 1, + lootMultiplier: 1, + experience: 60, + }, + { + id: 'ilvl-10', + label: 'iLvl 10', + itemLevel: 10, + coinColor: 'green', + coinLabel: 'Green Coins', + healthMultiplier: 1.55, + damageMultiplier: 1.28, + lootMultiplier: 2, + experience: 110, + }, + { + id: 'ilvl-20', + label: 'iLvl 20', + itemLevel: 20, + coinColor: 'blue', + coinLabel: 'Blue Coins', + healthMultiplier: 2.25, + damageMultiplier: 1.62, + lootMultiplier: 3, + experience: 180, + }, + { + id: 'ilvl-30', + label: 'iLvl 30', + itemLevel: 30, + coinColor: 'purple', + coinLabel: 'Purple Coins', + healthMultiplier: 3.1, + damageMultiplier: 2.05, + lootMultiplier: 4, + experience: 280, + }, +] + +const ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v2' +const LEGACY_ACTION_SAVE_KEY = 'i-want-to-heal:action-mode-save:v1' +const MAX_ACTION_LEVEL = 25 +export const BULLDROME_UPGRADE_COST = 5 +export const ACTION_GEAR_UPGRADE_COST = 5 + +const DEFAULT_ACTION_CHARACTER: ActionCharacter = { + id: 'action-local-1', + name: 'Action Healer', + level: 1, + experience: 0, + actionCoins: createEmptyCoinWallet(), + bulldromeCoins: 0, + yianKutKuCoins: 0, + bulldromeNormalClears: 0, + bulldromeHardClears: 0, + yianKutKuNormalClears: 0, + yianKutKuHardClears: 0, + inventory: [], +} + +export function loadActionCharacter(): ActionCharacter { + const saved = window.localStorage.getItem(ACTION_SAVE_KEY) + ?? window.localStorage.getItem(LEGACY_ACTION_SAVE_KEY) + if (!saved) return DEFAULT_ACTION_CHARACTER + + try { + const parsed = JSON.parse(saved) as Partial + const experience = Number(parsed.experience ?? DEFAULT_ACTION_CHARACTER.experience) + return { + ...DEFAULT_ACTION_CHARACTER, + ...parsed, + id: DEFAULT_ACTION_CHARACTER.id, + experience, + level: getActionLevel(experience), + actionCoins: normalizeCoinWallet(parsed), + bulldromeCoins: Number(parsed.bulldromeCoins ?? DEFAULT_ACTION_CHARACTER.bulldromeCoins), + yianKutKuCoins: Number(parsed.yianKutKuCoins ?? DEFAULT_ACTION_CHARACTER.yianKutKuCoins), + bulldromeNormalClears: Number(parsed.bulldromeNormalClears ?? DEFAULT_ACTION_CHARACTER.bulldromeNormalClears), + bulldromeHardClears: Number(parsed.bulldromeHardClears ?? DEFAULT_ACTION_CHARACTER.bulldromeHardClears), + yianKutKuNormalClears: Number(parsed.yianKutKuNormalClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuNormalClears), + yianKutKuHardClears: Number(parsed.yianKutKuHardClears ?? DEFAULT_ACTION_CHARACTER.yianKutKuHardClears), + inventory: Array.isArray(parsed.inventory) ? parsed.inventory.map(normalizeGearPiece) : [], + } + } catch { + return DEFAULT_ACTION_CHARACTER + } +} + +export function saveActionCharacter(character: ActionCharacter) { + window.localStorage.setItem(ACTION_SAVE_KEY, JSON.stringify(character)) +} + +export function completeBulldromeHunt( + character: ActionCharacter, + difficulty: ActionDifficulty, +): { character: ActionCharacter, reward: ActionRunReward } { + const tier = getActionDifficultyTier(difficulty) + const coinRoll = randomInt(1, 3) + const lootMultiplier = tier.lootMultiplier + const coinReward = coinRoll * lootMultiplier + const experienceReward = tier.experience + const nextExperience = character.experience + experienceReward + const nextLevel = getActionLevel(nextExperience) + const gear = rollBulldromeGear(lootMultiplier, tier.itemLevel) + const actionCoins = addActionCoins(character.actionCoins, 'bulldrome', tier.coinColor, coinReward) + + return { + character: { + ...character, + level: nextLevel, + experience: nextExperience, + actionCoins, + bulldromeCoins: actionCoins.bulldrome.white, + bulldromeNormalClears: character.bulldromeNormalClears + (difficulty === 'ilvl-1' ? 1 : 0), + bulldromeHardClears: character.bulldromeHardClears + (difficulty !== 'ilvl-1' ? 1 : 0), + inventory: [...character.inventory, ...gear], + }, + reward: { + coins: coinReward, + experience: experienceReward, + gear, + coinName: `${tier.coinLabel.replace(' Coins', '')} Bulldrome Coins`, + leveledUp: nextLevel > character.level, + }, + } +} + +export function completeActionDungeonHunt( + character: ActionCharacter, + dungeonId: ActionDungeonId, + difficulty: ActionDifficulty, +): { character: ActionCharacter, reward: ActionRunReward } { + if (dungeonId === 'bulldrome') return completeBulldromeHunt(character, difficulty) + + if (dungeonId === 'yian-kut-ku') { + const tier = getActionDifficultyTier(difficulty) + const coinRoll = randomInt(1, 3) + const lootMultiplier = tier.lootMultiplier + const coinReward = coinRoll * lootMultiplier + const experienceReward = Math.ceil(tier.experience * 1.25) + const nextExperience = character.experience + experienceReward + const nextLevel = getActionLevel(nextExperience) + const gear = rollDungeonGear('yian-kut-ku', lootMultiplier, tier.itemLevel) + const actionCoins = addActionCoins(character.actionCoins, 'yian-kut-ku', tier.coinColor, coinReward) + + return { + character: { + ...character, + level: nextLevel, + experience: nextExperience, + actionCoins, + yianKutKuCoins: actionCoins['yian-kut-ku'].white, + yianKutKuNormalClears: character.yianKutKuNormalClears + (difficulty === 'ilvl-1' ? 1 : 0), + yianKutKuHardClears: character.yianKutKuHardClears + (difficulty !== 'ilvl-1' ? 1 : 0), + inventory: [...character.inventory, ...gear], + }, + reward: { + coins: coinReward, + coinName: `${tier.coinLabel.replace(' Coins', '')} Yian Kut-Ku Coins`, + experience: experienceReward, + gear, + leveledUp: nextLevel > character.level, + }, + } + } + + const experienceReward = getActionDifficultyTier(difficulty).experience + const nextExperience = character.experience + experienceReward + const nextLevel = getActionLevel(nextExperience) + return { + character: { + ...character, + level: nextLevel, + experience: nextExperience, + }, + reward: { + coins: 0, + coinName: 'Coins', + experience: experienceReward, + gear: [], + leveledUp: nextLevel > character.level, + }, + } +} + +export function upgradeBulldromeGear(character: ActionCharacter, itemId: string): ActionCharacter { + const item = character.inventory.find((candidate) => candidate.id === itemId) + if (!item || item.itemLevel >= getActionGearUpgradeCap(item) || getUpgradeCoinCount(character, item) < ACTION_GEAR_UPGRADE_COST) return character + + return { + ...character, + ...spendUpgradeCoins(character, item), + inventory: character.inventory.map((candidate) => ( + candidate.id === itemId + ? { ...candidate, itemLevel: candidate.itemLevel + 1 } + : candidate + )), + } +} + +export function getUpgradeCoinCount(character: ActionCharacter, item: Pick) { + const fullItem = item as Partial> + const coinColor = getCoinColorForItemLevel(fullItem.itemLevel ?? 1) + return getActionCoinCount(character, item.source, coinColor) +} + +export function getUpgradeCoinName(item: Pick & Partial>) { + const tier = getTierForItemLevel(item.itemLevel ?? 1) + const sourceName = item.source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome' + return `${tier.coinLabel.replace(' Coins', '')} ${sourceName} Coins` +} + +export function getActionGearStats(item: Pick): ActionGearStats { + const slotWeight = item.slot === 'weapon' + ? 2 + : item.slot === 'chest' || item.slot === 'helmet' || item.slot === 'pants' + ? 1.5 + : 1 + const healingPower = Math.ceil(item.itemLevel * slotWeight) + const stamina = item.slot === 'ring' || item.slot === 'necklace' || item.slot === 'trinket' + ? item.itemLevel * 2 + : item.itemLevel + + return { healingPower, stamina } +} + +export function getActionLevel(experience: number) { + return Math.min(MAX_ACTION_LEVEL, 1 + Math.floor(Math.max(0, experience) / 220)) +} + +export function getActionLevelProgress(character: ActionCharacter) { + const currentLevelStart = (character.level - 1) * 220 + const nextLevelStart = character.level * 220 + const earnedThisLevel = Math.max(0, character.experience - currentLevelStart) + const neededThisLevel = Math.max(1, nextLevelStart - currentLevelStart) + return { + currentLevelStart, + nextLevelStart, + percent: character.level >= MAX_ACTION_LEVEL + ? 100 + : Math.max(0, Math.min(100, (earnedThisLevel / neededThisLevel) * 100)), + } +} + +export function getActionDifficultyTier(difficulty: ActionDifficulty) { + return ACTION_DIFFICULTY_TIERS.find((tier) => tier.id === difficulty) ?? ACTION_DIFFICULTY_TIERS[0] +} + +export function getActionCoinCount( + character: Pick, + source: ActionGearSource, + color: ActionCoinColor, +) { + if (color === 'white') return source === 'yian-kut-ku' ? character.yianKutKuCoins : character.bulldromeCoins + return character.actionCoins?.[source]?.[color] ?? 0 +} + +export function getTierForItemLevel(itemLevel: number) { + return [...ACTION_DIFFICULTY_TIERS] + .reverse() + .find((tier) => itemLevel >= tier.itemLevel) ?? ACTION_DIFFICULTY_TIERS[0] +} + +export function getActionGearUpgradeCap(item: Pick) { + return getTierForItemLevel(item.itemLevel).itemLevel + 4 +} + +function rollBulldromeGear(rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) { + return rollDungeonGear('bulldrome', rolls, itemLevel) +} + +function rollDungeonGear(source: ActionGearSource, rolls: number, itemLevel: ActionDifficultyTier['itemLevel']) { + const gear: ActionGearPiece[] = [] + for (let index = 0; index < rolls; index += 1) { + if (Math.random() > 0.75) continue + const slot = ACTION_GEAR_SLOTS[randomInt(0, ACTION_GEAR_SLOTS.length - 1)] + gear.push(createDungeonGear(source, slot.slot, itemLevel)) + } + return gear +} + +function createDungeonGear(source: ActionGearSource, slot: ActionGearSlot, itemLevel: ActionDifficultyTier['itemLevel']): ActionGearPiece { + const slotMeta = ACTION_GEAR_SLOTS.find((candidate) => candidate.slot === slot)! + const sourceName = source === 'yian-kut-ku' ? 'Yian Kut-Ku' : 'Bulldrome' + return { + id: `${source}-${slot}-${Date.now()}-${Math.floor(Math.random() * 100000)}`, + slug: `${source}-${slot}`, + name: `${sourceName} ${slotMeta.label}`, + source, + slot, + itemLevel, + } +} + +function normalizeGearPiece(item: ActionGearPiece): ActionGearPiece { + const source = item.source ?? (item.slug?.startsWith('yian-kut-ku') ? 'yian-kut-ku' : 'bulldrome') + return { + ...item, + source, + } +} + +function spendUpgradeCoins(character: ActionCharacter, item: Pick) { + const itemWithLevel = item as Pick & Partial> + const coinColor = getCoinColorForItemLevel(itemWithLevel.itemLevel ?? 1) + const actionCoins = addActionCoins(character.actionCoins, item.source, coinColor, -ACTION_GEAR_UPGRADE_COST) + if (item.source === 'yian-kut-ku' && coinColor === 'white') { + return { actionCoins, yianKutKuCoins: actionCoins['yian-kut-ku'].white } + } + if (item.source === 'bulldrome' && coinColor === 'white') { + return { actionCoins, bulldromeCoins: actionCoins.bulldrome.white } + } + return { actionCoins } +} + +function getCoinColorForItemLevel(itemLevel: number) { + return getTierForItemLevel(itemLevel).coinColor +} + +function createEmptyCoinWallet(): ActionCoinWallet { + return { + bulldrome: { white: 0, green: 0, blue: 0, purple: 0 }, + 'yian-kut-ku': { white: 0, green: 0, blue: 0, purple: 0 }, + } +} + +function normalizeCoinWallet(parsed: Partial) { + const wallet = createEmptyCoinWallet() + const saved = parsed.actionCoins + for (const source of ['bulldrome', 'yian-kut-ku'] as const) { + for (const color of ['white', 'green', 'blue', 'purple'] as const) { + wallet[source][color] = Number(saved?.[source]?.[color] ?? 0) + } + } + wallet.bulldrome.white = Number(parsed.bulldromeCoins ?? wallet.bulldrome.white) + wallet['yian-kut-ku'].white = Number(parsed.yianKutKuCoins ?? wallet['yian-kut-ku'].white) + return wallet +} + +function addActionCoins( + wallet: ActionCoinWallet, + source: ActionGearSource, + color: ActionCoinColor, + amount: number, +) { + return { + ...wallet, + [source]: { + ...wallet[source], + [color]: Math.max(0, wallet[source][color] + amount), + }, + } +} + +function randomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} diff --git a/src/components/ActionModeScreen.tsx b/src/components/ActionModeScreen.tsx new file mode 100644 index 0000000..a4ac7c4 --- /dev/null +++ b/src/components/ActionModeScreen.tsx @@ -0,0 +1,790 @@ +import { useEffect, useMemo, useState } from 'react' +import { + loadActionMechanicConfig, + resetActionMechanicConfig, + saveActionMechanicConfig, + type ActionAttackConfig, + type ActionMechanicConfig, +} from '../actionBoss/actionEncounterConfig' +import type { EnemyKind } from '../actionBoss/bulldromeSimulation' +import { + ACTION_DIFFICULTY_TIERS, + ACTION_GEAR_SLOTS, + ACTION_GEAR_UPGRADE_COST, + completeActionDungeonHunt, + getActionCoinCount, + getActionDifficultyTier, + getActionGearStats, + getActionLevelProgress, + getUpgradeCoinCount, + getUpgradeCoinName, + loadActionCharacter, + saveActionCharacter, + upgradeBulldromeGear, + type ActionCharacter, + type ActionDifficulty, + type ActionDungeonId, + type ActionGearPiece, + type ActionGearSlot, + type ActionGearSource, + type ActionRunMode, + type ActionRunReward, +} from '../actionMode' +import { BulldromeBossSlice } from './BulldromeBossSlice' + +type ActionModeScreenProps = { + onBack?: () => void +} + +type ActionHubTab = 'dungeons' | 'raids' | 'pvp' | 'roguelike' | 'customize' | 'settings' +type ActionHubScreen = 'menu' | ActionHubTab +type PlayableActionDungeonId = Exclude + +const ACTION_HUB_ITEMS: Array<{ + id: ActionHubTab + label: string + glyph: string + description: string +}> = [ + { id: 'dungeons', label: 'Dungeons', glyph: 'D', description: 'Run action dungeons and earn gear.' }, + { id: 'raids', label: 'Raids', glyph: 'R', description: 'Large action encounters.' }, + { id: 'pvp', label: 'PVP', glyph: 'P', description: 'Action healer competitions.' }, + { id: 'roguelike', label: 'Roguelike', glyph: 'L', description: 'Draft upgrades through action fights.' }, + { id: 'customize', label: 'Customize Character', glyph: 'C', description: 'Manage Bulldrome gear and upgrades.' }, + { id: 'settings', label: 'Settings', glyph: 'S', description: 'Tune action mode controls.' }, +] + +export function ActionModeScreen({ onBack }: ActionModeScreenProps) { + const [character, setCharacter] = useState(() => loadActionCharacter()) + const [activeDifficulty, setActiveDifficulty] = useState(null) + const [activeDungeonId, setActiveDungeonId] = useState('bulldrome') + const [activeRunMode, setActiveRunMode] = useState('hunt') + const [activeScreen, setActiveScreen] = useState('menu') + const [lastReward, setLastReward] = useState(null) + const [runKey, setRunKey] = useState(0) + const [message, setMessage] = useState('') + const progress = useMemo(() => getActionLevelProgress(character), [character]) + + useEffect(() => { + saveActionCharacter(character) + }, [character]) + + if (activeDifficulty) { + return ( +
+ { + setLastReward(null) + setActiveDifficulty(null) + setActiveScreen('dungeons') + }} + onRunComplete={() => { + if (lastReward && activeRunMode === 'hunt') return + const { character: nextCharacter, reward } = completeActionDungeonHunt(character, activeDungeonId, activeDifficulty) + setCharacter(nextCharacter) + if (activeRunMode === 'hunt') setLastReward(reward) + setMessage('') + }} + /> + {lastReward && ( + { + setLastReward(null) + setRunKey((current) => current + 1) + }} + onMainMenu={() => { + setLastReward(null) + setActiveDifficulty(null) + setActiveScreen('menu') + }} + /> + )} +
+ ) + } + + return ( +
+
+
+
+

Action Mode

+

{getHubTitle(activeScreen)}

+
+
+
+ {character.name} + Healer + Level {character.level} +
+ +
+
+ {(activeScreen !== 'menu' || onBack) && ( + + )} +
+
+ + {activeScreen === 'menu' && ( + + )} + + {message &&

{message}

} + + {activeScreen === 'dungeons' && ( + { + setActiveRunMode('hunt') + setLastReward(null) + setActiveDungeonId(dungeonId) + setRunKey((current) => current + 1) + setActiveDifficulty(difficulty) + }} + onStartMarathon={(dungeonId, difficulty) => { + setActiveRunMode('marathon') + setLastReward(null) + setActiveDungeonId(dungeonId) + setRunKey((current) => current + 1) + setActiveDifficulty(difficulty) + }} + /> + )} + + {activeScreen === 'customize' && ( + { + const before = character.inventory.find((item) => item.id === itemId) + const nextCharacter = upgradeBulldromeGear(character, itemId) + const coinCount = before ? getUpgradeCoinCount(character, before) : 0 + setCharacter(nextCharacter) + setMessage( + before && before.itemLevel < 5 && coinCount >= ACTION_GEAR_UPGRADE_COST + ? `${before.name} upgraded to item level ${before.itemLevel + 1}.` + : 'Upgrade unavailable.', + ) + }} + /> + )} + + {activeScreen === 'settings' && ( + + )} + + {activeScreen !== 'menu' && activeScreen !== 'dungeons' && activeScreen !== 'customize' && activeScreen !== 'settings' && ( +
+

{getHubTitle(activeScreen)}

+

Coming Soon

+

This section has its own Action Mode button now. Content hooks can land here without touching Normal Mode.

+
+ )} +
+
+ ) +} + +function ActionMechanicsAdmin() { + const [config, setConfig] = useState(() => loadActionMechanicConfig()) + const [enemyPage, setEnemyPage] = useState(0) + const [attackPageByEnemy, setAttackPageByEnemy] = useState>>({}) + + function updateAttack(enemyKind: EnemyKind, attackId: string, patch: Partial) { + const next = { + ...config, + [enemyKind]: { + ...config[enemyKind], + attacks: config[enemyKind].attacks.map((attack) => ( + attack.id === attackId ? { ...attack, ...patch } : attack + )), + }, + } + setConfig(next) + saveActionMechanicConfig(next) + } + + function resetConfig() { + setConfig(resetActionMechanicConfig()) + setEnemyPage(0) + setAttackPageByEnemy({}) + } + + const enemyKinds = Object.keys(config) as EnemyKind[] + const enemyKind = enemyKinds[Math.min(enemyPage, enemyKinds.length - 1)] ?? enemyKinds[0] + const enemy = config[enemyKind] + const enabledAttacks = enemy.attacks.filter((attack) => attack.enabled) + const disabledAttacks = enemy.attacks.filter((attack) => !attack.enabled) + const attackPage = Math.min(attackPageByEnemy[enemyKind] ?? 0, Math.max(0, enabledAttacks.length - 1)) + const activeAttack = enabledAttacks[attackPage] ?? null + + function setAttackPage(enemyKind: EnemyKind, page: number) { + setAttackPageByEnemy((current) => ({ ...current, [enemyKind]: page })) + } + + return ( +
+
+
+

Admin

+

Mob And Boss Mechanics

+
+ +
+ +
+
+
+

{enemy.role}

+

{enemy.label}

+
+ {enemyPage + 1} / {enemyKinds.length} +
+ +
+ + +
+ + {activeAttack ? ( +
+
+ + {attackPage + 1} / {enabledAttacks.length} + +
+ updateAttack(enemyKind, activeAttack.id, patch)} + /> +
+ ) : ( +

No active attacks.

+ )} + + {disabledAttacks.length > 0 && ( +
+ Add Attack + {disabledAttacks.map((attack) => ( + + ))} +
+ )} +
+
+ ) +} + +function AttackEditor({ + attack, + onChange, +}: { + attack: ActionAttackConfig + onChange: (patch: Partial) => void +}) { + return ( +
+
+
+ {attack.label} + {attack.kind} +
+ +
+ +
+ + + {attack.windupSeconds !== undefined && ( + + )} + {attack.recoverSeconds !== undefined && ( + + )} + {attack.speed !== undefined && ( + + )} + {attack.radius !== undefined && ( + + )} + {attack.everyNthCharge !== undefined && ( + + )} +
+
+ ) +} + +function DungeonsPanel({ + character, + onStart, + onStartMarathon, +}: { + character: ActionCharacter + onStart: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void + onStartMarathon: (dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) => void +}) { + const [selectedDungeonId, setSelectedDungeonId] = useState('bulldrome') + const [selectedDifficulty, setSelectedDifficulty] = useState('ilvl-1') + const selectedDungeon = ACTION_DUNGEONS.find((dungeon) => dungeon.id === selectedDungeonId) ?? ACTION_DUNGEONS[0] + const selectedTier = getActionDifficultyTier(selectedDifficulty) + const selectedCoinCount = getActionCoinCount(character, selectedDungeon.source, selectedTier.coinColor) + + return ( +
+
+ {ACTION_DUNGEONS.map((dungeon) => { + const selected = dungeon.id === selectedDungeonId + const locked = dungeon.locked + return ( + + ) + })} +
+ + +
+ ) +} + +const ACTION_DUNGEONS: Array<{ + id: ActionDungeonId + eyebrow: string + glyph: string + name: string + summary: string + description: string + source: ActionGearSource + coinLabel: string + locked?: boolean +}> = [ + { + id: 'bulldrome', + eyebrow: 'Dungeon 1', + glyph: 'B', + name: 'Bulldrome Hunting Grounds', + summary: 'Charges, slams, and Bullfango pressure.', + description: 'Bulldrome drops White Bulldrome Coins and item level 1 Bulldrome gear.', + source: 'bulldrome', + coinLabel: 'White Bulldrome Coins', + }, + { + id: 'yian-kut-ku', + eyebrow: 'Dungeon 2', + glyph: 'Y', + name: 'Yian Kut-Ku Roost', + summary: 'Bouncing fireballs and dive birds.', + description: 'Yian Kut-Ku fireballs bounce until the next cast and leave fire on walls or players.', + source: 'yian-kut-ku', + coinLabel: 'White Yian Kut-Ku Coins', + }, + { + id: 'rathian', + eyebrow: 'Dungeon 3', + glyph: 'R', + name: 'Rathian Nest', + summary: 'Coming next.', + description: 'Rathian mechanics will be built after Yian Kut-Ku.', + source: 'bulldrome', + coinLabel: 'Rathian Coins', + locked: true, + }, +] + +function RunRewardModal({ + dungeonId, + difficulty, + onGoAgain, + onMainMenu, + reward, +}: { + dungeonId: PlayableActionDungeonId + difficulty: ActionDifficulty + onGoAgain: () => void + onMainMenu: () => void + reward: ActionRunReward +}) { + return ( +
+
+

Dungeon Complete

+

+ {getRunRewardTitle(dungeonId, difficulty)} +

+
+
+
XP
+
{reward.experience}
+
+
+
{reward.coinName}
+
{reward.coins}
+
+
+
Level
+
{reward.leveledUp ? 'Up' : 'No Change'}
+
+
+
+ Loot + {reward.gear.length === 0 ? ( +

No gear dropped.

+ ) : ( + reward.gear.map((item) => ( + {item.name} · ilvl {item.itemLevel} + )) + )} +
+
+ + +
+
+
+ ) +} + +function CustomizePanel({ + character, + onUpgrade, +}: { + character: ActionCharacter + onUpgrade: (itemId: string) => void +}) { + const [selectedSlot, setSelectedSlot] = useState('weapon') + const slotMeta = ACTION_GEAR_SLOTS.find((slot) => slot.slot === selectedSlot) ?? ACTION_GEAR_SLOTS[0] + const filteredItems = useMemo(() => ( + character.inventory + .filter((item) => item.slot === selectedSlot) + .sort((a, b) => b.itemLevel - a.itemLevel || a.name.localeCompare(b.name)) + ), [character.inventory, selectedSlot]) + const [selectedItemId, setSelectedItemId] = useState(null) + const selectedItem = filteredItems.find((item) => item.id === selectedItemId) ?? filteredItems[0] ?? null + + return ( +
+
+

Craft Table

+

Bulldrome Gear

+

+ Pick a gear slot, inspect that slot inventory, then preview the upgrade + cost and stat gain before spending coins. +

+
+ +
+
+ {ACTION_GEAR_SLOTS.map((slot) => { + const count = character.inventory.filter((item) => item.slot === slot.slot).length + return ( + + ) + })} +
+ + +
+
+ ) +} + +function GearDetail({ + coins, + item, + onUpgrade, +}: { + coins: number + item: ActionGearPiece | null + onUpgrade: () => void +}) { + if (!item) { + return ( +
+

Select a slot with gear to see stats and upgrade costs.

+
+ ) + } + + const currentStats = getActionGearStats(item) + const nextItem = { ...item, itemLevel: Math.min(5, item.itemLevel + 1) } + const nextStats = getActionGearStats(nextItem) + const coinName = getUpgradeCoinName(item) + const canUpgrade = coins >= ACTION_GEAR_UPGRADE_COST && item.itemLevel < 5 + + return ( +
+
+
+

Selected

+

{item.name}

+
+ ilvl {item.itemLevel} +
+
+
+
Healing
+
{currentStats.healingPower} → {nextStats.healingPower}
+
+
+
Stamina
+
{currentStats.stamina} → {nextStats.stamina}
+
+
+
Upgrade Cost
+
{item.itemLevel >= 5 ? 'Max' : `${ACTION_GEAR_UPGRADE_COST} ${coinName}`}
+
+
+
You Have
+
{coins} {coinName}
+
+
+ +
+ ) +} + +function getHubTitle(screen: ActionHubScreen) { + if (screen === 'menu') return 'Action Mode' + return ACTION_HUB_ITEMS.find((item) => item.id === screen)?.label ?? 'Action Mode' +} + +function getRunRewardTitle(dungeonId: PlayableActionDungeonId, difficulty: ActionDifficulty) { + const dungeonName = dungeonId === 'yian-kut-ku' ? 'Yian Kut-Ku Hunt' : 'Bulldrome Hunt' + return `${getActionDifficultyTier(difficulty).label} ${dungeonName}` +} diff --git a/src/components/BulldromeBossSlice.tsx b/src/components/BulldromeBossSlice.tsx new file mode 100644 index 0000000..1ec47ee --- /dev/null +++ b/src/components/BulldromeBossSlice.tsx @@ -0,0 +1,346 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import Phaser from 'phaser' +import { BulldromeScene } from '../actionBoss/BulldromeScene' +import { + getActionDifficultyTier, + type ActionDifficulty, + type ActionRunMode, +} from '../actionMode' +import { + createBulldromeState, + getEncounterHp, + getEncounterTitle, + getEnemyFrames, + getRaidFrames, + SPELLS, + type BulldromeState, + type ActionDungeonId, + type EnemyFrame, + type RaidFrame, + type SpellDefinition, + type SpellSlot, +} from '../actionBoss/bulldromeSimulation' + +type BulldromeBossSliceProps = { + dungeonId?: ActionDungeonId + difficulty?: ActionDifficulty + runMode?: ActionRunMode + onExit: () => void + onRunComplete?: () => void +} + +function getRunTitle(dungeonId: ActionDungeonId, difficulty: ActionDifficulty, runMode: ActionRunMode) { + const suffix = runMode === 'marathon' ? 'Marathon' : 'Hunt' + const tier = getActionDifficultyTier(difficulty).label + if (dungeonId === 'yian-kut-ku') return `${tier} Yian Kut-Ku ${suffix}` + return `${tier} Bulldrome ${suffix}` +} + +export function BulldromeBossSlice({ + dungeonId = 'bulldrome', + difficulty = 'ilvl-1', + runMode = 'hunt', + onExit, + onRunComplete, +}: BulldromeBossSliceProps) { + const mountRef = useRef(null) + const gameRef = useRef(null) + const sceneRef = useRef(null) + const completionSentRef = useRef(false) + const rewardedBossKillsRef = useRef(0) + const [state, setState] = useState(() => createBulldromeState(difficulty, dungeonId, runMode)) + const raidFrames = useMemo(() => getRaidFrames(state), [state]) + const enemyFrames = useMemo(() => getEnemyFrames(state), [state]) + const encounterHp = useMemo(() => getEncounterHp(state), [state]) + const encounterTitle = useMemo(() => getEncounterTitle(state), [state]) + + const resultLabel = useMemo(() => { + if (state.result === 'win') return 'Hunt Complete' + if (state.result === 'loss') return 'Carted' + if (state.encounterStep === 'trash') return 'Bullfangos' + return state.boss.phase === 'slamWindup' + ? 'Slam' + : state.boss.phase === 'mauling' + ? 'Tank' + : state.boss.phase === 'windup' + ? 'Dodge' + : state.boss.phase === 'recovering' + ? 'Punish' + : 'Fight' + }, [state.boss.phase, state.encounterStep, state.result]) + + useEffect(() => { + if (!mountRef.current || gameRef.current) return + + const scene = new BulldromeScene({ difficulty, dungeonId, runMode, onStateChange: setState }) + sceneRef.current = scene + + const game = new Phaser.Game({ + type: Phaser.CANVAS, + parent: mountRef.current, + width: 960, + height: 540, + backgroundColor: '#11151c', + scale: { + mode: Phaser.Scale.FIT, + autoCenter: Phaser.Scale.CENTER_BOTH, + }, + scene: [scene], + }) + + gameRef.current = game + + return () => { + game.destroy(true) + gameRef.current = null + sceneRef.current = null + } + }, [difficulty, dungeonId, runMode]) + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.repeat) return + + if (event.key === 'ArrowUp' || event.key === 'ArrowDown') { + event.preventDefault() + const selectedIndex = Math.max(0, raidFrames.findIndex((frame) => frame.selected)) + const delta = event.key === 'ArrowDown' ? 1 : -1 + const nextFrame = raidFrames[(selectedIndex + delta + raidFrames.length) % raidFrames.length] + if (nextFrame) sceneRef.current?.selectTarget(nextFrame.id) + } + + if (['1', '2', '3', '4', '5'].includes(event.key)) { + event.preventDefault() + sceneRef.current?.castSpell(Number(event.key) as SpellSlot) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [raidFrames]) + + useEffect(() => { + if (runMode === 'marathon') { + if (state.bossKills <= rewardedBossKillsRef.current) return + rewardedBossKillsRef.current = state.bossKills + onRunComplete?.() + return + } + + if (state.result !== 'win' || completionSentRef.current) return + completionSentRef.current = true + onRunComplete?.() + }, [onRunComplete, runMode, state.bossKills, state.result]) + + return ( +
+
+
+
+

Action Boss Prototype

+

{getRunTitle(dungeonId, difficulty, runMode)}

+
+ +
+ +
+ +
+
+ {encounterTitle} + {Math.ceil(encounterHp.hp)} / {encounterHp.maxHp} + + + +
+ {state.player.currentCast && ( +
+
+ {SPELLS[state.player.currentCast.spell].name} + {state.player.currentCast.remaining.toFixed(1)}s +
+ + + +
+ )} +
+
+ +
+
+
+ ) +} + +function EnemyRow({ enemy }: { enemy: EnemyFrame }) { + const percent = Math.max(0, Math.min(100, (enemy.hp / enemy.maxHp) * 100)) + + return ( +
+
+ {enemy.name} + {Math.ceil(enemy.hp)} / {enemy.maxHp} +
+ + + +
+ ) +} + +function PartyFrame({ + frame, + onSelect, +}: { + frame: RaidFrame + onSelect: () => void +}) { + const percent = Math.max(0, Math.min(100, (frame.hp / frame.maxHp) * 100)) + const shieldPercent = Math.max(0, Math.min(100 - percent, (frame.shield / frame.maxHp) * 100)) + + return ( + + ) +} + +function SpellButton({ + cooldown, + onCast, + spell, +}: { + cooldown: number + onCast: () => void + spell: SpellDefinition +}) { + const cooldownPercent = spell.cooldown > 0 + ? Math.max(0, Math.min(100, (cooldown / spell.cooldown) * 100)) + : 0 + + return ( + + ) +} + +function Meter({ + label, + max, + tone, + value, +}: { + label: string + max: number + tone: 'player' | 'boss' + value: number +}) { + const percent = Math.max(0, Math.min(100, (value / max) * 100)) + + return ( +
+
+ {label} + {Math.ceil(value)} / {max} +
+ + + +
+ ) +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..4542b2a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { ActionModeScreen } from './components/ActionModeScreen' +import './styles.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..59d5822 --- /dev/null +++ b/src/styles.css @@ -0,0 +1,2171 @@ +@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&family=VT323&display=swap'); + +:root { + --ink: #f4eed8; + --muted: #a89f87; + --panel: #191b22; + --panel-light: #242630; + --edge: #565066; + --gold: #e5b95f; + --red: #a73543; + --red-bright: #dc5162; + --green: #3f9a66; + --blue: #3477bb; + --purple: #8e68c4; +} + +* { + box-sizing: border-box; +} + +.sr-only { + height: 1px; + margin: -1px; + overflow: hidden; + position: absolute; + width: 1px; +} + +button { + font: inherit; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +textarea:focus-visible, +[tabindex]:focus-visible { + box-shadow: 0 0 0 5px #8b6726, 0 0 18px rgba(229, 185, 95, 0.65); + outline: 3px solid #fff4a8 !important; +} + +html { + background: #07080b; +} + +body { + background: + radial-gradient(circle at 20% 0%, rgba(229, 185, 95, 0.08), transparent 26%), + linear-gradient(180deg, #0d0f15 0%, #07080b 100%); + background-color: #07080b; + color: var(--ink); + margin: 0; + min-height: 100vh; + min-width: 320px; + overflow: hidden; +} + +#root { + background: transparent; + height: 100dvh; + overflow: hidden; +} +.game-shell { + display: flex; + flex-direction: column; + height: 100dvh; + width: min(1180px, calc(100% - 28px)); + margin: 0 auto; + overflow: hidden; + padding: 12px 0; + position: relative; +} + +.header-xp { + background: #090a0d; + height: 5px; + width: 100px; +} + +.header-xp span, +.experience-bar span { + background: var(--purple); + box-shadow: inset 0 3px #b08bdf; + display: block; + height: 100%; +} + +h1, +h2, +p { + margin: 0; +} + +h1, +h2 { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + line-height: 1.45; +} + +h1 { + font-size: clamp(18px, 3vw, 28px); +} + +h2 { + font-size: 14px; +} + +.eyebrow { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + letter-spacing: 1px; + margin-bottom: 8px; + text-transform: uppercase; +} + +.menu-screen, +.content-screen, +.message-panel { + background: var(--panel); + border: 3px solid #0c0d11; + box-shadow: 7px 7px 0 #08090c; + flex: 1; + margin-top: 12px; + min-height: 0; + outline: 2px solid var(--edge); + overflow: hidden; + padding: 28px; +} + +.content-screen { + display: flex; + flex-direction: column; +} + +.dungeon-run-screen { + gap: 14px; + margin-top: 0; +} +.action-mode-shell { + width: min(1180px, calc(100% - 28px)); +} + +.action-mode-screen { + flex: 1; + gap: 18px; + margin-top: 0; + min-height: 0; +} + +.action-screen-heading { + gap: 16px; +} + +.action-heading-meta { + align-items: center; + display: flex; + gap: 14px; +} + +.action-character-strip { + align-items: center; + display: flex; + gap: 10px; + justify-content: flex-end; + text-align: right; +} + +.action-character-strip strong, +.action-character-strip small { + font-family: 'Press Start 2P', monospace; +} + +.action-character-strip strong { + color: var(--ink); + font-size: 8px; +} + +.action-character-strip small { + color: var(--muted); + font-size: 7px; +} + +.action-mode-message { + background: #111319; + border: 2px solid #090a0d; + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #41404a; + padding: 12px; +} + +.action-run-shell { + min-height: 100dvh; + position: relative; +} + +.action-run-reward-backdrop { + align-items: center; + background: rgba(5, 6, 9, 0.78); + display: flex; + inset: 0; + justify-content: center; + padding: 18px; + position: fixed; + z-index: 30; +} + +.action-run-reward-modal { + background: var(--panel); + border: 3px solid #090a0d; + box-shadow: 8px 8px 0 #050609; + display: grid; + gap: 16px; + max-width: 560px; + outline: 2px solid var(--gold); + padding: 24px; + width: min(560px, 100%); +} + +.action-run-reward-summary { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.action-run-reward-summary div, +.action-run-loot-list { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 8px; + padding: 12px; +} + +.action-run-reward-summary dt, +.action-run-reward-summary dd, +.action-run-loot-list strong, +.action-run-loot-list span, +.action-run-reward-actions button { + font-family: 'Press Start 2P', monospace; +} + +.action-run-reward-summary dt { + color: var(--muted); + font-size: 7px; +} + +.action-run-reward-summary dd { + color: var(--ink); + font-size: 10px; + margin: 0; +} + +.action-run-loot-list strong { + color: var(--gold); + font-size: 8px; +} + +.action-run-loot-list p { + color: var(--muted); + font-size: 20px; + margin: 0; +} + +.action-run-loot-list span { + color: var(--ink); + font-size: 7px; + line-height: 1.4; +} + +.action-run-reward-actions { + display: grid; + gap: 10px; + grid-template-columns: 1fr 1fr; +} + +.action-mode-grid { + display: grid; + flex: 1; + gap: 16px; + grid-template-columns: minmax(0, 1.25fr) minmax(220px, 0.8fr) minmax(220px, 0.8fr); + min-height: 0; +} + +.action-dungeon-list { + display: grid; + gap: 16px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.action-dungeon-actions { + display: grid; + gap: 10px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin-top: auto; +} + +.action-dungeon-actions button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 7px; + min-height: 42px; + outline: 2px solid #4b4855; +} + +.action-dungeon-actions button:disabled, +.action-dungeon-card.locked { + opacity: 0.62; +} + +.action-hub-nav { + display: grid; + gap: 15px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0 auto; + max-width: 930px; + width: 100%; +} + +.action-hub-nav .menu-card:first-child { + grid-column: auto; +} + +.action-hub-nav span, +.action-hub-nav strong, +.action-gear-slot header, +.action-gear-row strong, +.action-gear-row small, +.action-gear-row button { + font-family: 'Press Start 2P', monospace; +} + +.action-hub-nav span { + font-size: 19px; +} + +.action-hub-nav strong { + font-size: 11px; + line-height: 1.25; +} + +.action-dungeon-card, +.action-difficulty-card { + background: #111319; + border: 2px solid #090a0d; + display: flex; + flex-direction: column; + gap: 16px; + outline: 2px solid #41404a; + padding: 18px; +} + +.action-dungeon-card p:not(.eyebrow), +.action-difficulty-card p:not(.eyebrow) { + color: var(--muted); + font-size: 20px; + line-height: 1.15; + margin-top: 8px; +} + +.action-dungeon-card dl { + display: grid; + gap: 10px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: auto 0 0; +} + +.action-dungeon-card dl div { + background: #171922; + border: 2px solid #090a0d; + display: grid; + gap: 7px; + padding: 12px; +} + +.action-dungeon-card dt, +.action-dungeon-card dd { + font-family: 'Press Start 2P', monospace; + margin: 0; +} + +.action-dungeon-card dt { + color: var(--muted); + font-size: 6px; +} + +.action-dungeon-card dd { + color: var(--ink); + font-size: 10px; +} + +.action-difficulty-card .primary-button { + margin-top: auto; +} + +.action-difficulty-card.hard { + outline-color: #6f4f25; +} + +.action-dungeon-board { + display: grid; + gap: 14px; + grid-template-columns: minmax(260px, 0.65fr) minmax(420px, 1.35fr); + min-height: 0; +} + +.action-dungeon-board .action-dungeon-list { + align-content: start; + display: grid; + gap: 8px; + grid-template-columns: 1fr; +} + +.action-dungeon-board .action-dungeon-card { + align-items: center; + background: #111319; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 10px; + grid-template-columns: 44px minmax(0, 1fr); + min-height: 76px; + outline: 2px solid #41404a; + padding: 10px; + text-align: left; +} + +.action-dungeon-board .action-dungeon-card.selected { + outline-color: var(--gold); +} + +.action-dungeon-board .action-dungeon-card.locked { + cursor: not-allowed; + opacity: 0.55; +} + +.action-dungeon-glyph { + align-items: center; + background: #171922; + border: 2px solid #090a0d; + color: var(--gold); + display: flex; + font-family: 'Press Start 2P', monospace; + font-size: 14px; + height: 44px; + justify-content: center; +} + +.action-dungeon-board .action-dungeon-card > span:last-child { + display: grid; + gap: 5px; + min-width: 0; +} + +.action-dungeon-board .action-dungeon-card small, +.action-dungeon-board .action-dungeon-card strong, +.action-dungeon-board .action-dungeon-card i, +.action-dungeon-selected dt, +.action-dungeon-selected dd, +.action-tier-grid button, +.action-dungeon-start button { + font-family: 'Press Start 2P', monospace; +} + +.action-dungeon-board .action-dungeon-card small { + color: var(--gold); + font-size: 6px; +} + +.action-dungeon-board .action-dungeon-card strong { + font-size: 8px; + line-height: 1.25; +} + +.action-dungeon-board .action-dungeon-card i { + color: var(--muted); + font-size: 6px; + font-style: normal; + line-height: 1.35; +} + +.action-dungeon-setup { + display: grid; + gap: 10px; + grid-template-columns: minmax(0, 1fr); +} + +.action-dungeon-selected, +.action-dungeon-tier, +.action-dungeon-start { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 12px; + outline: 2px solid #41404a; + padding: 14px; +} + +.action-dungeon-selected p:not(.eyebrow), +.action-dungeon-start p { + color: var(--muted); + font-size: 18px; + line-height: 1.15; + margin: 0; +} + +.action-dungeon-selected dl { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + margin: 0; +} + +.action-dungeon-selected dl div { + background: #171922; + border: 2px solid #090a0d; + display: grid; + gap: 6px; + padding: 9px; +} + +.action-dungeon-selected dt, +.action-dungeon-selected dd { + margin: 0; +} + +.action-dungeon-selected dt { + color: var(--muted); + font-size: 6px; +} + +.action-dungeon-selected dd { + color: var(--ink); + font-size: 9px; +} + +.action-tier-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.action-tier-grid button { + background: #171922; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 7px; + outline: 2px solid #30313a; + padding: 12px; + text-align: left; +} + +.action-tier-grid button.selected { + outline-color: var(--gold); +} + +.action-tier-grid button.coin-white.selected { + outline-color: #f4eed8; +} + +.action-tier-grid button.coin-green.selected { + outline-color: #7dff9d; +} + +.action-tier-grid button.coin-blue.selected { + outline-color: #5d91ff; +} + +.action-tier-grid button.coin-purple.selected { + outline-color: #b884ff; +} + +.action-tier-grid strong { + font-size: 9px; +} + +.action-tier-grid span { + color: var(--muted); + font-size: 7px; +} + +.action-mechanics-admin { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 14px; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + outline: 2px solid #41404a; + padding: 16px; +} + +.action-mechanics-admin > header, +.action-mechanic-heading, +.action-attack-editor header { + align-items: center; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.action-mechanics-admin > p { + color: var(--muted); + font-size: 18px; + line-height: 1.15; + margin: 0; +} + +.action-mechanic-card { + background: #171922; + border: 2px solid #090a0d; + display: grid; + gap: 12px; + grid-template-rows: auto auto minmax(0, 1fr) auto; + min-height: 0; + outline: 2px solid #30313a; + padding: 12px; +} + +.action-mechanic-heading span, +.action-disabled-attacks strong, +.action-disabled-attacks button, +.action-attack-editor button, +.action-attack-editor strong, +.action-attack-editor small, +.action-attack-fields label { + font-family: 'Press Start 2P', monospace; +} + +.action-mechanic-heading h3 { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 10px; + line-height: 1.35; + margin: 0; +} + +.action-mechanic-heading span { + color: var(--gold); + font-size: 7px; +} + +.action-attack-list { + display: grid; + gap: 10px; + min-height: 0; +} + +.action-page-controls { + align-items: center; + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.action-attack-list .action-page-controls { + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); +} + +.action-page-controls button, +.action-page-controls span { + font-family: 'Press Start 2P', monospace; +} + +.action-page-controls button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-size: 6px; + min-height: 32px; + outline: 2px solid #4b4855; +} + +.action-page-controls button:disabled { + color: var(--muted); + cursor: default; + opacity: 0.55; +} + +.action-page-controls span { + color: var(--gold); + font-size: 7px; + text-align: center; + white-space: nowrap; +} + +.action-empty-note { + color: var(--muted); + font-size: 18px; +} + +.action-attack-editor { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 10px; + padding: 10px; +} + +.action-attack-editor strong { + color: var(--ink); + display: block; + font-size: 8px; + line-height: 1.35; +} + +.action-attack-editor small { + color: var(--muted); + display: block; + font-size: 6px; + margin-top: 5px; +} + +.action-attack-editor button, +.action-disabled-attacks button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-size: 6px; + padding: 8px; +} + +.action-attack-fields { + display: grid; + gap: 8px; + grid-template-columns: repeat(3, minmax(0, 1fr)); +} + +.action-attack-fields label { + color: var(--muted); + display: grid; + font-size: 6px; + gap: 5px; + line-height: 1.35; +} + +.action-attack-fields input { + background: #050609; + border: 2px solid #30313a; + color: var(--ink); + font: inherit; + min-width: 0; + padding: 7px; +} + +.action-disabled-attacks { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.action-disabled-attacks strong { + align-self: center; + color: var(--gold); + font-size: 7px; +} + +.action-placeholder-panel, +.action-gear-panel { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 16px; + grid-template-rows: auto minmax(0, 1fr); + min-height: 0; + outline: 2px solid #41404a; + padding: 18px; +} + +.action-placeholder-panel p:not(.eyebrow), +.action-gear-panel p:not(.eyebrow) { + color: var(--muted); + font-size: 20px; + line-height: 1.15; + margin: 0; +} + +.action-gear-summary { + display: grid; + gap: 6px; +} + +.action-customize-layout { + display: grid; + gap: 14px; + grid-template-columns: minmax(270px, 0.9fr) minmax(340px, 1.1fr); + min-height: 0; +} + +.action-slot-grid { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + min-height: 0; +} + +.action-slot-grid button, +.action-inventory-list button, +.action-gear-detail, +.action-inventory-panel { + background: #171922; + border: 2px solid #090a0d; + color: var(--ink); + outline: 2px solid #30313a; +} + +.action-slot-grid button { + cursor: pointer; + display: grid; + gap: 6px; + min-height: 64px; + padding: 10px; + text-align: left; +} + +.action-slot-grid button.selected, +.action-slot-grid button:hover, +.action-inventory-list button.selected, +.action-inventory-list button:hover { + outline-color: var(--gold); +} + +.action-slot-grid span { + color: var(--gold); + font-family: 'Press Start 2P', monospace; + font-size: 10px; +} + +.action-slot-grid strong, +.action-slot-grid small, +.action-inventory-panel > header span, +.action-inventory-list strong, +.action-inventory-list small, +.action-gear-detail header span, +.action-stat-compare dt, +.action-stat-compare dd, +.action-gear-detail button { + font-family: 'Press Start 2P', monospace; +} + +.action-slot-grid strong, +.action-inventory-list strong { + font-size: 7px; + line-height: 1.35; +} + +.action-slot-grid small, +.action-inventory-list small { + color: var(--muted); + font-size: 6px; +} + +.action-inventory-panel { + display: grid; + gap: 12px; + grid-template-rows: auto minmax(92px, 0.75fr) auto; + min-height: 0; + padding: 14px; +} + +.action-inventory-panel > header, +.action-gear-detail header { + align-items: start; + display: flex; + gap: 12px; + justify-content: space-between; +} + +.action-inventory-panel > header span, +.action-gear-detail header span { + color: var(--gold); + font-size: 7px; +} + +.action-inventory-list { + display: grid; + gap: 8px; + min-height: 0; + overflow: auto; +} + +.action-inventory-list > p { + color: var(--muted); + font-size: 20px; + margin: 0; +} + +.action-inventory-list button { + cursor: pointer; + display: flex; + gap: 10px; + justify-content: space-between; + min-height: 44px; + padding: 10px; + text-align: left; +} + +.action-gear-detail { + display: grid; + gap: 12px; + padding: 12px; +} + +.action-gear-detail.empty { + align-content: center; +} + +.action-stat-compare { + display: grid; + gap: 8px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + margin: 0; +} + +.action-stat-compare div { + background: #111319; + border: 2px solid #090a0d; + display: grid; + gap: 6px; + padding: 8px; +} + +.action-stat-compare dt { + color: var(--muted); + font-size: 6px; +} + +.action-stat-compare dd { + color: var(--ink); + font-size: 7px; + margin: 0; +} + +.action-gear-detail button { + background: #242630; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + font-size: 6px; + min-height: 38px; + outline: 2px solid #4b4855; +} + +.action-gear-detail button:disabled { + color: var(--muted); + cursor: not-allowed; + opacity: 0.65; +} + +.menu-card { + align-items: center; + background: var(--panel-light); + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: flex; + gap: 16px; + min-height: 105px; + outline: 2px solid #42414c; + padding: 16px; + text-align: left; +} + +.menu-card:first-child { + grid-column: 1 / -1; +} + +.mode-select-grid > .mode-select-card:first-child { + grid-column: auto; +} + +.menu-card:hover { + outline-color: var(--gold); + transform: translateY(-2px); +} + +.cloud-sync-card { + cursor: default; + justify-content: space-between; +} + +.cloud-sync-card:hover { + outline-color: #42414c; + transform: none; +} + +.cloud-sync-card > div { + display: grid; + flex: 1; + gap: 6px; +} + +.cloud-sync-card .text-button:disabled { + opacity: 0.7; +} + +.cloud-sync-message { + color: var(--gold); +} + +.menu-card > span, +.class-portrait { + align-items: center; + background: #13141a; + border: 2px solid var(--gold); + color: var(--gold); + display: flex; + flex: 0 0 58px; + font-family: 'Press Start 2P', monospace; + font-size: 19px; + height: 58px; + justify-content: center; +} + +.menu-card strong, +.menu-card small { + display: block; +} + +.menu-card strong { + font-family: 'Press Start 2P', monospace; + font-size: 11px; + margin-bottom: 9px; +} + +.menu-card small { + color: var(--muted); + font-size: 18px; + line-height: 1.05; +} + +.screen-heading { + align-items: center; + border-bottom: 2px solid #34343d; + display: flex; + justify-content: space-between; + padding-bottom: 14px; +} + +.back-button, +.text-button { + background: #15161c; + border: 2px solid #090a0d; + color: var(--muted); + cursor: pointer; + outline: 2px solid #464550; + padding: 9px 13px; +} + +.back-button:hover, +.text-button:hover { + color: var(--ink); + outline-color: var(--gold); +} + +.boss-slice-shell { + height: 100dvh; + margin: 0 auto; + overflow: hidden; + padding: 12px; + width: min(1360px, 100%); +} + +.boss-slice-stage { + background: var(--panel); + border: 3px solid #0c0d11; + box-shadow: 7px 7px 0 #08090c; + display: flex; + flex-direction: column; + gap: 14px; + height: 100%; + outline: 2px solid var(--edge); + overflow: hidden; + padding: 18px; +} + +.boss-slice-heading { + align-items: center; + border-bottom: 2px solid #34343d; + display: flex; + flex: 0 0 auto; + justify-content: space-between; + padding-bottom: 14px; +} + +.boss-slice-layout { + display: grid; + flex: 1; + column-gap: 0; + grid-template-columns: 184px minmax(0, 1fr) minmax(260px, 320px); + min-height: 0; + row-gap: 16px; +} + +.boss-playfield-panel { + background: #08090c; + border: 2px solid #090a0d; + min-height: 0; + outline: 2px solid #41404a; + overflow: hidden; + position: relative; +} + +.boss-window-bossbar { + left: 50%; + position: absolute; + top: 12px; + transform: translateX(-50%); + width: min(440px, calc(100% - 40px)); + z-index: 3; +} + +.boss-window-bossbar strong, +.boss-window-bossbar span { + color: var(--ink); + font-family: 'Press Start 2P', monospace; + font-size: 8px; +} + +.boss-window-bossbar strong { + display: block; + text-align: center; +} + +.boss-window-bossbar span { + color: var(--muted); + display: block; + margin-top: 4px; + text-align: center; +} + +.boss-window-bossbar i { + background: #090a0d; + border: 2px solid #090a0d; + display: block; + height: 16px; + margin-top: 5px; +} + +.boss-window-bossbar b { + background: var(--red-bright); + box-shadow: inset 0 4px #ff8b98; + display: block; + height: 100%; + transition: width 120ms linear; +} + +.boss-party-frames { + background: rgba(17, 19, 25, 0.94); + border: 2px solid #090a0d; + display: flex; + flex-direction: column; + gap: 9px; + min-height: 0; + outline: 1px solid #41404a; + overflow: auto; + padding: 6px; + width: 184px; +} + +.boss-party-heading { + border-bottom: 2px solid #34343d; + display: grid; + gap: 3px; + padding-bottom: 9px; +} + +.boss-party-heading span { + color: var(--muted); + font-size: 16px; +} + +.party-frame { + background: #171922; + border: 1px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 5px; + min-height: 58px; + outline: 1px solid #30313a; + padding: 8px 4px; + text-align: left; +} + +.party-frame:hover, +.party-frame.selected { + outline-color: var(--gold); +} + +.party-frame.dead { + opacity: 0.55; +} + +.party-frame strong, +.party-frame small, +.party-frame em, +.role-chip { + font-family: 'Press Start 2P', monospace; +} + +.party-frame strong { + font-size: 8px; +} + +.party-frame small, +.party-frame em { + color: var(--muted); + font-size: 6px; + font-style: normal; +} + +.party-frame em { + color: #7fd89d; +} + +.party-frame i { + background: #090a0d; + display: block; + height: 9px; + overflow: hidden; + position: relative; +} + +.party-health-fill, +.party-shield-fill { + display: block; + height: 100%; + left: 0; + position: absolute; + top: 0; + transition: width 120ms linear; +} + +.party-health-fill { + background: #76d39a; +} + +.party-shield-fill { + background: #5ec7ff; + box-shadow: inset 0 3px #a7e4ff; +} + +.role-chip { + color: var(--gold); + font-size: 6px; + text-transform: uppercase; +} + +.role-chip.healer { + color: #5ec7ff; +} + +.role-chip.tank { + color: var(--gold); +} + +.role-chip.melee { + color: var(--red-bright); +} + +.role-chip.ranged { + color: var(--purple); +} + +.boss-canvas-wrap { + align-items: center; + display: flex; + height: 100%; + justify-content: center; + min-height: 0; + overflow: hidden; +} + +.boss-canvas-wrap canvas { + display: block; +} + +.boss-hud { + background: #111319; + border: 2px solid #090a0d; + color: var(--ink); + display: flex; + flex-direction: column; + gap: 14px; + min-height: 0; + margin-left: 16px; + outline: 2px solid #41404a; + overflow: auto; + padding: 16px; +} + +.boss-hud-status p:not(.eyebrow) { + color: var(--muted); + font-size: 20px; + line-height: 1.15; + margin-top: 8px; +} + +.boss-meter { + display: grid; + gap: 8px; +} + +.boss-meter > div { + align-items: center; + display: flex; + justify-content: space-between; +} + +.boss-meter strong, +.boss-meter span, +.boss-stat-grid dt, +.boss-stat-grid dd, +.boss-controls strong { + font-family: 'Press Start 2P', monospace; +} + +.boss-meter strong, +.boss-meter span, +.boss-stat-grid dt { + font-size: 7px; +} + +.boss-meter span, +.boss-stat-grid dt { + color: var(--muted); +} + +.boss-meter i { + background: #090a0d; + border: 2px solid #090a0d; + display: block; + height: 18px; +} + +.boss-meter b { + display: block; + height: 100%; + transition: width 120ms linear; +} + +.boss-meter.player b { + background: #5ec7ff; + box-shadow: inset 0 4px #a7e4ff; +} + +.boss-meter.boss b { + background: var(--red-bright); + box-shadow: inset 0 4px #ff8b98; +} + +.boss-enemy-list { + display: grid; + gap: 8px; +} + +.boss-enemy-row { + background: #171922; + border: 2px solid #090a0d; + display: grid; + gap: 6px; + padding: 8px; +} + +.boss-enemy-row > div { + align-items: center; + display: flex; + gap: 8px; + justify-content: space-between; +} + +.boss-enemy-row strong, +.boss-enemy-row span { + font-family: 'Press Start 2P', monospace; + font-size: 6px; +} + +.boss-enemy-row span { + color: var(--muted); +} + +.boss-enemy-row i { + background: #090a0d; + display: block; + height: 8px; +} + +.boss-enemy-row b { + background: var(--red-bright); + display: block; + height: 100%; + transition: width 120ms linear; +} + +.boss-enemy-row.bullfango b { + background: #f08a4b; +} + +.boss-spellbar { + display: grid; + gap: 8px; + grid-template-columns: repeat(5, minmax(0, 1fr)); +} + +.boss-spellbar button { + background: #15161c; + border: 2px solid #090a0d; + color: var(--ink); + cursor: pointer; + display: grid; + gap: 4px; + font-family: 'Press Start 2P', monospace; + font-size: 6px; + min-height: 52px; + outline: 2px solid #464550; + overflow: hidden; + padding: 6px 4px; + position: relative; +} + +.boss-spellbar button:hover { + outline-color: var(--gold); +} + +.boss-spellbar button.cooling { + color: var(--muted); +} + +.boss-spellbar button > strong, +.boss-spellbar button > span, +.boss-spellbar button > em { + position: relative; + z-index: 1; +} + +.boss-spellbar button > span { + line-height: 1.25; +} + +.boss-spellbar button > i { + background: rgba(8, 9, 12, 0.72); + bottom: 0; + display: block; + left: 0; + position: absolute; + width: 100%; +} + +.boss-spellbar button > em { + color: var(--gold); + font-style: normal; +} + +.boss-castbar { + display: grid; + gap: 7px; +} + +.boss-field-castbar { + background: rgba(8, 9, 12, 0.78); + outline: 2px solid #41404a; + padding: 8px; + left: 50%; + position: absolute; + top: 75%; + transform: translate(-50%, -50%); + width: min(360px, 42%); + z-index: 3; +} + +.boss-castbar > div { + align-items: center; + display: flex; + justify-content: space-between; +} + +.boss-castbar strong, +.boss-castbar span { + font-family: 'Press Start 2P', monospace; + font-size: 7px; +} + +.boss-castbar span { + color: var(--muted); +} + +.boss-castbar i { + background: #090a0d; + border: 2px solid #090a0d; + display: block; + height: 14px; +} + +.boss-castbar b { + background: var(--gold); + display: block; + height: 100%; +} + +.boss-stat-grid { + display: grid; + gap: 8px; + grid-template-columns: 1fr 1fr; + margin: 0; +} + +.boss-stat-grid div { + background: #171922; + border: 2px solid #090a0d; + display: grid; + gap: 7px; + min-width: 0; + padding: 10px; +} + +.boss-stat-grid dd { + color: var(--ink); + font-size: 8px; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + text-transform: uppercase; + white-space: nowrap; +} + +.boss-controls { + border-top: 2px solid #34343d; + display: grid; + gap: 8px; + margin-top: auto; + padding-top: 14px; +} + +.boss-controls strong { + color: var(--gold); + font-size: 8px; +} + +.boss-controls span { + color: var(--muted); + font-size: 18px; +} + +@media (max-width: 920px), (max-height: 620px) { + .boss-slice-shell { + padding: 8px; + } + + .boss-slice-stage { + gap: 10px; + padding: 12px; + } + + .boss-slice-layout { + gap: 10px; + grid-template-columns: 150px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) auto; + } + + .boss-party-frames { + padding: 5px; + width: 150px; + } + + .boss-window-bossbar { + width: min(300px, calc(100% - 24px)); + } + + .boss-hud { + display: grid; + grid-column: 1 / -1; + grid-template-columns: minmax(170px, 1fr) minmax(260px, 1.2fr) minmax(150px, 0.7fr); + margin-left: 0; + max-height: 178px; + overflow: hidden; + } + + .boss-stat-grid, + .boss-controls { + display: none; + } + + .boss-hud-status p:not(.eyebrow) { + font-size: 17px; + } +} + +@media (max-width: 980px), (max-height: 560px) { + h1 { + font-size: 16px; + } + + h2 { + font-size: 11px; + } + + .eyebrow { + font-size: 6px; + margin-bottom: 5px; + } + + .game-shell, + .action-mode-shell { + padding: 6px; + width: 100%; + } + + .content-screen, + .message-panel { + box-shadow: none; + margin-top: 0; + padding: 12px; + } + + .action-mode-screen { + gap: 10px; + } + + .screen-heading, + .boss-slice-heading { + gap: 10px; + padding-bottom: 8px; + } + + .action-heading-meta { + gap: 8px; + } + + .action-character-strip { + gap: 6px; + } + + .action-character-strip strong, + .action-character-strip small { + font-size: 6px; + } + + .header-xp { + width: 62px; + } + + .back-button, + .text-button { + font-size: 7px; + padding: 7px 9px; + } + + .action-hub-nav { + gap: 9px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + max-width: none; + } + + .menu-card { + gap: 10px; + min-height: 72px; + padding: 10px; + } + + .menu-card > span, + .class-portrait { + flex-basis: 38px; + font-size: 14px; + height: 38px; + } + + .menu-card strong, + .action-hub-nav strong { + font-size: 7px; + margin-bottom: 5px; + } + + .menu-card small { + font-size: 14px; + line-height: 1; + } + + .action-dungeon-board { + gap: 10px; + grid-template-columns: minmax(180px, 0.55fr) minmax(0, 1.45fr); + } + + .action-dungeon-board .action-dungeon-list, + .action-dungeon-setup { + gap: 7px; + } + + .action-dungeon-board .action-dungeon-card { + gap: 8px; + grid-template-columns: 36px minmax(0, 1fr); + min-height: 58px; + padding: 7px; + } + + .action-dungeon-glyph { + font-size: 11px; + height: 36px; + } + + .action-dungeon-board .action-dungeon-card strong, + .action-tier-grid strong { + font-size: 7px; + } + + .action-dungeon-board .action-dungeon-card small, + .action-dungeon-board .action-dungeon-card i, + .action-tier-grid span, + .action-dungeon-selected dt, + .action-dungeon-card dt { + font-size: 5px; + } + + .action-dungeon-selected, + .action-dungeon-tier, + .action-dungeon-start, + .action-gear-panel, + .action-mechanics-admin, + .action-placeholder-panel { + gap: 8px; + padding: 10px; + } + + .action-dungeon-selected p:not(.eyebrow), + .action-dungeon-start p, + .action-gear-panel p:not(.eyebrow), + .action-placeholder-panel p:not(.eyebrow), + .action-mechanics-admin > p, + .action-empty-note { + font-size: 14px; + line-height: 1.05; + } + + .tag-row { + gap: 5px; + margin-top: 6px; + } + + .tag-row span { + font-size: 11px; + padding: 2px 5px; + } + + .action-dungeon-selected dl { + gap: 6px; + } + + .action-dungeon-selected dl div, + .action-tier-grid button { + gap: 5px; + padding: 7px; + } + + .action-dungeon-selected dd { + font-size: 7px; + } + + .action-dungeon-actions { + gap: 8px; + } + + .action-dungeon-actions button, + .primary-button { + font-size: 6px; + min-height: 34px; + padding: 9px 10px; + } + + .action-customize-layout { + gap: 10px; + grid-template-columns: minmax(190px, 0.75fr) minmax(0, 1.25fr); + } + + .action-slot-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .action-slot-grid button { + gap: 4px; + min-height: 50px; + padding: 7px; + } + + .action-slot-grid span { + font-size: 8px; + } + + .action-slot-grid strong, + .action-inventory-list strong { + font-size: 6px; + } + + .action-slot-grid small, + .action-inventory-list small, + .action-inventory-panel > header span, + .action-gear-detail header span, + .action-stat-compare dt, + .action-stat-compare dd { + font-size: 5px; + } + + .action-inventory-panel { + gap: 8px; + grid-template-rows: auto minmax(58px, 0.6fr) auto; + padding: 9px; + } + + .action-inventory-list { + gap: 6px; + } + + .action-inventory-list button { + min-height: 34px; + padding: 7px; + } + + .action-gear-detail { + gap: 8px; + padding: 8px; + } + + .action-stat-compare { + gap: 6px; + } + + .action-stat-compare div { + gap: 4px; + padding: 6px; + } + + .action-mechanic-card { + gap: 8px; + padding: 9px; + } + + .action-mechanic-heading h3 { + font-size: 8px; + } + + .action-mechanic-heading span, + .action-page-controls span, + .action-disabled-attacks strong { + font-size: 6px; + } + + .action-page-controls button, + .action-attack-editor button, + .action-disabled-attacks button { + font-size: 5px; + min-height: 28px; + padding: 6px; + } + + .action-attack-editor { + gap: 8px; + padding: 8px; + } + + .action-attack-editor strong { + font-size: 7px; + } + + .action-attack-editor small, + .action-attack-fields label { + font-size: 5px; + } + + .action-attack-fields { + gap: 6px; + } + + .action-attack-fields input { + padding: 5px; + } + + .action-disabled-attacks { + gap: 6px; + } + + .boss-slice-shell { + padding: 6px; + } + + .boss-slice-stage { + box-shadow: none; + gap: 8px; + padding: 8px; + } + + .boss-slice-layout { + gap: 8px; + grid-template-columns: 132px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) 130px; + } + + .boss-party-frames { + gap: 4px; + overflow: hidden; + padding: 4px; + width: 132px; + } + + .party-frame { + gap: 3px; + min-height: 44px; + padding: 5px 4px; + } + + .party-frame strong { + font-size: 6px; + } + + .party-frame small, + .party-frame em, + .role-chip { + font-size: 5px; + } + + .party-frame i { + height: 7px; + } + + .boss-hud { + gap: 8px; + grid-template-columns: minmax(110px, 0.8fr) minmax(96px, 0.7fr) minmax(130px, 1fr) minmax(190px, 1.25fr); + max-height: none; + overflow: hidden; + padding: 8px; + } + + .boss-hud-status p:not(.eyebrow) { + font-size: 13px; + } + + .boss-meter { + gap: 6px; + } + + .boss-meter strong, + .boss-meter span, + .boss-stat-grid dt { + font-size: 5px; + } + + .boss-enemy-list { + gap: 5px; + } + + .boss-enemy-row { + gap: 4px; + padding: 5px; + } + + .boss-enemy-row strong, + .boss-enemy-row span { + font-size: 5px; + } + + .boss-spellbar { + gap: 5px; + } + + .boss-spellbar button { + font-size: 5px; + min-height: 42px; + padding: 4px 2px; + } + + .boss-controls span { + font-size: 13px; + } + + .boss-window-bossbar { + top: 8px; + width: min(260px, calc(100% - 20px)); + } + + .boss-window-bossbar strong, + .boss-window-bossbar span, + .boss-castbar strong, + .boss-castbar span { + font-size: 6px; + } +} + +@media (max-width: 680px) { + h1 { + font-size: 13px; + } + + .content-screen, + .message-panel { + border-width: 2px; + padding: 8px; + } + + .screen-heading { + align-items: start; + } + + .action-heading-meta { + align-items: flex-end; + flex-direction: column; + } + + .action-hub-nav { + gap: 7px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .menu-card { + min-height: 64px; + padding: 8px; + } + + .menu-card small { + font-size: 12px; + } + + .action-dungeon-board { + grid-template-columns: minmax(148px, 0.48fr) minmax(0, 1.52fr); + } + + .action-dungeon-board .action-dungeon-card { + grid-template-columns: 30px minmax(0, 1fr); + min-height: 52px; + padding: 6px; + } + + .action-dungeon-glyph { + font-size: 9px; + height: 30px; + } + + .action-tier-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .action-tier-grid button { + padding: 6px 4px; + } + + .action-tier-grid strong, + .action-tier-grid span { + font-size: 5px; + } + + .action-dungeon-selected dl, + .tag-row, + .action-dungeon-start p, + .action-gear-summary p:not(.eyebrow) { + display: none; + } + + .action-customize-layout { + grid-template-columns: minmax(150px, 0.55fr) minmax(0, 1.45fr); + } + + .action-slot-grid { + gap: 6px; + } + + .action-slot-grid button { + min-height: 44px; + padding: 6px; + } + + .action-inventory-panel > header { + align-items: start; + } + + .action-mechanics-admin > header { + align-items: start; + } + + .action-attack-fields { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .boss-slice-layout { + grid-template-columns: 104px minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr) 124px; + } + + .boss-party-frames { + width: 104px; + } + + .party-frame { + min-height: 41px; + } + + .boss-hud { + grid-template-columns: minmax(88px, 0.75fr) minmax(76px, 0.65fr) minmax(106px, 0.95fr) minmax(180px, 1.4fr); + } + + .boss-hud-status h2 { + font-size: 9px; + } + + .boss-hud-status p:not(.eyebrow), + .boss-controls span { + font-size: 12px; + } +} + +.tag-row { + display: flex; + gap: 7px; + margin-top: 10px; + flex-wrap: wrap; +} + +.tag-row span { + background: #15161c; + color: var(--gold); + font-size: 15px; + padding: 3px 7px; +} + +.part-buttons { + display: flex; + gap: 8px; +} + +.part-buttons .primary-button { + white-space: nowrap; +} + +.part-buttons .primary-button.selected-part { + outline-color: #fff; + background: #f0cb79; +} + +.part-buttons .primary-button.locked { + filter: grayscale(0.65); + opacity: 0.62; + cursor: not-allowed; +} + +.primary-button { + background: var(--gold); + border: 2px solid #08090c; + color: #19150e; + cursor: pointer; + font-family: 'Press Start 2P', monospace; + font-size: 8px; + outline: 2px solid #816630; + padding: 13px 17px; +} + +.primary-button:hover:not(:disabled) { + background: #f0cb79; + transform: translateY(-1px); +} + +.primary-button:disabled { + cursor: wait; + opacity: 0.6; +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..8050f05 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowImportingTsExtensions": true, + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..7acfc33 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts", "eslint.config.js"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..9ffcc67 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], +})