diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 613e2ee..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,43 +0,0 @@ -# Buildathon Submission Pull Request - -Thank you for submitting your project for the Build Beyond Limits 2.0 - powered by Valkey and Breeth AI, hosted by React Hyderabad. - -Please fill in the details below before raising your Pull Request. - ---- - -## Submission Details - -**Project Name:** -**Attendee Name:** -**Problem Statement Selected:** - -Choose one: - -- [ ] HR Cost Intelligence Engine -- [ ] Intelligent Slack Knowledge Base - ---- - -## Checklist - -Please confirm the following: - -- [ ] I have created my submission file inside the `submissions/` folder. -- [ ] My submission file follows this naming format: `projectname_attendeeName.md`. -- [ ] I have used the `SUBMISSION_TEMPLATE.md` format. -- [ ] I have added my GitHub project repository link. -- [ ] I have added my LinkedIn profile link. -- [ ] I have added my GitHub username. -- [ ] My project repository is public. -- [ ] I have not added API keys, passwords, tokens, or private credentials. -- [ ] I understand that my actual project code should be in my own GitHub repository, not in this repository. - ---- - -## Project Repository Link - -Add your project GitHub repository link here: - -```txt -PASTE_LINK_HERE \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27e6c94 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +.env +*.log +.DS_Store +Thumbs.db diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 25412a4..0000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,84 +0,0 @@ - ---- - -# `CODE_OF_CONDUCT.md` - -```md -# Code of Conduct - -## Our Promise - -React Hyderabad is committed to creating a welcoming, respectful, and inclusive environment for everyone participating in this buildathon. - -This applies to all participants, mentors, organizers, volunteers, judges, and community members. - -We expect everyone to treat each other with kindness, patience, and respect. - ---- - -## Expected Behaviour - -All participants are expected to: - -- Be respectful towards others -- Collaborate in a healthy and professional way -- Give constructive feedback -- Respect different skill levels and backgrounds -- Help others when possible -- Follow the event and submission guidelines -- Keep the community safe and welcoming - ---- - -## Unacceptable Behaviour - -The following behaviour will not be tolerated: - -- Harassment or bullying -- Discrimination of any kind -- Offensive comments or personal attacks -- Inappropriate language or behaviour -- Sharing someone’s private information without permission -- Plagiarism or claiming someone else’s work as your own -- Misuse of GitHub issues, pull requests, or discussions -- Any behaviour that makes others feel unsafe or unwelcome - ---- - -## Project and Submission Ethics - -Participants are expected to submit their own work. - -You may use AI tools, open-source libraries, APIs, documentation, and online resources, but you should understand what you are building and be able to explain your solution. - -If you use existing code, templates, or external resources, please give proper credit wherever required. - -Do not submit copied projects as original work. - ---- - -## Reporting Issues - -If you experience or notice unacceptable behaviour, please reach out to the React Hyderabad organizing team. - -The organizing team will review the concern and take appropriate action. - ---- - -## Enforcement - -Depending on the situation, the organizing team may take actions such as: - -- Giving a warning -- Asking for corrective action -- Rejecting a submission -- Removing a participant from the event or repository -- Taking further action if required - ---- - -## Final Note - -This buildathon is about learning, building, collaboration, and community. - -Please help us keep it respectful, safe, and meaningful for everyone. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index e69de29..0000000 diff --git a/PROBLEM_STATEMENTS.md b/PROBLEM_STATEMENTS.md deleted file mode 100644 index fc5a465..0000000 --- a/PROBLEM_STATEMENTS.md +++ /dev/null @@ -1,23 +0,0 @@ -Here's the link to access the problem statements - - -All the problem statements here - [https://github.com/opensource-for-valkey/valkey-community/tree/main/hackathons/hyd13_2026-06-20](https://github.com/opensource-for-valkey/valkey-community/tree/main/hackathons/hyd13_2026-06-20) - -There are three important criterias for building your project - - -1. You must use Valkey in your app. -Valkey Intro - [https://github.com/pnbrown/valkey-intro](https://github.com/pnbrown/valkey-intro) -2. You should use Breeth AI in your app. Documentation is available - [https://thebreeth.com/](https://thebreeth.com/) -3. Please use React.js as your frontend library. - - - -Please make sure you only pick the problem statements as described above! - -We will be sharing the credits link of Breeth AI when we go live! - - - -Please join the discord channel for getting the credits - [https://discord.gg/mPC3AeUC](https://discord.gg/mPC3AeUC) - - - diff --git a/README.md b/README.md index d14c3c6..04c618b 100644 --- a/README.md +++ b/README.md @@ -1,156 +1,87 @@ -# build-beyond-limits-2.0 -PR submissions for the Build Beyond Limits 2.0 - Powered by Valkey, Hosted by React Hyderabad -Credit Partner - https://thebreeth.com/ +# Slither Quest — Multiplayer Snake Game -Instructions releasing live on 20th June, at 3 PM +A Slither.io-style multiplayer game built with React + Canvas, Node.js + WebSocket, Valkey (Redis), and Breeth AI for player memory. -Join here - https://youtube.com/live/kTT2Kovs-y8?feature=share +## Architecture - -# React Hyderabad x Valkey - -Welcome to the official open-source submission repository for the **Build Beyond Limits 2.0 - powered by Valkey and Breeth AI, and hosted by React Hyderabad**. - -This buildathon is focused on solving **real-world business problems using AI**. The goal is not just to write code, but to understand a problem, think from a business perspective, use the right tools, build a working solution, and present it clearly. - -This repository will act as the central place where all participant submissions are listed. - -Your actual project/code should be maintained in your own GitHub repository. This repo is only for submitting your project details. - ---- - -## How to Participate - -1. Fork this repository. - -2. Create your own project repository on GitHub. - -3. Pick one of the problem statements from `PROBLEM_STATEMENTS.md`. - -4. Build your project in your own GitHub repository. - -5. In your fork of this repository, go to the `submissions/` folder. - -6. Create a new markdown file using this naming format: - -```txt -projectname_attendeeName.md ``` - -OR - -```txt -projectname_teamName.md +Browser ──input {dir, boost}──► Node.js game loop (25 Hz) + ◄──snapshot (world)── + │ + ├── Valkey (ioredis): leaderboard, pub/sub, world snapshot + └── Breeth AI: per-player memory (join greeting / death log) ``` -Example: - -```txt -ai-sales-copilot_shlok-srivastava.md -``` - -7. Use `SUBMISSION_TEMPLATE.md` as the blueprint for your submission. - -8. Fill in all the required details: - - * Project name - * Your name - * Problem statement selected - * Project description - * Approach - * Tools and technologies used - * GitHub project link - * LinkedIn profile - * GitHub username - -9. Raise a Pull Request to this repository. +- **Server-authoritative**: client sends only mouse direction + boost flag; server owns all positions, growth, and collision. +- **25 ticks/sec** game loop; Valkey updated ~2×/sec to avoid remote round-trip costs. +- **Pub/sub killfeed**: deaths published to `game:events`, subscriber relays to all WebSocket clients. -10. Get your Pull Request merged. +## Run Steps ---- +### 1. Server -## Important Submission Note - -Please make sure your Pull Request is raised by today. - -Even if your project is incomplete, you should still submit your project details by creating a Pull Request. - -You can continue building and improving your actual project in your own GitHub repository after the submission PR is merged. - -The purpose of this repository is to make the hackathon open, transparent, and community-driven. - ---- - -## Submission Rules - -1. Each participant must submit their own project. - -2. Your actual project code should be in your own GitHub repository. - -3. Do not push your project code directly into this repository. - -4. This repository should only contain participant submission files. - -5. Each submission file must be added inside the `submissions/` folder. - -6. Use the correct file naming format: - -```txt -projectname_attendeeName.md -``` -OR -```txt -projectname_attendeeName.md +```bash +cd server +npm install +cp .env.example .env # edit VALKEY_URL and optionally BREETH_API_KEY +npm run dev ``` -7. Make sure your project repository is public. - -8. Do not commit API keys, private tokens, passwords, or secret credentials in your project repository. - -9. If your project is still a work in progress, mention that clearly in your submission. +### 2. Client -10. Keep your submission clear, honest, and easy to understand. - ---- +```bash +cd client +npm install +# Optional: edit .env if server is not on localhost:3001 +npm run dev +``` -## Pull Request Title Format +Open **http://localhost:5173** in your browser. -Please use this format for your Pull Request title: +## Environment Variables -```txt -Submission: Project Name - Attendee Name/ Team Name -``` +### server/.env -Example: - -```txt -Submission: AI Sales Copilot - Shlok Srivastava -``` +| Variable | Description | +|---|---| +| `PORT` | WebSocket/HTTP port (default `3001`) | +| `VALKEY_URL` | `rediss://user:pass@host:port` — Aiven or local Redis | +| `BREETH_API_KEY` | `ck_live_…` — optional; game runs with in-memory fallback | ---- +### client/.env -## What Makes a Good Submission? +| Variable | Description | +|---|---| +| `VITE_WS_URL` | WebSocket URL (default `ws://localhost:3001`) | -A good submission should clearly explain: +## Valkey Data Model -* What problem you selected -* What your project does -* Why your solution is useful -* How you approached the problem -* What AI tools or technologies you used -* What is already working -* What you plan to improve next +| Key | Type | Usage | +|---|---|---| +| `game:leaderboard` | Sorted Set | `ZADD score playerId`, read with `ZREVRANGE 0 9 WITHSCORES` | +| `game:names` | Hash | `HSET playerId name` | +| `game:world` | String | JSON snapshot, `EX 5` TTL | +| `game:events` | Pub/Sub | join / death events | -The project does not have to be perfect. We care about your thought process, execution, learning, and ability to solve a real-world problem. +## Breeth AI ---- +On **join**: `POST /v1/search` with player name → personalized greeting showing best length and last killer. +On **death**: `POST /v1/episodes` stores the run narrative for future recall. +Falls back to in-memory store automatically when `BREETH_API_KEY` is not set. -## About the Build Beyond Limits 2.0 +## Controls -This hackathon is hosted by **React Hyderabad** in collaboration with **Valkey** and **Breeth AI**. +| Action | Control | +|---|---| +| Steer | Mouse movement | +| Boost | Hold left-click or Space | +| Respawn | Click RESPAWN button (3 s cooldown) | -The goal is to bring builders together to solve meaningful problems using AI, frontend, backend, cloud, automation, and modern development tools. +## Game Mechanics -Build with intent. -Solve with clarity. -Ship with confidence. +- World is **4000×4000** with a grid background; camera follows your snake head. +- Eating pellets grows your snake and increases score. +- Boosting increases speed but burns length and drops pellets. +- Colliding **head-first into another snake's body** kills you. +- Hitting the **world border** kills you. +- Dead snakes explode into pellets. diff --git a/SECURITY.md b/SECURITY.md deleted file mode 100644 index edd9514..0000000 --- a/SECURITY.md +++ /dev/null @@ -1,50 +0,0 @@ - ---- - -# `SECURITY.md` - -```md -# Security Policy - -This repository is used only for collecting hackathon project submissions. - -Participants should not add sensitive information to this repository or to their public project repositories. - ---- - -## Do Not Commit Secrets - -Please do not commit: - -- API keys -- Access tokens -- Passwords -- Private credentials -- `.env` files -- Database connection strings -- Cloud service credentials -- Personal identification documents -- Any confidential business data - ---- - -## If You Accidentally Commit a Secret - -If you accidentally commit a secret: - -1. Remove it from your repository immediately. -2. Rotate or revoke the exposed key/token from the provider dashboard. -3. Update your project with a safe placeholder value. -4. Inform the organizers if the secret was committed to this repository. - ---- - -## Recommended Practice - -Use environment variables for local development. - -Example: - -```txt -OPENAI_API_KEY=your_api_key_here -DATABASE_URL=your_database_url_here \ No newline at end of file diff --git a/SUBMISSION_TEMPLATE.md b/SUBMISSION_TEMPLATE.md deleted file mode 100644 index a6e1d6e..0000000 --- a/SUBMISSION_TEMPLATE.md +++ /dev/null @@ -1,183 +0,0 @@ -# Project Submission Template - -Create a new file inside the `submissions/` folder using this naming format: - -```txt -projectname_attendeeName.md -``` - -Example: - -```txt -ai-sales-copilot_shlok-srivastava.md -``` - -Copy the template below into your new file and fill in the details. - ---- - -# Project Name - -Write your project name here. - ---- - -## Attendee/Team Details - -Add all the names of all the team members if you are participating in teams. - -**Name:** Your full name -**GitHub Username:** Your GitHub username -**LinkedIn Profile:** Your LinkedIn profile link -**GitHub Project Repository:** Your project repository link - ---- - -## Problem Statement Selected - -Mention the title of the problem statement you selected. - -Choose one: - -```txt -Problem statement 1 -``` - -or - -```txt -Problem statement 2 -``` - ---- - -## Project Description - -Briefly explain what your project does. - -Try to answer: - -* What is the project about? -* Who is it for? -* What problem does it solve? -* How does it help the user? - -Write your answer here. - ---- - -## Approach - -Explain how you approached the problem. - -You can include: - -* How you understood the problem -* What user flow you designed -* What features you decided to build -* How AI is used in your solution -* What makes your approach useful or different - -Write your answer here. - ---- - -## Tech Stack and Tools Used - -Mention the tools, technologies, frameworks, and platforms you used. - -Example: - -**Frontend:** React, Next.js, HTML, CSS, Tailwind, Material UI -**Backend:** Node.js, Express, FastAPI, Django, Flask -**Database:** MongoDB, PostgreSQL, Firebase, Supabase -**AI Tools/API:** OpenAI, Gemini, Claude, LangChain, Hugging Face, local LLMs -**Cloud/Deployment:** Vercel, Netlify, Render, Railway, AWS, GCP -**Other Tools:** Cursor, GitHub Copilot, Figma, Postman, Docker - -Fill your details below: - -**Frontend:** -**Backend:** -**Database:** -**AI Tools/API:** -**Cloud/Deployment:** -**Other Tools:** - ---- - -## Key Features - -List the key features of your project. - -Example: - -1. Feature one -2. Feature two -3. Feature three - -Write your features here: - -1. -2. -3. - ---- - -## What is Working? - -Mention what is currently working in your project. - -This is important even if your project is incomplete. - -Write your answer here. - ---- - -## What is Still in Progress? - -Mention what you are still building or planning to improve. - -Write your answer here. - ---- - -## Screenshots or Demo - -Add screenshots, demo video link, or deployed project link if available. - -**Deployed Link:** -**Demo Video Link:** -**Screenshots:** - ---- - -## Challenges Faced - -Mention any challenges you faced while building this project. - -Write your answer here. - ---- - -## Learnings - -Mention what you learned while building this project. - -Write your answer here. - ---- - -## Future Improvements - -Mention what you would improve if you had more time. - -Write your answer here. - ---- - -## Final Note - -Use this space to add anything else you want mentors, judges, or the community to know about your project. - -Write your answer here. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..c62109e --- /dev/null +++ b/client/index.html @@ -0,0 +1,17 @@ + + + + + + Slither Quest + + + +
+ + + diff --git a/client/package-lock.json b/client/package-lock.json new file mode 100644 index 0000000..3ee9f4a --- /dev/null +++ b/client/package-lock.json @@ -0,0 +1,1680 @@ +{ + "name": "slither-client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slither-client", + "version": "1.0.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } + }, + "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-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "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/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/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/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "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/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "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/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/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/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/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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/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/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/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/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/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/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/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.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "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/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/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/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/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "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/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/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/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "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" + } + } +} diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..fcdbe73 --- /dev/null +++ b/client/package.json @@ -0,0 +1,19 @@ +{ + "name": "slither-client", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.1", + "vite": "^5.4.0" + } +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..a527b9c --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,317 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import GameCanvas from './components/GameCanvas.jsx'; +import HUD from './components/HUD.jsx'; +import LobbyScreen from './components/LobbyScreen.jsx'; +import AuthScreen from './components/AuthScreen.jsx'; +import DeathScreen from './components/DeathScreen.jsx'; +import GameOverScreen from './components/GameOverScreen.jsx'; +import AchievementToast from './components/AchievementToast.jsx'; +import { usePlayerStats } from './hooks/usePlayerStats.js'; +import { useAchievements } from './hooks/useAchievements.js'; +import { audio } from './audio/AudioManager.js'; + +const WS_URL = import.meta.env.VITE_WS_URL || + (typeof window !== 'undefined' + ? (window.location.protocol === 'https:' ? 'wss://' : 'ws://') + window.location.host + : 'ws://localhost:3001'); + +export default function App() { + const wsRef = useRef(null); + const { stats, recordDeath } = usePlayerStats(); + const { unlocked, toasts, tryUnlock, dismissToast } = useAchievements(); + + // ── Phase + core state ──────────────────────────────────────────────────── + const [phase, setPhase] = useState('auth'); // auth|lobby|playing|dead|gameover + const [playerId, setPlayerId] = useState(null); + const [playerName, setPlayerName] = useState(''); + const [playerPin, setPlayerPin] = useState(''); + const [roomCode, setRoomCode] = useState(null); + const [greeting, setGreeting] = useState(null); + const [lobbyError, setLobbyError] = useState(null); + const [lobbyLoading, setLobbyLoading] = useState(false); + const [createdCode, setCreatedCode] = useState(null); + const [snapshot, setSnapshot] = useState(null); + const [leaderboard, setLeaderboard] = useState([]); + const [killfeed, setKillfeed] = useState([]); + const [deathInfo, setDeathInfo] = useState(null); + const [gameOverData, setGameOverData] = useState(null); + const [timeLeftMs, setTimeLeftMs] = useState(null); + + // ── Refs ────────────────────────────────────────────────────────────────── + const playerIdRef = useRef(null); + const playerNameRef = useRef(''); + const phaseRef = useRef('lobby'); + const pendingRef = useRef({}); + const leaderboardRef = useRef([]); + + useEffect(() => { playerIdRef.current = playerId; }, [playerId]); + useEffect(() => { playerNameRef.current = playerName; }, [playerName]); + useEffect(() => { phaseRef.current = phase; }, [phase]); + useEffect(() => { leaderboardRef.current = leaderboard; }, [leaderboard]); + + // ── Session runtime tracking ────────────────────────────────────────────── + const sessRef = useRef({ + kills: 0, startMs: null, + boostStartMs: null, maxBoostSec: 0, + eatTimestamps: [], maxQuickEat: 0, + prevScore: 0, + }); + + const resetSession = () => { + sessRef.current = { + kills: 0, startMs: Date.now(), + boostStartMs: null, maxBoostSec: 0, + eatTimestamps: [], maxQuickEat: 0, + prevScore: 0, + }; + }; + + // ── WebSocket ────────────────────────────────────────────────────────────── + useEffect(() => { + let ws, reconnectTimer; + + function connect() { + ws = new WebSocket(WS_URL); + wsRef.current = ws; + + ws.onopen = () => { console.log('[WS] connected'); audio.resume(); }; + + ws.onmessage = (e) => { + let msg; + try { msg = JSON.parse(e.data); } catch { return; } + + // Lobby responses + if (msg.type === 'name_status') { pendingRef.current.nameStatus?.(msg); return; } + if (msg.type === 'room_info') { pendingRef.current.roomInfo?.(msg); return; } + + // Room created + if (msg.type === 'room_created') { + setLobbyLoading(false); + setCreatedCode(msg.roomCode); + setTimeout(() => { + setPlayerId(msg.id); playerIdRef.current = msg.id; + setRoomCode(msg.roomCode); + setGreeting(msg.greeting); + setLobbyError(null); + setPhase('playing'); phaseRef.current = 'playing'; + resetSession(); + audio.join(); + }, 2000); + return; + } + + // Joined existing room + if (msg.type === 'joined') { + setLobbyLoading(false); + setPlayerId(msg.id); playerIdRef.current = msg.id; + setRoomCode(msg.roomCode); + setGreeting(msg.greeting); + setLobbyError(null); + setCreatedCode(null); + setPhase('playing'); phaseRef.current = 'playing'; + resetSession(); + audio.join(); + return; + } + + // Error + if (msg.type === 'error') { + setLobbyLoading(false); + setLobbyError(msg.message || 'Something went wrong.'); + return; + } + + // Snapshot + if (msg.type === 'snapshot') { + setSnapshot(msg.data); + if (msg.leaderboard?.length > 0) { + setLeaderboard(msg.leaderboard); + leaderboardRef.current = msg.leaderboard; + } + if (msg.timeLeftMs !== undefined) setTimeLeftMs(msg.timeLeftMs); + + // Runtime tracking + const me = msg.data?.snakes?.find(s => s.id === playerIdRef.current); + if (me && phaseRef.current === 'playing') { + const sess = sessRef.current; + const now = Date.now(); + + // Boost tracking + if (me.boosting) { + if (!sess.boostStartMs) sess.boostStartMs = now; + const sec = (now - sess.boostStartMs) / 1000; + if (sec > sess.maxBoostSec) sess.maxBoostSec = sec; + audio.startBoost(); + } else { + if (sess.boostStartMs) { sess.boostStartMs = null; audio.stopBoost(); } + } + + // Eat tracking (score increase = pellet eaten) + if (me.score > sess.prevScore) { + audio.eat(); + const diff = me.score - sess.prevScore; + for (let i = 0; i < diff; i++) sess.eatTimestamps.push(now); + const recent = sess.eatTimestamps.filter(t => t > now - 5000); + sess.eatTimestamps = recent; + if (recent.length > sess.maxQuickEat) sess.maxQuickEat = recent.length; + } + sess.prevScore = me.score; + } + return; + } + + // Death + if (msg.type === 'death') { + audio.stopBoost(); + const sess = sessRef.current; + const survivalSec = sess.startMs ? Math.round((Date.now() - sess.startMs) / 1000) : 0; + const d = msg.data || {}; + const lb = leaderboardRef.current; + const rank = lb.length > 0 ? (lb.findIndex(e => e.id === playerIdRef.current) + 1) || null : null; + + recordDeath({ score: d.score || 0, survivalSec, kills: sess.kills, length: d.length || 0, rank }); + + const updatedStats = { + ...stats, + highScore: Math.max(stats.highScore, d.score || 0), + totalKills: stats.totalKills + sess.kills, + highLength: Math.max(stats.highLength, d.length || 0), + }; + tryUnlock(updatedStats, { maxBoostSec: sess.maxBoostSec, maxQuickEat: sess.maxQuickEat }); + + setDeathInfo({ ...d, sessionKills: sess.kills, survivalSec }); + setPhase('dead'); phaseRef.current = 'dead'; + audio.die(); + return; + } + + // Respawned (also covers new round auto-respawn) + if (msg.type === 'respawned') { + setDeathInfo(null); + setGameOverData(null); + setPhase('playing'); phaseRef.current = 'playing'; + resetSession(); + audio.join(); + return; + } + + // Game over — 5-minute round ended + if (msg.type === 'game_over') { + audio.stopBoost(); + setGameOverData({ leaderboard: msg.leaderboard || [], winner: msg.winner || null }); + setPhase('gameover'); phaseRef.current = 'gameover'; + return; + } + + // Killfeed + if (msg.type === 'killfeed') { + const kd = msg.data; + setKillfeed(prev => [{ ...kd, key: Date.now() + Math.random() }, ...prev].slice(0, 6)); + if (kd.killerName && playerNameRef.current && kd.killerName === playerNameRef.current) { + sessRef.current.kills++; + audio.kill(); + } + return; + } + }; + + ws.onclose = () => { reconnectTimer = setTimeout(connect, 2000); }; + ws.onerror = () => ws.close(); + } + + connect(); + return () => { clearTimeout(reconnectTimer); ws?.close(); }; + }, []); // eslint-disable-line + + const wsSend = useCallback((obj) => { + if (wsRef.current?.readyState === WebSocket.OPEN) wsRef.current.send(JSON.stringify(obj)); + }, []); + + // ── Lobby actions ────────────────────────────────────────────────────────── + const handleCreate = useCallback((name, pin, roomPassword) => { + setLobbyError(null); setLobbyLoading(true); + setPlayerName(name); playerNameRef.current = name; + setPlayerPin(pin); + wsSend({ type: 'create_room', name, pin, roomPassword }); + }, [wsSend]); + + const handleJoin = useCallback((code, name, pin, password) => { + setLobbyError(null); setLobbyLoading(true); + setPlayerName(name); playerNameRef.current = name; + setPlayerPin(pin); + wsSend({ type: 'join_room', roomCode: code, name, pin, password }); + }, [wsSend]); + + const handleCheckRoom = useCallback((code) => { + return new Promise(resolve => { + pendingRef.current.roomInfo = resolve; + wsSend({ type: 'check_room', roomCode: code }); + setTimeout(() => { + if (pendingRef.current.roomInfo === resolve) { + pendingRef.current.roomInfo = null; + resolve({ exists: false, passwordRequired: false }); + } + }, 3000); + }); + }, [wsSend]); + + const handleInput = useCallback((dir, boost) => wsSend({ type: 'input', dir, boost }), [wsSend]); + const handleRespawn = useCallback(() => wsSend({ type: 'respawn' }), [wsSend]); + + const inGame = phase === 'playing' || phase === 'dead' || phase === 'gameover'; + + const handleAuth = useCallback((name, pin) => { + setPlayerName(name); playerNameRef.current = name; + setPlayerPin(pin); + setPhase('lobby'); phaseRef.current = 'lobby'; + }, []); + + return ( +
+ {phase === 'auth' && ( + + )} + + {phase === 'lobby' && ( + { setPhase('auth'); phaseRef.current = 'auth'; }} + /> + )} + + {inGame && ( + <> + + setGreeting(null)} + timeLeftMs={timeLeftMs} + /> + {phase === 'dead' && ( + + )} + {phase === 'gameover' && ( + {}} // auto-handled when server sends respawned + /> + )} + + )} + + +
+ ); +} diff --git a/client/src/audio/AudioManager.js b/client/src/audio/AudioManager.js new file mode 100644 index 0000000..1769338 --- /dev/null +++ b/client/src/audio/AudioManager.js @@ -0,0 +1,99 @@ +class AudioManager { + constructor() { + this._ctx = null; + this._master = null; + this._boostOsc = null; + this._boostGain = null; + this.muted = false; + } + + _init() { + if (this._ctx) return; + this._ctx = new (window.AudioContext || window.webkitAudioContext)(); + this._master = this._ctx.createGain(); + this._master.gain.value = 0.35; + this._master.connect(this._ctx.destination); + } + + resume() { + try { this._init(); if (this._ctx.state === 'suspended') this._ctx.resume(); } catch {} + } + + _tone(freq, dur, type = 'sine', vol = 0.3, delay = 0) { + if (this.muted) return; + try { + this._init(); + const t = this._ctx.currentTime + delay; + const osc = this._ctx.createOscillator(); + const gain = this._ctx.createGain(); + osc.type = type; + osc.frequency.setValueAtTime(freq, t); + gain.gain.setValueAtTime(vol, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + dur); + osc.connect(gain); + gain.connect(this._master); + osc.start(t); + osc.stop(t + dur + 0.05); + } catch {} + } + + click() { this._tone(900, 0.05, 'square', 0.1); } + eat() { this._tone(1400, 0.07, 'sine', 0.15); this._tone(1800, 0.05, 'sine', 0.1, 0.05); } + kill() { this._tone(200, 0.1, 'sawtooth', 0.3); this._tone(150, 0.25, 'sawtooth', 0.2, 0.1); } + navSwitch() { this._tone(600, 0.06, 'sine', 0.08); } + join() { [440, 554, 659, 880].forEach((f, i) => this._tone(f, 0.18, 'sine', 0.2, i * 0.1)); } + achievement() { [523, 659, 784, 1047, 1047].forEach((f, i) => this._tone(f, 0.22, 'sine', 0.25, i * 0.13)); } + + die() { + if (this.muted) return; + try { + this._init(); + const t = this._ctx.currentTime; + const osc = this._ctx.createOscillator(); + const gain = this._ctx.createGain(); + osc.type = 'sawtooth'; + osc.frequency.setValueAtTime(300, t); + osc.frequency.exponentialRampToValueAtTime(40, t + 2); + gain.gain.setValueAtTime(0.4, t); + gain.gain.exponentialRampToValueAtTime(0.001, t + 2); + osc.connect(gain); + gain.connect(this._master); + osc.start(t); + osc.stop(t + 2.1); + } catch {} + } + + startBoost() { + if (this.muted || this._boostOsc) return; + try { + this._init(); + this._boostOsc = this._ctx.createOscillator(); + this._boostGain = this._ctx.createGain(); + this._boostOsc.type = 'sawtooth'; + this._boostOsc.frequency.value = 75; + this._boostGain.gain.value = 0.07; + this._boostOsc.connect(this._boostGain); + this._boostGain.connect(this._master); + this._boostOsc.start(); + } catch {} + } + + stopBoost() { + if (!this._boostOsc) return; + try { + const t = this._ctx.currentTime; + this._boostGain.gain.exponentialRampToValueAtTime(0.001, t + 0.15); + this._boostOsc.stop(t + 0.16); + } catch {} + this._boostOsc = null; + this._boostGain = null; + } + + setMuted(val) { + this.muted = val; + if (this._master) this._master.gain.value = val ? 0 : 0.35; + if (val) this.stopBoost(); + } +} + +export const audio = new AudioManager(); diff --git a/client/src/components/AchievementToast.jsx b/client/src/components/AchievementToast.jsx new file mode 100644 index 0000000..6a6643c --- /dev/null +++ b/client/src/components/AchievementToast.jsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { audio } from '../audio/AudioManager.js'; + +function Toast({ a, onDone }) { + const [alive, setAlive] = useState(true); + + useEffect(() => { + audio.achievement(); + const t1 = setTimeout(() => setAlive(false), 4200); + const t2 = setTimeout(onDone, 4700); + return () => { clearTimeout(t1); clearTimeout(t2); }; + }, []); + + return ( +
+ {a.icon} +
+
+ ACHIEVEMENT UNLOCKED +
+
{a.name}
+
{a.desc}
+
+
+ ); +} + +export default function AchievementToast({ toasts, onDismiss }) { + if (!toasts.length) return null; + return ( +
+ {toasts.map(a => ( + onDismiss(a.key)} /> + ))} +
+ ); +} diff --git a/client/src/components/AnimatedBg.jsx b/client/src/components/AnimatedBg.jsx new file mode 100644 index 0000000..622ca49 --- /dev/null +++ b/client/src/components/AnimatedBg.jsx @@ -0,0 +1,250 @@ +import { useEffect, useRef } from 'react'; + +// ── Config ──────────────────────────────────────────────────────────────────── +const SNAKE_COUNT = 7; +const PREY_COUNT = 11; +const SNAKE_SPEED = 1.15; +const BODY_LEN = 26; +const TURN_RATE = 0.048; +const SNAKE_HUES = [160, 200, 280, 35, 110, 340, 190]; + +function rnd(a, b) { return a + Math.random() * (b - a); } + +export default function AnimatedBg() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext('2d'); + let W = 0, H = 0, raf, t = 0; + + const resize = () => { + W = canvas.width = window.innerWidth; + H = canvas.height = window.innerHeight; + }; + resize(); + window.addEventListener('resize', resize); + + // ── Particles (rising) ──────────────────────────────────────────────────── + const particles = Array.from({ length: 50 }, () => ({ + x: Math.random(), y: Math.random(), + r: rnd(0.5, 2.5), + vy: -(rnd(0.00012, 0.00035)), + hue: Math.random() < 0.5 ? rnd(170, 210) : rnd(260, 290), + a: rnd(0.1, 0.45), + })); + + // ── Prey (glowing food dots) ─────────────────────────────────────────────── + const prey = Array.from({ length: PREY_COUNT }, (_, i) => ({ + x: rnd(80, window.innerWidth - 80), + y: rnd(80, window.innerHeight - 80), + hue: i * 33, + alive: true, + respawnIn: 0, + phase: rnd(0, Math.PI * 2), + wx: rnd(0, Math.PI * 2), // wander angle x + wy: rnd(0, Math.PI * 2), // wander angle y + })); + + // ── Snakes ──────────────────────────────────────────────────────────────── + const snakes = Array.from({ length: SNAKE_COUNT }, (_, i) => { + const sx = rnd(120, window.innerWidth - 120); + const sy = rnd(120, window.innerHeight - 120); + const ang = rnd(0, Math.PI * 2); + return { + x: sx, y: sy, angle: ang, + body: Array.from({ length: BODY_LEN }, (_, j) => [ + sx - Math.cos(ang) * j * 7, + sy - Math.sin(ang) * j * 7, + ]), + hue: SNAKE_HUES[i % SNAKE_HUES.length], + targetIdx: i % PREY_COUNT, + wander: ang, + }; + }); + + // ── Main loop ───────────────────────────────────────────────────────────── + const draw = () => { + raf = requestAnimationFrame(draw); + t += 0.007; + + // ── Background ── + const rg = ctx.createRadialGradient(W / 2, H * 0.42, 0, W / 2, H * 0.42, Math.max(W, H) * 0.85); + rg.addColorStop(0, '#0a1228'); + rg.addColorStop(0.5, '#060914'); + rg.addColorStop(1, '#03060f'); + ctx.fillStyle = rg; + ctx.fillRect(0, 0, W, H); + + // ── Neon block grid (wave-animated) ── + const COLS = 14, ROWS = 9; + const cw = W / COLS, ch = H / ROWS; + for (let c = 0; c < COLS; c++) { + for (let r = 0; r < ROWS; r++) { + const wave = Math.sin(t * 0.9 + c * 0.55 + r * 1.0) * 0.5 + 0.5; + const bright= Math.sin(t * 0.3 + c * 0.4 - r * 0.7) * 0.5 + 0.5; + const hue = 185 + c * 4 + r * 2; + ctx.strokeStyle = `hsla(${hue},85%,65%,${wave * 0.065 + 0.01})`; + ctx.lineWidth = 1; + ctx.strokeRect(c * cw + 2, r * ch + 2, cw - 4, ch - 4); + if (Math.sin(t * 0.55 + c * 1.7 + r * 2.3) > 0.94) { + ctx.fillStyle = `hsla(${hue},90%,70%,${bright * 0.045})`; + ctx.fillRect(c * cw + 2, r * ch + 2, cw - 4, ch - 4); + } + } + } + + // Faint grid lines + ctx.strokeStyle = 'rgba(0,200,255,0.02)'; + ctx.lineWidth = 1; + for (let c = 0; c <= COLS; c++) { + ctx.beginPath(); ctx.moveTo(c * cw, 0); ctx.lineTo(c * cw, H); ctx.stroke(); + } + for (let r = 0; r <= ROWS; r++) { + ctx.beginPath(); ctx.moveTo(0, r * ch); ctx.lineTo(W, r * ch); ctx.stroke(); + } + + // ── Rising particles ── + for (const p of particles) { + p.y += p.vy; + if (p.y < -0.02) { p.y = 1.02; p.x = Math.random(); } + const fade = p.a * (1 - Math.abs(p.y - 0.5) * 1.5); + if (fade <= 0) continue; + ctx.beginPath(); + ctx.arc(p.x * W, p.y * H, p.r, 0, Math.PI * 2); + ctx.fillStyle = `hsla(${p.hue},100%,78%,${fade})`; + ctx.fill(); + } + + // ── Prey ── + prey.forEach((p) => { + if (!p.alive) { + p.respawnIn--; + if (p.respawnIn <= 0) { + p.x = rnd(60, W - 60); + p.y = rnd(60, H - 60); + p.alive = true; + p.hue = rnd(0, 360); + p.wx = rnd(0, Math.PI * 2); + p.wy = rnd(0, Math.PI * 2); + } + return; + } + p.phase += 0.045; + p.wx += (Math.random() - 0.5) * 0.025; + p.wy += (Math.random() - 0.5) * 0.025; + p.x += Math.cos(p.wx) * 0.45; + p.y += Math.sin(p.wy) * 0.45; + p.x = Math.max(30, Math.min(W - 30, p.x)); + p.y = Math.max(30, Math.min(H - 30, p.y)); + + const pulse = 0.5 + 0.5 * Math.sin(p.phase); + const gr = 3 + pulse * 1.8; + + const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, gr * 5); + grd.addColorStop(0, `hsla(${p.hue},100%,78%,0.65)`); + grd.addColorStop(0.4, `hsla(${p.hue},100%,60%,0.2)`); + grd.addColorStop(1, `hsla(${p.hue},100%,40%,0)`); + ctx.beginPath(); + ctx.arc(p.x, p.y, gr * 5, 0, Math.PI * 2); + ctx.fillStyle = grd; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(p.x, p.y, gr, 0, Math.PI * 2); + ctx.fillStyle = `hsla(${p.hue},100%,90%,${0.8 + pulse * 0.2})`; + ctx.fill(); + }); + + // ── Snakes ── + snakes.forEach(s => { + // Find alive target + let tgt = prey[s.targetIdx]; + if (!tgt?.alive) { + const ni = prey.findIndex(p => p.alive); + if (ni >= 0) { s.targetIdx = ni; tgt = prey[ni]; } + } + + if (tgt?.alive) { + const dx = tgt.x - s.x, dy = tgt.y - s.y; + const dist = Math.hypot(dx, dy); + if (dist < 12) { + tgt.alive = false; + tgt.respawnIn = 110; + const ni = prey.findIndex((p, i) => i !== s.targetIdx && p.alive); + if (ni >= 0) s.targetIdx = ni; + } else { + let ta = Math.atan2(dy, dx); + let diff = ta - s.angle; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + s.angle += Math.sign(diff) * Math.min(Math.abs(diff), TURN_RATE); + } + } else { + s.wander += (Math.random() - 0.5) * 0.07; + s.angle += (s.wander - s.angle) * 0.04; + } + + s.x += Math.cos(s.angle) * SNAKE_SPEED; + s.y += Math.sin(s.angle) * SNAKE_SPEED; + if (s.x < -40) s.x = W + 40; + if (s.x > W + 40) s.x = -40; + if (s.y < -40) s.y = H + 40; + if (s.y > H + 40) s.y = -40; + + s.body.unshift([s.x, s.y]); + if (s.body.length > BODY_LEN) s.body.length = BODY_LEN; + + // Body segments (back-to-front so head is on top) + for (let j = s.body.length - 1; j >= 0; j--) { + const [bx, by] = s.body[j]; + const frac = 1 - j / s.body.length; + const r = 1.5 + frac * 4.5; + const a = 0.06 + frac * 0.42; + ctx.beginPath(); + ctx.arc(bx, by, r, 0, Math.PI * 2); + ctx.fillStyle = `hsla(${s.hue},80%,60%,${a})`; + ctx.fill(); + } + + // Head with glow + ctx.shadowColor = `hsl(${s.hue},100%,65%)`; + ctx.shadowBlur = 12; + ctx.beginPath(); + ctx.arc(s.x, s.y, 5.5, 0, Math.PI * 2); + ctx.fillStyle = `hsl(${s.hue},100%,78%)`; + ctx.fill(); + ctx.shadowBlur = 0; + }); + + // ── Diagonal light sweep ── + ctx.save(); + ctx.globalAlpha = 0.018 + Math.sin(t * 0.3) * 0.006; + const ray = ctx.createLinearGradient(0, 0, W, H); + ray.addColorStop(0, 'transparent'); + ray.addColorStop(0.5, '#00e8ff'); + ray.addColorStop(1, 'transparent'); + ctx.fillStyle = ray; + ctx.fillRect(0, 0, W, H); + ctx.restore(); + + // ── Vignette ── + const vig = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.65); + vig.addColorStop(0, 'transparent'); + vig.addColorStop(1, 'rgba(0,0,0,0.6)'); + ctx.fillStyle = vig; + ctx.fillRect(0, 0, W, H); + }; + + draw(); + return () => { cancelAnimationFrame(raf); window.removeEventListener('resize', resize); }; + }, []); + + return ( + + ); +} diff --git a/client/src/components/ArenaPanel.jsx b/client/src/components/ArenaPanel.jsx new file mode 100644 index 0000000..fe03e16 --- /dev/null +++ b/client/src/components/ArenaPanel.jsx @@ -0,0 +1,244 @@ +import { useState } from 'react'; +import { audio } from '../audio/AudioManager.js'; + +const COLORS = [ + { id: 'emerald', label: 'Emerald', head: '#22c55e', body: '#16a34a' }, + { id: 'cobalt', label: 'Cobalt', head: '#3b82f6', body: '#2563eb' }, + { id: 'crimson', label: 'Crimson', head: '#ef4444', body: '#dc2626' }, + { id: 'amber', label: 'Amber', head: '#f59e0b', body: '#d97706' }, + { id: 'violet', label: 'Violet', head: '#a855f7', body: '#9333ea' }, + { id: 'cyber', label: 'Cyber', head: '#06b6d4', body: '#0891b2' }, + { id: 'rose', label: 'Rose', head: '#f43f5e', body: '#e11d48' }, + { id: 'venom', label: 'Venom', head: '#84cc16', body: '#65a30d' }, +]; + +const EMOJIS = ['🐍', '👾', '🤖', '🦦', '🐲', '🦅', '🐺', '💀']; + +const loadAvatar = () => { + try { return JSON.parse(localStorage.getItem('sq_avatar') || '{"colorId":"emerald","emoji":"🐍"}'); } + catch { return { colorId: 'emerald', emoji: '🐍' }; } +}; + +const loadCredentials = () => ({ + name: (() => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } })(), + pin: (() => { try { return localStorage.getItem('sq_last_pin') || ''; } catch { return ''; } })(), +}); + +function Label({ children }) { + return ( +
+ {children} +
+ ); +} + +function SnakePreview({ color, emoji }) { + return ( +
+ {[40, 32, 26, 20, 16].map((size, i) => ( +
+ {i === 0 ? emoji : null} +
+ ))} +
+ ); +} + +export default function ArenaPanel() { + const [avatar, setAvatar] = useState(loadAvatar); + const creds = loadCredentials(); + const [credName, setCredName] = useState(creds.name); + const [credPin, setCredPin] = useState(creds.pin); + const [saved, setSaved] = useState(false); + + const color = COLORS.find(c => c.id === avatar.colorId) || COLORS[0]; + + const saveAvatar = (next) => { + setAvatar(next); + audio.click(); + try { localStorage.setItem('sq_avatar', JSON.stringify(next)); } catch {} + }; + + const saveCredentials = () => { + try { + if (credName.trim()) localStorage.setItem('sq_last_name', credName.trim()); + if (credPin.length === 4) localStorage.setItem('sq_last_pin', credPin); + } catch {} + audio.click(); + setSaved(true); + setTimeout(() => setSaved(false), 2200); + }; + + const credReady = credName.trim().length >= 2 && credPin.length === 4; + + const inputStyle = { + background: 'rgba(255,255,255,0.05)', + border: '1px solid rgba(255,255,255,0.1)', + borderRadius: 10, padding: '11px 14px', + color: '#f1f5f9', fontSize: 14, + outline: 'none', width: '100%', + fontFamily: 'inherit', boxSizing: 'border-box', + }; + + return ( +
+ + {/* ── Snake preview ── */} +
+ +
+
+ {avatar.emoji} {credName || 'Your Snake'} +
+
{color.label} skin
+ {credName && credPin.length === 4 && ( +
✓ Credentials set
+ )} +
+
+ + {/* ── Player Credentials (editable) ── */} +
+
+ PLAYER CREDENTIALS +
+
+ Set your name and PIN here — they'll auto-fill when you create or join a room. +
+ + {/* Name */} +
+ + setCredName(e.target.value)} + onFocus={e => { e.target.style.borderColor = 'rgba(0,245,255,0.35)'; }} + onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.1)'; }} + /> +
+ + {/* PIN */} +
+ + setCredPin(e.target.value.replace(/\D/g, '').slice(0, 4))} + onFocus={e => { e.target.style.borderColor = 'rgba(0,245,255,0.35)'; }} + onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.1)'; }} + /> +
+ PIN locks your identity across sessions. First use registers, return use verifies. +
+
+ + {/* Save button */} + +
+ + {/* ── Color picker ── */} +
+ +
+ {COLORS.map(c => ( +
+
+ + {/* ── Emoji picker ── */} +
+ +
+ {EMOJIS.map(e => ( + + ))} +
+
+ + {/* ── Room browser notice ── */} +
+
🔭
+
Open Room Browser
+
+ Browse and join open rooms from the{' '} + JOIN ROOM tab +
+
+
+ ); +} diff --git a/client/src/components/AuthScreen.jsx b/client/src/components/AuthScreen.jsx new file mode 100644 index 0000000..1dcf5ca --- /dev/null +++ b/client/src/components/AuthScreen.jsx @@ -0,0 +1,290 @@ +import { useState, useEffect, useRef } from 'react'; +import AnimatedBg from './AnimatedBg.jsx'; +import { audio } from '../audio/AudioManager.js'; + +const FONT = "'Space Grotesk','Inter',system-ui,sans-serif"; + +const T = { + teal: '#00d4aa', + tealGlow: 'rgba(0,212,170,0.3)', + tealDim: 'rgba(0,212,170,0.1)', + blue: '#38bdf8', + panel: 'rgba(6,10,24,0.92)', + border: 'rgba(255,255,255,0.07)', + tealBorder: 'rgba(0,212,170,0.3)', + text: '#e8f4ff', + muted: '#4a6080', + error: '#f87171', +}; + +const inp = { + background: 'rgba(4,8,20,0.9)', + border: '1px solid rgba(255,255,255,0.08)', + borderRadius: 10, + padding: '13px 16px', + color: T.text, + fontSize: 15, + outline: 'none', + width: '100%', + fontFamily: FONT, + boxSizing: 'border-box', + transition: 'border-color 0.2s', +}; + +export default function AuthScreen({ wsSend, pendingRef, onAuth }) { + const [name, setName] = useState(() => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } }); + const [pin, setPin] = useState(''); + const [mode, setMode] = useState(null); // null | 'register' | 'login' + const [step, setStep] = useState('name'); // 'name' | 'pin' + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [shake, setShake] = useState(false); + const nameRef = useRef(null); + const pinRef = useRef(null); + + useEffect(() => { nameRef.current?.focus(); }, []); + useEffect(() => { if (step === 'pin') pinRef.current?.focus(); }, [step]); + + const triggerShake = () => { + setShake(true); + setTimeout(() => setShake(false), 500); + }; + + const checkName = async () => { + const n = name.trim(); + if (n.length < 2) { setError('Name must be at least 2 characters.'); triggerShake(); return; } + setError(''); + setLoading(true); + audio.click(); + + const result = await new Promise(resolve => { + pendingRef.current.nameStatus = resolve; + wsSend({ type: 'check_name', name: n }); + setTimeout(() => { + if (pendingRef.current.nameStatus === resolve) { + pendingRef.current.nameStatus = null; + resolve(null); + } + }, 4000); + }); + + setLoading(false); + if (!result) { setError('Connection error. Try again.'); triggerShake(); return; } + setMode(result.isNew ? 'register' : 'login'); + setStep('pin'); + }; + + const submit = () => { + if (pin.length !== 4) { setError('PIN must be 4 digits.'); triggerShake(); return; } + setError(''); + audio.click(); + try { localStorage.setItem('sq_last_name', name.trim()); } catch {} + try { localStorage.setItem('sq_last_pin', pin); } catch {} + onAuth(name.trim(), pin); + }; + + const back = () => { + setStep('name'); + setMode(null); + setPin(''); + setError(''); + setTimeout(() => nameRef.current?.focus(), 50); + }; + + const nameTrimmed = name.trim(); + + return ( +
+ + + {/* Dark overlay */} +
+ + {/* Center card */} +
+
+ + {/* Logo */} +
+
🐍
+
SLITHER QUEST
+
+ {step === 'name' + ? 'ENTER YOUR IDENTITY' + : mode === 'register' + ? '🆕 CREATE YOUR PIN' + : '🔒 WELCOME BACK'} +
+
+ + {/* Step: Name */} + {step === 'name' && ( +
+
+
+ PLAYER NAME +
+ { setName(e.target.value); setError(''); }} + onFocus={e => { e.target.style.borderColor = T.tealBorder; }} + onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; }} + onKeyDown={e => e.key === 'Enter' && !loading && nameTrimmed.length >= 2 && checkName()} + /> +
+ + {error && {error}} + + + {loading ? 'CHECKING…' : 'CONTINUE →'} + +
+ )} + + {/* Step: PIN */} + {step === 'pin' && ( +
+ {/* Who */} +
+
👤
+
+
{name.trim()}
+
+ {mode === 'register' ? 'New player — set your PIN' : 'Returning player — enter PIN'} +
+
+ +
+ +
+
+ 4-DIGIT PIN +
+ { setPin(e.target.value.replace(/\D/g, '').slice(0, 4)); setError(''); }} + onFocus={e => { e.target.style.borderColor = T.tealBorder; }} + onBlur={e => { e.target.style.borderColor = 'rgba(255,255,255,0.08)'; }} + onKeyDown={e => e.key === 'Enter' && pin.length === 4 && submit()} + /> +
+ {mode === 'register' + ? 'Choose a 4-digit PIN — it locks your identity.' + : 'Enter your PIN to verify identity.'} +
+
+ + {error && {error}} + + + {mode === 'register' ? '🎮 CREATE ACCOUNT' : '🔓 LOGIN'} + +
+ )} + + {/* Footer */} +
+ Your PIN is hashed and stored securely. +
+
+
+ + +
+ ); +} + +function Btn({ children, onClick, disabled, loading }) { + return ( + + ); +} + +function ErrorMsg({ children }) { + return ( +
+ ⚠ {children} +
+ ); +} diff --git a/client/src/components/DeathScreen.jsx b/client/src/components/DeathScreen.jsx new file mode 100644 index 0000000..7e8f134 --- /dev/null +++ b/client/src/components/DeathScreen.jsx @@ -0,0 +1,128 @@ +import { useEffect, useState, useRef } from 'react'; +import { audio } from '../audio/AudioManager.js'; + +export default function DeathScreen({ info, onRespawn, sessionStats }) { + const [countdown, setCountdown] = useState(3); + const played = useRef(false); + + useEffect(() => { + setCountdown(3); + played.current = false; + }, [info]); + + useEffect(() => { + if (!played.current) { audio.die(); played.current = true; } + const t = setInterval(() => { + setCountdown(p => { if (p <= 1) { clearInterval(t); return 0; } return p - 1; }); + }, 1000); + return () => clearInterval(t); + }, [info]); + + const ready = countdown === 0; + const fmtTime = s => s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`; + + // SVG countdown ring + const R = 22, CIRC = 2 * Math.PI * R; + const dash = ready ? CIRC : CIRC * (1 - countdown / 3); + + return ( +
+
+ + {/* Skull */} +
💀
+ + {/* Title */} +
YOU DIED
+ + {/* Cause */} + {info && ( +
+ {info.killerName + ?
Devoured by {info.killerName}
+ :
Crashed into the wall
} + {info.length > 0 &&
Length reached {info.length}
} + {info.score > 0 &&
Score {info.score}
} +
+ )} + + {/* Session stats chips */} + {sessionStats && (sessionStats.kills > 0 || sessionStats.survivalSec > 0) && ( +
+ {sessionStats.kills > 0 && } + {sessionStats.survivalSec > 0 && } +
+ )} + + {/* Countdown ring + respawn button */} +
+ {!ready && ( +
+ + + + +
{countdown}
+
+ )} + +
+
+ + +
+ ); +} + +function StatChip({ label, val, color }) { + return ( +
+
{label}
+
{val}
+
+ ); +} diff --git a/client/src/components/GameCanvas.jsx b/client/src/components/GameCanvas.jsx new file mode 100644 index 0000000..60be09b --- /dev/null +++ b/client/src/components/GameCanvas.jsx @@ -0,0 +1,555 @@ +import { useRef, useEffect } from 'react'; + +const GRID_SIZE = 100; + +// ── Grid ────────────────────────────────────────────────────────────────────── + +function drawGrid(ctx, camX, camY, w, h) { + ctx.strokeStyle = 'rgba(255,255,255,0.025)'; + ctx.lineWidth = 1; + const x0 = Math.floor((camX - w / 2) / GRID_SIZE) * GRID_SIZE; + const y0 = Math.floor((camY - h / 2) / GRID_SIZE) * GRID_SIZE; + ctx.beginPath(); + for (let x = x0; x < camX + w / 2 + GRID_SIZE; x += GRID_SIZE) { + ctx.moveTo(x, camY - h / 2); ctx.lineTo(x, camY + h / 2); + } + for (let y = y0; y < camY + h / 2 + GRID_SIZE; y += GRID_SIZE) { + ctx.moveTo(camX - w / 2, y); ctx.lineTo(camX + w / 2, y); + } + ctx.stroke(); +} + +// ── World border ────────────────────────────────────────────────────────────── + +function drawWorldBorder(ctx, worldSize) { + ctx.shadowColor = '#ff3355'; + ctx.shadowBlur = 18; + ctx.strokeStyle = '#ff3355'; + ctx.lineWidth = 5; + ctx.strokeRect(0, 0, worldSize, worldSize); + ctx.shadowBlur = 0; +} + +// ── Pellets ─────────────────────────────────────────────────────────────────── + +const FRUITS = ['🍎', '🍊', '🍋', '🍇', '🍓', '🫐', '🍑', '🍒']; + +function drawPellets(ctx, pellets, frame) { + for (const [id, x, y, hue] of pellets) { + const t = id % 4; + const pulse = 0.6 + 0.4 * Math.sin(frame * 0.06 + id * 0.37); + + if (t === 3) { + // Fruit emoji (every 4th pellet) + ctx.shadowBlur = 0; + ctx.globalAlpha = 0.92; + ctx.font = `${(11 + pulse * 4) | 0}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(FRUITS[id % FRUITS.length], x, y); + ctx.globalAlpha = 1; + ctx.textBaseline = 'alphabetic'; + } else { + // Standard glowing dot + const r = 4 + pulse * 1.8; + ctx.shadowColor = `hsl(${hue},100%,65%)`; + ctx.shadowBlur = 10; + const grd = ctx.createRadialGradient(x, y, 0, x, y, r * 1.8); + grd.addColorStop(0, `hsl(${hue},100%,88%)`); + grd.addColorStop(0.5, `hsl(${hue},90%,62%)`); + grd.addColorStop(1, `hsla(${hue},80%,40%,0)`); + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = grd; + ctx.fill(); + ctx.shadowBlur = 0; + } + } + // Ensure clean state after pellets + ctx.shadowBlur = 0; + ctx.globalAlpha = 1; + ctx.textBaseline = 'alphabetic'; +} + +// ── Snake ───────────────────────────────────────────────────────────────────── + +function drawSnake(ctx, snake, isOwn) { + const body = snake.body; // [[x,y], ...] + const hw = isOwn ? 12 : 9.5; + + // Body polyline (only if we have body data) + if (body && body.length > 1) { + if (isOwn) { + // Outer glow + ctx.beginPath(); + ctx.moveTo(snake.x, snake.y); + for (const [bx, by] of body) ctx.lineTo(bx, by); + ctx.strokeStyle = `${snake.color}44`; + ctx.lineWidth = hw * 2 + 10; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + } + + // Main body + ctx.beginPath(); + ctx.moveTo(snake.x, snake.y); + for (const [bx, by] of body) ctx.lineTo(bx, by); + ctx.strokeStyle = snake.color; + ctx.lineWidth = hw * 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.stroke(); + + // Dark spine + ctx.strokeStyle = 'rgba(0,0,0,0.28)'; + ctx.lineWidth = hw * 0.65; + ctx.stroke(); + + // Light highlight + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = hw * 0.3; + ctx.stroke(); + } + + // Head — always drawn + const headR = hw + 2; + ctx.shadowColor = snake.color; + ctx.shadowBlur = 14; + ctx.beginPath(); + ctx.arc(snake.x, snake.y, headR, 0, Math.PI * 2); + ctx.fillStyle = snake.color; + ctx.fill(); + ctx.shadowBlur = 0; + ctx.strokeStyle = 'rgba(255,255,255,0.4)'; + ctx.lineWidth = 2; + ctx.stroke(); + + // Eyes + const eyeDist = headR * 0.55; + for (const off of [-0.42, 0.42]) { + const ea = snake.dir + off; + const ex = snake.x + Math.cos(ea) * eyeDist; + const ey = snake.y + Math.sin(ea) * eyeDist; + ctx.beginPath(); + ctx.arc(ex, ey, 3.5, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + ctx.beginPath(); + ctx.arc( + ex + Math.cos(snake.dir) * 1.2, + ey + Math.sin(snake.dir) * 1.2, + 1.8, 0, Math.PI * 2, + ); + ctx.fillStyle = '#111'; + ctx.fill(); + } + + // Boost spark at tail + if (snake.boosting && body && body.length > 0) { + const tail = body[body.length - 1]; + ctx.shadowColor = snake.color; + ctx.shadowBlur = 16; + ctx.beginPath(); + ctx.arc(tail[0], tail[1], 7, 0, Math.PI * 2); + ctx.fillStyle = `${snake.color}bb`; + ctx.fill(); + ctx.shadowBlur = 0; + } + + // Name label + const ly = snake.y - headR - 11; + ctx.font = isOwn ? 'bold 14px sans-serif' : '12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'alphabetic'; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.fillText(snake.name, snake.x + 1, ly + 1); + ctx.fillStyle = isOwn ? '#ffe566' : '#e2e8f0'; + ctx.fillText(snake.name, snake.x, ly); +} + +// ── Explosion system ────────────────────────────────────────────────────────── + +function addExplosion(list, x, y, color) { + const COUNT = 32; + const particles = []; + for (let i = 0; i < COUNT; i++) { + const angle = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.5; + const spd = 1.5 + Math.random() * 7; + const spark = Math.random() < 0.4; + particles.push({ + x, y, + vx: Math.cos(angle) * spd, + vy: Math.sin(angle) * spd, + r: 2 + Math.random() * 3, + col: spark ? `hsl(${20 + Math.random() * 35 | 0},100%,65%)` : color, + life: 1.0, + dec: 0.015 + Math.random() * 0.012, + }); + } + list.push({ particles, ring: 0, ringMax: 65, x, y, color }); +} + +function updateDrawExplosions(ctx, list) { + for (let i = list.length - 1; i >= 0; i--) { + const e = list[i]; + let alive = false; + + // Expanding ring + if (e.ring < e.ringMax) { + e.ring += 3.5; + const alpha = (1 - e.ring / e.ringMax) * 0.65; + ctx.globalAlpha = alpha; + ctx.beginPath(); + ctx.arc(e.x, e.y, e.ring, 0, Math.PI * 2); + ctx.strokeStyle = e.color; + ctx.lineWidth = 2.5; + ctx.stroke(); + ctx.globalAlpha = 1; + alive = true; + } + + // Particles + for (const p of e.particles) { + if (p.life <= 0) continue; + alive = true; + p.x += p.vx; p.y += p.vy; + p.vx *= 0.93; p.vy *= 0.93; + p.life -= p.dec; + ctx.globalAlpha = Math.max(0, p.life); + ctx.beginPath(); + ctx.arc(p.x, p.y, Math.max(0.5, p.r * p.life), 0, Math.PI * 2); + ctx.fillStyle = p.col; + ctx.fill(); + } + + ctx.globalAlpha = 1; + if (!alive) list.splice(i, 1); + } +} + +// ── Minimap ─────────────────────────────────────────────────────────────────── + +function rrPath(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +function drawMinimap(ctx, snap, mySnake, cw, ch) { + const SIZE = 180, PAD = 16, R = 12; + const mx = cw - SIZE - PAD; + const my = ch - SIZE - PAD; + const scale = SIZE / snap.worldSize; + + // ── Clipped map interior ── + ctx.save(); + rrPath(ctx, mx, my, SIZE, SIZE, R); + ctx.clip(); + + ctx.globalAlpha = 0.88; + ctx.fillStyle = 'rgba(4,8,24,0.95)'; + ctx.fillRect(mx, my, SIZE, SIZE); + + // Grid + ctx.globalAlpha = 0.09; + ctx.strokeStyle = '#00f5ff'; + ctx.lineWidth = 0.5; + ctx.beginPath(); + for (let i = 1; i < 4; i++) { + const step = SIZE / 4; + ctx.moveTo(mx + i * step, my); ctx.lineTo(mx + i * step, my + SIZE); + ctx.moveTo(mx, my + i * step); ctx.lineTo(mx + SIZE, my + i * step); + } + ctx.stroke(); + + // World border hint + ctx.globalAlpha = 0.3; + ctx.strokeStyle = '#ff3355'; + ctx.lineWidth = 1.5; + ctx.strokeRect(mx + 1, my + 1, SIZE - 2, SIZE - 2); + + // Pellets + ctx.globalAlpha = 0.65; + for (const [, px, py, hue] of snap.pellets) { + ctx.fillStyle = `hsl(${hue ?? 180},100%,72%)`; + ctx.fillRect(mx + px * scale - 1, my + py * scale - 1, 2, 2); + } + + // Other snakes + ctx.globalAlpha = 0.9; + for (const s of snap.snakes) { + if (s.id === mySnake?.id) continue; + ctx.beginPath(); + ctx.arc(mx + s.x * scale, my + s.y * scale, 2.5, 0, Math.PI * 2); + ctx.fillStyle = s.color; + ctx.fill(); + } + + // Viewport rect + if (mySnake) { + const vx = mx + (mySnake.x - cw / 2) * scale; + const vy = my + (mySnake.y - ch / 2) * scale; + ctx.globalAlpha = 0.14; + ctx.fillStyle = '#00f5ff'; + ctx.fillRect(vx, vy, cw * scale, ch * scale); + ctx.globalAlpha = 0.5; + ctx.strokeStyle = '#00f5ff'; + ctx.lineWidth = 0.8; + ctx.strokeRect(vx, vy, cw * scale, ch * scale); + } + + // Own snake marker + direction arrow + if (mySnake) { + const sx = mx + mySnake.x * scale; + const sy = my + mySnake.y * scale; + const dir = mySnake.dir ?? 0; + + ctx.globalAlpha = 1; + ctx.shadowColor = '#fff'; + ctx.shadowBlur = 6; + ctx.beginPath(); + ctx.arc(sx, sy, 3.5, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.fill(); + ctx.shadowBlur = 0; + + // Arrow tip + const tip = { x: sx + Math.cos(dir) * 10, y: sy + Math.sin(dir) * 10 }; + const l = { x: sx + Math.cos(dir + 2.5) * 2.5, y: sy + Math.sin(dir + 2.5) * 2.5 }; + const r2 = { x: sx + Math.cos(dir - 2.5) * 2.5, y: sy + Math.sin(dir - 2.5) * 2.5 }; + ctx.globalAlpha = 0.95; + ctx.beginPath(); + ctx.moveTo(tip.x, tip.y); + ctx.lineTo(l.x, l.y); + ctx.lineTo(r2.x, r2.y); + ctx.closePath(); + ctx.fillStyle = '#00f5ff'; + ctx.fill(); + } + + ctx.globalAlpha = 1; + ctx.restore(); + + // ── Glowing border + labels (outside clip) ── + ctx.save(); + ctx.shadowColor = '#00c8ff'; + ctx.shadowBlur = 12; + ctx.strokeStyle = 'rgba(0,200,255,0.85)'; + ctx.lineWidth = 1.5; + rrPath(ctx, mx, my, SIZE, SIZE, R); + ctx.stroke(); + ctx.shadowBlur = 0; + + ctx.globalAlpha = 0.7; + ctx.fillStyle = '#00f5ff'; + ctx.font = 'bold 8px monospace'; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ctx.fillText('MAP', mx + 9, my + 6); + + ctx.globalAlpha = 0.5; + ctx.fillStyle = '#94a3b8'; + ctx.textAlign = 'right'; + ctx.fillText(`${snap.snakes.length}P`, mx + SIZE - 9, my + 6); + + ctx.globalAlpha = 1; + ctx.restore(); +} + +// ── Component ───────────────────────────────────────────────────────────────── + +export default function GameCanvas({ snapshot, playerId, onInput, active }) { + const canvasRef = useRef(null); + const snapshotRef = useRef(null); + const playerIdRef = useRef(null); + const boostRef = useRef(false); + const rafRef = useRef(null); + const frameRef = useRef(0); + const explosionsRef = useRef([]); + const prevSnakesRef = useRef(new Map()); + + useEffect(() => { snapshotRef.current = snapshot; }, [snapshot]); + useEffect(() => { playerIdRef.current = playerId; }, [playerId]); + + // ── Input ────────────────────────────────────────────────────────────────── + + const keysRef = useRef(new Set()); + const mouseDirRef = useRef(0); + + useEffect(() => { + if (!active) return; + const canvas = canvasRef.current; + if (!canvas) return; + + const keyDir = () => { + const k = keysRef.current; + let dx = 0, dy = 0; + if (k.has('ArrowUp') || k.has('KeyW')) dy -= 1; + if (k.has('ArrowDown') || k.has('KeyS')) dy += 1; + if (k.has('ArrowLeft') || k.has('KeyA')) dx -= 1; + if (k.has('ArrowRight') || k.has('KeyD')) dx += 1; + if (dx === 0 && dy === 0) return null; + return Math.atan2(dy, dx); + }; + + let lastMouseSend = 0; + const onMouseMove = (e) => { + const now = Date.now(); + if (now - lastMouseSend < 30) return; + lastMouseSend = now; + const rect = canvas.getBoundingClientRect(); + mouseDirRef.current = Math.atan2( + e.clientY - rect.top - canvas.height / 2, + e.clientX - rect.left - canvas.width / 2, + ); + onInput(mouseDirRef.current, boostRef.current); + }; + + const onMouseDown = (e) => { + if (e.button === 0) { boostRef.current = true; onInput(mouseDirRef.current, true); } + }; + const onMouseUp = (e) => { + if (e.button === 0) { boostRef.current = false; onInput(mouseDirRef.current, false); } + }; + + const DIRS = new Set(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','KeyW','KeyA','KeyS','KeyD']); + const onKeyDown = (e) => { + if (DIRS.has(e.code)) { + e.preventDefault(); + keysRef.current.add(e.code); + const d = keyDir(); + if (d !== null) onInput(d, boostRef.current); + } + if (e.code === 'Space') { + e.preventDefault(); + boostRef.current = true; + onInput(keyDir() ?? mouseDirRef.current, true); + } + }; + const onKeyUp = (e) => { + keysRef.current.delete(e.code); + if (e.code === 'Space') { boostRef.current = false; onInput(mouseDirRef.current, false); } + const d = keyDir(); + if (d !== null) onInput(d, boostRef.current); + }; + + const keyInterval = setInterval(() => { + const d = keyDir(); + if (d !== null) onInput(d, boostRef.current); + }, 33); + + canvas.addEventListener('mousemove', onMouseMove); + canvas.addEventListener('mousedown', onMouseDown); + canvas.addEventListener('mouseup', onMouseUp); + window.addEventListener('keydown', onKeyDown); + window.addEventListener('keyup', onKeyUp); + return () => { + clearInterval(keyInterval); + canvas.removeEventListener('mousemove', onMouseMove); + canvas.removeEventListener('mousedown', onMouseDown); + canvas.removeEventListener('mouseup', onMouseUp); + window.removeEventListener('keydown', onKeyDown); + window.removeEventListener('keyup', onKeyUp); + }; + }, [active, onInput]); + + // ── Render loop ──────────────────────────────────────────────────────────── + + useEffect(() => { + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + + const resize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + }; + resize(); + window.addEventListener('resize', resize); + + const render = () => { + rafRef.current = requestAnimationFrame(render); + frameRef.current++; + + const snap = snapshotRef.current; + const w = canvas.width; + const h = canvas.height; + + if (!snap) { + ctx.fillStyle = '#080c18'; + ctx.fillRect(0, 0, w, h); + ctx.fillStyle = '#334155'; + ctx.font = '18px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Connecting…', w / 2, h / 2); + ctx.textBaseline = 'alphabetic'; + return; + } + + // ── Detect snake deaths → explosions ── + const curMap = new Map(snap.snakes.map(s => [s.id, s])); + if (prevSnakesRef.current.size > 0) { + for (const [id, prev] of prevSnakesRef.current) { + if (!curMap.has(id)) { + addExplosion(explosionsRef.current, prev.x, prev.y, prev.color); + } + } + } + prevSnakesRef.current = new Map( + snap.snakes.map(s => [s.id, { x: s.x, y: s.y, color: s.color }]) + ); + + const mySnake = snap.snakes.find(s => s.id === playerIdRef.current); + const camX = mySnake ? mySnake.x : snap.worldSize / 2; + const camY = mySnake ? mySnake.y : snap.worldSize / 2; + + // ── Background ── + ctx.fillStyle = '#080c18'; + ctx.fillRect(0, 0, w, h); + + // ── World transform ── + ctx.save(); + ctx.translate(w / 2 - camX, h / 2 - camY); + + drawGrid(ctx, camX, camY, w, h); + drawWorldBorder(ctx, snap.worldSize); + drawPellets(ctx, snap.pellets, frameRef.current); + + // Draw snakes — others first, own on top + for (const s of snap.snakes) { + if (s.id !== playerIdRef.current) drawSnake(ctx, s, false); + } + if (mySnake) drawSnake(ctx, mySnake, true); + + // Explosions + updateDrawExplosions(ctx, explosionsRef.current); + + ctx.restore(); + + // ── Minimap ── + drawMinimap(ctx, snap, mySnake, w, h); + }; + + rafRef.current = requestAnimationFrame(render); + return () => { + cancelAnimationFrame(rafRef.current); + window.removeEventListener('resize', resize); + }; + }, []); + + return ( + + ); +} diff --git a/client/src/components/GameOverScreen.jsx b/client/src/components/GameOverScreen.jsx new file mode 100644 index 0000000..d9cd346 --- /dev/null +++ b/client/src/components/GameOverScreen.jsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from 'react'; +import { audio } from '../audio/AudioManager.js'; + +const MEDAL_ICONS = ['\uD83E\uDD47', '\uD83E\uDD48', '\uD83E\uDD49']; +const PODIUM_COLS = ['#fbbf24', '#e2e8f0', '#cd7f32']; +const PODIUM_H = [120, 90, 70]; +// Display order: 2nd | 1st | 3rd +const PODIUM_ORDER = [1, 0, 2]; + +export default function GameOverScreen({ leaderboard = [], winner, onClose }) { + const [countdown, setCountdown] = useState(10); + + useEffect(() => { + audio.join(); + const t = setInterval(() => { + setCountdown(p => { + if (p <= 1) { clearInterval(t); onClose?.(); return 0; } + return p - 1; + }); + }, 1000); + return () => clearInterval(t); + }, []); + + const top3 = leaderboard.slice(0, 3); + + return ( +
+
+ + {/* Header */} +
+
🏆
+
ROUND OVER
+ {winner && ( +
+ {winner.name} + {' '}wins with{' '} + {winner.score} points! +
+ )} +
+ + {/* Podium */} + {top3.length > 0 && ( +
+ {PODIUM_ORDER.map((rank, vi) => { + const p = top3[rank]; + if (!p) return
; + return ( +
+
{p.name}
+
{p.score} pts
+
+ {MEDAL_ICONS[rank]} +
+
+ ); + })} +
+ )} + + {/* Leaderboard rows 4+ */} + {leaderboard.length > 3 && ( +
+ {leaderboard.slice(3).map((p, i) => ( +
+ #{i + 4} + {p.name} + {p.score} +
+ ))} +
+ )} + + {/* Countdown */} +
+
+ NEW ROUND STARTING IN +
+
{countdown}
+
+ All players auto-respawn — get ready! +
+
+
+ + +
+ ); +} diff --git a/client/src/components/HUD.jsx b/client/src/components/HUD.jsx new file mode 100644 index 0000000..d77f832 --- /dev/null +++ b/client/src/components/HUD.jsx @@ -0,0 +1,290 @@ +import { useState, useEffect } from 'react'; + +const FONT = "'Space Grotesk','Inter',system-ui,sans-serif"; + +const BASE = { + position: 'absolute', top: 0, left: 0, + width: '100%', height: '100%', + pointerEvents: 'none', userSelect: 'none', + fontFamily: FONT, +}; + +// ── Round timer ─────────────────────────────────────────────────────────────── + +function GameTimer({ timeLeftMs }) { + if (timeLeftMs === null || timeLeftMs === undefined) return null; + const total = Math.max(0, Math.ceil(timeLeftMs / 1000)); + const m = Math.floor(total / 60); + const s = total % 60; + const urgent = total <= 30; + const warn = total <= 60; + + return ( +
+
ROUND
+
{`${m}:${String(s).padStart(2, '0')}`}
+
+ ); +} + +// ── Leaderboard ─────────────────────────────────────────────────────────────── + +const MEDALS = ['🥇', '🥈', '🥉']; + +function Leaderboard({ leaderboard, playerId }) { + return ( +
+
LEADERBOARD
+ + {leaderboard.length === 0 && ( +
No players yet
+ )} + + {leaderboard.map((e, i) => { + const isMe = e.id === playerId; + return ( +
+ {/* Rank */} + + {i < 3 ? MEDALS[i] : #{i + 1}} + + + {/* Color dot */} +
+ + {/* Name */} + {e.name} + + {/* Score */} + {e.score} +
+ ); + })} +
+ ); +} + +// ── Killfeed ────────────────────────────────────────────────────────────────── + +function Killfeed({ entries }) { + return ( +
+ {entries.map((e, i) => ( +
+ {e.killerName + ? <>{e.killerName} + + {e.victimName} + +{e.length} + : <>{e.victimName} + hit the wall} +
+ ))} +
+ ); +} + +// ── Score bar ───────────────────────────────────────────────────────────────── + +function ScoreBar({ snapshot, playerId }) { + const me = snapshot?.snakes?.find(s => s.id === playerId); + if (!me) return null; + return ( +
+ +
+ + {me.boosting && ( + <> +
+
⚡ BOOST
+ + )} +
+ ); +} + +function Pill({ label, value, color }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +// ── Room badge ──────────────────────────────────────────────────────────────── + +function RoomBadge({ roomCode }) { + const [copied, setCopied] = useState(false); + if (!roomCode) return null; + const copy = () => { + navigator.clipboard.writeText(roomCode).catch(() => {}); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + return ( +
+
ROOM
+
{copied ? '✓ COPIED' : roomCode}
+
+ ); +} + +// ── Greeting banner ─────────────────────────────────────────────────────────── + +function Greeting({ text, onDismiss }) { + const [vis, setVis] = useState(true); + useEffect(() => { + const t = setTimeout(() => { setVis(false); setTimeout(onDismiss, 500); }, 5500); + return () => clearTimeout(t); + }, []); + if (!text) return null; + return ( +
{text}
+ ); +} + +// ── Controls hint ───────────────────────────────────────────────────────────── + +function ControlsHint() { + return ( +
+ Steer: mouse / WASD / arrows
+ Boost: hold click / space +
+ ); +} + +// ── Main HUD ────────────────────────────────────────────────────────────────── + +export default function HUD({ + leaderboard, killfeed, snapshot, playerId, + roomCode, greeting, onGreetingDismiss, timeLeftMs, +}) { + return ( +
+ + + + + + {greeting && } + + + +
+ ); +} diff --git a/client/src/components/HallOfFame.jsx b/client/src/components/HallOfFame.jsx new file mode 100644 index 0000000..a605267 --- /dev/null +++ b/client/src/components/HallOfFame.jsx @@ -0,0 +1,154 @@ +import { useState } from 'react'; +import { audio } from '../audio/AudioManager.js'; + +const TABS = [ + { id: 'global', label: 'Global All-Time', icon: '\uD83C\uDF0D' }, + { id: 'weekly', label: 'Weekly Arena', icon: '\uD83D\uDCC5' }, + { id: 'daily', label: 'Daily Blitz', icon: '\u26A1' }, +]; + +export default function HallOfFame({ stats }) { + const [tab, setTab] = useState('global'); + return ( +
+
+ {TABS.map(t => ( + + ))} +
+ {tab === 'global' && } + {tab !== 'global' && } +
+ ); +} + +function GlobalTab({ stats }) { + const fmtTime = s => { + if (!s) return '--'; + const m = Math.floor(s / 60), sec = s % 60; + return m > 0 ? `${m}m ${sec}s` : `${s}s`; + }; + const hasData = stats.gamesPlayed > 0; + const rows = [ + { label: 'Best Score', val: stats.highScore, color: '#fbbf24', icon: '\u2B50' }, + { label: 'Record Length', val: stats.highLength, color: '#22c55e', icon: '\uD83D\uDC0D' }, + { label: 'Best Rank', val: stats.bestRank !== null ? `#${stats.bestRank}` : '--', color: '#a855f7', icon: '\uD83D\uDC51' }, + { label: 'Longest Run', val: fmtTime(stats.highSurvivalSec), color: '#06b6d4', icon: '\u23F1\uFE0F' }, + { label: 'Total Kills', val: stats.totalKills, color: '#ef4444', icon: '\uD83D\uDC80' }, + { label: 'Games Played', val: stats.gamesPlayed, color: '#f97316', icon: '\uD83C\uDFAE' }, + ]; + + return ( +
+ {!hasData ? ( +
+
🏆
+
No records yet
+
Play your first game to claim the throne
+
+ ) : ( + <> + {/* Hero card */} +
+
+
👑
+
+
PERSONAL BEST
+
+ {stats.highScore} + points +
+
+
+
Length record
+
{stats.highLength}
+
+
+ + {/* Stat grid */} +
+ {rows.map(s => ( +
+
+
{s.label}
+
{s.icon}
+
+
{s.val}
+
+ ))} +
+ + )} + +
+ 🌐 +
+ Global rankings — cross-server aggregation coming soon. Stats saved locally. +
+
+ + +
+ ); +} + +function ComingSoon({ tab }) { + const t = TABS.find(x => x.id === tab); + return ( +
+
{t?.icon}
+
{t?.label}
+
+ {tab === 'weekly' + ? 'Weekly Arena resets every Monday. Compete globally for the highest weekly rank.' + : 'Daily Blitz — ultra-competitive 5-minute ranked sessions, reset every midnight.'} +
+
COMING SOON
+
+ ); +} diff --git a/client/src/components/JoinScreen.jsx b/client/src/components/JoinScreen.jsx new file mode 100644 index 0000000..f8972d1 --- /dev/null +++ b/client/src/components/JoinScreen.jsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react'; + +const HTTP_URL = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001'; + +export default function JoinScreen({ onJoin, error }) { + const [name, setName] = useState(''); + const [password, setPassword] = useState(''); + const [needsPassword, setNeedsPassword] = useState(false); + + // Ask server if this room needs a password + useEffect(() => { + fetch(`${HTTP_URL}/api/health`) + .then(r => r.json()) + .then(d => setNeedsPassword(!!d.passwordRequired)) + .catch(() => {}); + }, []); + + const ready = name.trim() && (!needsPassword || password.length > 0); + + const submit = (e) => { + e.preventDefault(); + if (ready) onJoin(name.trim(), password); + }; + + const inputStyle = { + background: 'rgba(255,255,255,0.06)', + border: '1px solid rgba(255,255,255,0.15)', + borderRadius: 10, padding: '12px 16px', + color: '#fff', fontSize: 16, + outline: 'none', width: '100%', + }; + + return ( +
+ {/* Decorative grid */} + + + + + + + + + +
+ {/* Title */} +
+
+ SLITHER QUEST +
+
+ Eat pellets • Grow • Outlast everyone +
+
+ + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Form */} +
+ setName(e.target.value)} + style={inputStyle} + /> + + {needsPassword && ( + setPassword(e.target.value)} + style={{ ...inputStyle, borderColor: 'rgba(255,200,100,0.3)' }} + /> + )} + + +
+ + {needsPassword && ( +
+ 🔒 Private room — password required +
+ )} + + {/* Tips */} +
+ Steer: mouse or WASD / arrow keys  •  Boost: hold click or space
+ Boost burns length but drops pellets  •  Hit another snake to die +
+
+
+ ); +} diff --git a/client/src/components/LobbyScreen.jsx b/client/src/components/LobbyScreen.jsx new file mode 100644 index 0000000..f88f23f --- /dev/null +++ b/client/src/components/LobbyScreen.jsx @@ -0,0 +1,701 @@ +import { useState, useEffect, useCallback } from 'react'; +import AnimatedBg from './AnimatedBg.jsx'; +import ProfilePanel from './ProfilePanel.jsx'; +import ArenaPanel from './ArenaPanel.jsx'; +import HallOfFame from './HallOfFame.jsx'; +import { audio } from '../audio/AudioManager.js'; + +const HTTP_BASE = (() => { + const ws = import.meta.env.VITE_WS_URL || ''; + if (ws) return ws.replace('wss://', 'https://').replace('ws://', 'http://'); + return typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3001'; +})(); + +// ── Design system ───────────────────────────────────────────────────────────── +const T = { + teal: '#00d4aa', + tealGlow: 'rgba(0,212,170,0.25)', + tealDim: 'rgba(0,212,170,0.12)', + tealBorder: 'rgba(0,212,170,0.28)', + blue: '#38bdf8', + purple: '#a78bfa', + panel: 'rgba(8,13,30,0.96)', + input: 'rgba(4,8,20,0.9)', + glass: 'rgba(10,16,34,0.85)', + border: 'rgba(255,255,255,0.07)', + text: '#e8f4ff', + muted: '#4a6080', + faint: '#1e2d42', +}; + +const FONT = "'Space Grotesk','Inter',system-ui,sans-serif"; + +const SECTIONS = [ + { id: 'play', label: 'PLAY', icon: '▶' }, + { id: 'arena', label: 'ARENA', icon: '🎮' }, + { id: 'profile', label: 'PROFILE', icon: '👤' }, + { id: 'hall', label: 'HALL OF FAME', icon: '🏆' }, +]; + +// ── Base input style ────────────────────────────────────────────────────────── +const INPUT = { + background: T.input, + border: `1px solid ${T.border}`, + borderRadius: 10, padding: '12px 16px', + color: T.text, fontSize: 15, + outline: 'none', width: '100%', + fontFamily: FONT, boxSizing: 'border-box', + transition: 'border-color 0.2s', +}; + +// ── Shared atoms ────────────────────────────────────────────────────────────── + +function Label({ children, color }) { + return ( +
{children}
+ ); +} + +function FInput({ label, hint, style: extra, ...props }) { + const [focused, setFocused] = useState(false); + return ( +
+ {label && } + setFocused(true)} + onBlur={() => setFocused(false)} + {...props} + /> + {hint && ( +
{hint}
+ )} +
+ ); +} + +function Btn({ children, onClick, disabled, variant = 'primary', small = false }) { + const variants = { + primary: { + background: disabled + ? 'rgba(255,255,255,0.06)' + : `linear-gradient(135deg, ${T.teal} 0%, #00b0e0 100%)`, + color: disabled ? T.muted : '#040e18', + boxShadow: disabled ? 'none' : `0 0 28px ${T.tealGlow}`, + border: 'none', + }, + secondary: { + background: 'transparent', + color: T.muted, + boxShadow: 'none', + border: `1px solid ${T.border}`, + }, + ghost: { + background: T.tealDim, + color: T.teal, + boxShadow: 'none', + border: `1px solid ${T.tealBorder}`, + }, + }; + const v = variants[variant] || variants.primary; + return ( + + ); +} + +function ErrBox({ msg }) { + if (!msg) return null; + return ( +
{msg}
+ ); +} + +function Divider({ label }) { + return ( +
+
+ {label && {label}} +
+
+ ); +} + +// ── Create Tab ──────────────────────────────────────────────────────────────── + +function CreateTab({ onCreate, error, loading, createdCode, onCopyCode }) { + const savedName = () => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } }; + const savedPin = () => { try { return localStorage.getItem('sq_last_pin') || ''; } catch { return ''; } }; + const name = savedName(); + const pin = savedPin(); + const [roomPw, setRoomPw] = useState(''); + const ready = name.trim().length >= 2 && pin.length === 4; + + const submit = (e) => { + e.preventDefault(); + if (!ready || loading) return; + onCreate(name.trim(), pin, roomPw.trim() || null); + }; + + if (createdCode) { + return ( +
+
+ SHARE THIS CODE WITH FRIENDS +
+
{createdCode}
+ +
Entering arena…
+
+ ); + } + + return ( +
+ + {/* Name/PIN shown as read-only since auth already captured them */} +
+ 👤 +
+
{name || 'Unknown'}
+
AUTHENTICATED · PIN VERIFIED
+
+
+
+ setRoomPw(e.target.value)} autoFocus /> + + {loading ? 'Creating…' : 'CREATE ROOM →'} + + + ); +} + +// ── Open Rooms Browser ──────────────────────────────────────────────────────── + +function fmtLeft(ms) { + if (!ms || ms <= 0) return 'N/A'; + const s = Math.floor(ms / 1000); + return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; +} + +function OpenRoomsBrowser({ onSelectRoom }) { + const [rooms, setRooms] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); setError(null); + try { + const data = await fetch(`${HTTP_BASE}/api/rooms`).then(r => r.json()); + setRooms(Array.isArray(data) ? data : []); + } catch { setError('Could not reach server'); setRooms([]); } + setLoading(false); + }, []); + + useEffect(() => { refresh(); }, []); + + return ( +
+
+ + +
+ + {loading && ( +
+ Scanning rooms… +
+ )} + {error &&
{error}
} + + {!loading && rooms?.length === 0 && ( +
+
🌌
+
No open rooms right now
+
Create one and invite friends!
+
+ )} + + {!loading && rooms?.map(r => ( + + ))} +
+ ); +} + +// ── Join Tab ────────────────────────────────────────────────────────────────── + +function JoinTab({ onJoin, onCheckRoom, error, loading }) { + const savedName = () => { try { return localStorage.getItem('sq_last_name') || ''; } catch { return ''; } }; + const savedPin = () => { try { return localStorage.getItem('sq_last_pin') || ''; } catch { return ''; } }; + const name = savedName(); + const pin = savedPin(); + const [mode, setMode] = useState('code'); + const [code, setCode] = useState(''); + const [roomPw, setRoomPw] = useState(''); + const [info, setInfo] = useState(null); + const [checking, setChecking] = useState(false); + + useEffect(() => { + if (code.length === 4) { + setChecking(true); + onCheckRoom(code).then(r => { setInfo(r); setChecking(false); }); + } else { setInfo(null); } + }, [code]); + + const ready = code.length === 4 && name.trim().length >= 2 && pin.length === 4 + && info?.exists && (!info?.passwordRequired || roomPw.length > 0); + + const submit = (e) => { + e.preventDefault(); + if (!ready || loading) return; + try { localStorage.setItem('sq_last_name', name.trim()); } catch {} + onJoin(code, name.trim(), pin, roomPw.trim() || ''); + }; + + const ModeBtn = ({ id, label }) => ( + + ); + + return ( +
+ +
+ + +
+ + {mode === 'browse' ? ( + { setCode(c); setMode('code'); }} /> + ) : ( +
+ + setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 4))} + style={{ ...INPUT, letterSpacing: 12, textAlign: 'center', fontSize: 28, fontWeight: 900 }} + /> + {code.length === 4 && ( +
+ {checking ? '⏳ Checking…' + : info?.exists + ? (info.passwordRequired ? '🔒 Room found — password required' : '✅ Room found — join below') + : '✗ Room not found'} +
+ )} +
+ )} + + {mode === 'code' && ( + <> +
+ 👤 +
+
{name || 'Unknown'}
+
AUTHENTICATED · PIN VERIFIED
+
+
+
+ {info?.passwordRequired && ( + setRoomPw(e.target.value)} /> + )} + + {loading ? 'Joining…' : 'JOIN ROOM →'} + + + )} + + ); +} + +// ── Play Section ────────────────────────────────────────────────────────────── + +function PlaySection({ onCreate, onJoin, onCheckRoom, error, loading, createdCode }) { + const [sub, setSub] = useState('create'); + + return ( +
+ + {/* Pill badge */} +
+
+ + 5-MIN ROUNDS · REAL-TIME · MULTIPLAYER +
+
+ + {/* Create / Join switcher */} + {!createdCode && ( +
+ {[['create', '⊕ CREATE ROOM'], ['join', '→ JOIN ROOM']].map(([id, label]) => ( + + ))} +
+ )} + + {(sub === 'create' || createdCode) && ( + { navigator.clipboard.writeText(createdCode).catch(() => {}); audio.click(); }} /> + )} + {sub === 'join' && !createdCode && ( + + )} + +
+ Steer: mouse / WASD / arrows  ·  Boost: hold click or space +
+
+ ); +} + +// ── Section header ──────────────────────────────────────────────────────────── + +function SectionHeader({ icon, title, sub, color }) { + return ( +
+
{icon}
+
+
{title}
+ {sub &&
{sub}
} +
+
+ ); +} + +// ── Glass panel ─────────────────────────────────────────────────────────────── + +function Panel({ children }) { + return ( +
+ {children} +
+ ); +} + +// ── Main lobby ──────────────────────────────────────────────────────────────── + +export default function LobbyScreen({ + onCreate, onJoin, onCheckRoom, + error, loading, createdCode, + stats, unlocked, + playerName, onSwitchAuth, +}) { + const [section, setSection] = useState('play'); + const [muted, setMuted] = useState(false); + + const go = (id) => { audio.navSwitch(); audio.resume(); setSection(id); }; + const toggleMute = () => { + const next = !muted; + setMuted(next); + audio.setMuted(next); + audio.click(); + }; + + const sectionTitle = { + play: ['⚔️', 'Enter the Arena', 'Create a room or join an existing one', T.teal], + arena: ['🎨', 'Player Arena', 'Set your credentials, skin & avatar', T.purple], + profile: ['👤', 'Player Profile', 'Your stats and achievements', '#fbbf24'], + hall: ['🏆', 'Hall of Fame', 'Your personal records', '#fbbf24'], + }; + + return ( +
+ + +
+ + {/* ════ Navbar ════ */} + + + {/* ════ Content ════ */} +
+
+ {section === 'play' && ( + + + + + )} + + {section === 'arena' && ( + + + + + )} + + {section === 'profile' && ( + + + + + )} + + {section === 'hall' && ( + + + + + )} +
+
+
+ + +
+ ); +} diff --git a/client/src/components/ProfilePanel.jsx b/client/src/components/ProfilePanel.jsx new file mode 100644 index 0000000..fa2869a --- /dev/null +++ b/client/src/components/ProfilePanel.jsx @@ -0,0 +1,150 @@ +import { ACHIEVEMENTS } from '../hooks/useAchievements.js'; + +const fmtTime = sec => { + if (!sec) return '--'; + const m = Math.floor(sec / 60), s = sec % 60; + return m > 0 ? `${m}m ${s}s` : `${s}s`; +}; + +function SectionLabel({ children }) { + return ( +
+
+ {children} +
+
+ ); +} + +function StatCard({ icon, label, value, color }) { + return ( +
+ {/* Glow bleed */} +
+ {icon} +
{value}
+
{label}
+
+ ); +} + +function AchCard({ ach, isUnlocked }) { + return ( +
+ {isUnlocked && ( +
+ )} + + {ach.icon} + +
+
+ {ach.name} +
+
{ach.desc}
+
+
+ {isUnlocked ? ( +
DONE
+ ) : ( + 🔒 + )} +
+
+ ); +} + +export default function ProfilePanel({ stats, unlocked }) { + const avg = stats.gamesPlayed > 0 ? Math.round(stats.totalScore / stats.gamesPlayed) : 0; + const doneCount = [...unlocked].length; + + return ( +
+ + {/* ── Stats ── */} +
+ PLAYER STATS +
+ + + + +
+
+ + + +
+
+ + {/* ── Achievements ── */} +
+ ACHIEVEMENTS {doneCount}/{ACHIEVEMENTS.length} + {/* Progress bar */} +
+
+
+
+
+ {doneCount === ACHIEVEMENTS.length ? '🎉 ALL UNLOCKED' : `${ACHIEVEMENTS.length - doneCount} remaining`} +
+
+
+ {ACHIEVEMENTS.map(a => ( + + ))} +
+
+ + +
+ ); +} diff --git a/client/src/hooks/useAchievements.js b/client/src/hooks/useAchievements.js new file mode 100644 index 0000000..a7e3fa9 --- /dev/null +++ b/client/src/hooks/useAchievements.js @@ -0,0 +1,51 @@ +import { useState, useCallback } from 'react'; + +const KEY = 'sq_ach_v2'; + +export const ACHIEVEMENTS = [ + { id: 'first_blood', name: 'First Blood', desc: 'Eliminate your first opponent', icon: '\u2694\uFE0F', color: '#ef4444', glow: 'rgba(239,68,68,0.35)' }, + { id: 'titanium_tail', name: 'Titanium Tail', desc: 'Reach a body length of 100', icon: '\uD83D\uDD29', color: '#94a3b8', glow: 'rgba(148,163,184,0.35)' }, + { id: 'speed_demon', name: 'Speed Demon', desc: 'Boost for 30 consecutive seconds', icon: '\u26A1', color: '#f97316', glow: 'rgba(249,115,22,0.35)' }, + { id: 'cosmic_giant', name: 'Cosmic Giant', desc: 'Achieve a score of 500 in one game', icon: '\uD83C\uDF0C', color: '#a855f7', glow: 'rgba(168,85,247,0.35)' }, + { id: 'black_hole_eater', name: 'Black Hole Eater', desc: 'Eat 10 pellets in 5 seconds', icon: '\uD83D\uDD73\uFE0F', color: '#3b82f6', glow: 'rgba(59,130,246,0.35)' }, +]; + +const loadSet = () => { + try { return new Set(JSON.parse(localStorage.getItem(KEY) || '[]')); } + catch { return new Set(); } +}; + +export function useAchievements() { + const [unlocked, setUnlocked] = useState(loadSet); + const [toasts, setToasts] = useState([]); + + const tryUnlock = useCallback((stats, rt = {}) => { + const newOnes = []; + setUnlocked(prev => { + const next = new Set(prev); + for (const a of ACHIEVEMENTS) { + if (next.has(a.id)) continue; + let earn = false; + if (a.id === 'first_blood') earn = (stats.totalKills || 0) >= 1; + if (a.id === 'titanium_tail') earn = (stats.highLength || 0) >= 100; + if (a.id === 'speed_demon') earn = (rt.maxBoostSec || 0) >= 30; + if (a.id === 'cosmic_giant') earn = (stats.highScore || 0) >= 500; + if (a.id === 'black_hole_eater') earn = (rt.maxQuickEat || 0) >= 10; + if (earn) { next.add(a.id); newOnes.push(a); } + } + if (newOnes.length) { + try { localStorage.setItem(KEY, JSON.stringify([...next])); } catch {} + } + return next; + }); + if (newOnes.length) { + setToasts(p => [...p, ...newOnes.map(a => ({ ...a, key: Date.now() + Math.random() }))]); + } + }, []); + + const dismissToast = useCallback(key => { + setToasts(p => p.filter(t => t.key !== key)); + }, []); + + return { unlocked, toasts, tryUnlock, dismissToast }; +} diff --git a/client/src/hooks/usePlayerStats.js b/client/src/hooks/usePlayerStats.js new file mode 100644 index 0000000..aad90bf --- /dev/null +++ b/client/src/hooks/usePlayerStats.js @@ -0,0 +1,46 @@ +import { useState, useCallback } from 'react'; + +const KEY = 'sq_stats_v2'; + +const defaults = { + gamesPlayed: 0, + highScore: 0, + totalScore: 0, + highSurvivalSec: 0, + totalKills: 0, + highLength: 0, + bestRank: null, +}; + +const load = () => { + try { return { ...defaults, ...JSON.parse(localStorage.getItem(KEY) || '{}') }; } + catch { return { ...defaults }; } +}; + +export function usePlayerStats() { + const [stats, setStats] = useState(load); + + const recordDeath = useCallback(({ score = 0, survivalSec = 0, kills = 0, length = 0, rank = null } = {}) => { + setStats(prev => { + const next = { + ...prev, + gamesPlayed: prev.gamesPlayed + 1, + highScore: Math.max(prev.highScore, score), + totalScore: prev.totalScore + score, + highSurvivalSec: Math.max(prev.highSurvivalSec, survivalSec), + totalKills: prev.totalKills + kills, + highLength: Math.max(prev.highLength, length), + bestRank: + prev.bestRank === null + ? rank + : rank !== null && rank < prev.bestRank + ? rank + : prev.bestRank, + }; + try { localStorage.setItem(KEY, JSON.stringify(next)); } catch {} + return next; + }); + }, []); + + return { stats, recordDeath }; +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..6f4d655 --- /dev/null +++ b/client/src/main.jsx @@ -0,0 +1,9 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); diff --git a/client/vercel.json b/client/vercel.json new file mode 100644 index 0000000..4e57fc3 --- /dev/null +++ b/client/vercel.json @@ -0,0 +1,7 @@ +{ + "framework": "vite", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "installCommand": "npm install", + "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] +} diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..3054c75 --- /dev/null +++ b/client/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { port: 5173, host: true }, +}); diff --git a/server/.env.example b/server/.env.example new file mode 100644 index 0000000..fb7e6cb --- /dev/null +++ b/server/.env.example @@ -0,0 +1,3 @@ +PORT=3001 +VALKEY_URL=rediss://default:PASSWORD@host:port +BREETH_API_KEY=ck_live_... diff --git a/server/Procfile b/server/Procfile new file mode 100644 index 0000000..1da0cd6 --- /dev/null +++ b/server/Procfile @@ -0,0 +1 @@ +web: node index.js diff --git a/server/breeth.js b/server/breeth.js new file mode 100644 index 0000000..72f7ca8 --- /dev/null +++ b/server/breeth.js @@ -0,0 +1,124 @@ +'use strict'; + +const BASE_URL = 'https://api.thebreeth.com/v1'; + +// ─── In-memory fallback store ──────────────────────────────────────────────── + +const memoryStore = new Map(); // playerName -> { bestLength, lastKiller, runCount } + +function getLocal(name) { + return memoryStore.get(name) || null; +} + +function saveLocal(name, data) { + memoryStore.set(name, { ...(memoryStore.get(name) || {}), ...data }); +} + +// ─── Breeth REST helpers ───────────────────────────────────────────────────── + +async function breethPost(path, body, apiKey) { + const res = await fetch(`${BASE_URL}${path}`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(4000), + }); + if (!res.ok) throw new Error(`Breeth ${path} → HTTP ${res.status}`); + return res.json(); +} + +// ─── Public API ────────────────────────────────────────────────────────────── + +class BreethClient { + constructor(apiKey) { + this.apiKey = apiKey || null; + this.enabled = !!apiKey; + } + + /** + * Called on join. Returns greeting text (may be null). + */ + async recallPlayer(name) { + // Always update local memory first for fallback + const local = getLocal(name); + + if (!this.enabled) { + return local ? this._buildGreeting(name, local) : null; + } + + try { + const result = await breethPost( + '/search', + { query: `snake game player "${name}" history best length killer`, limit: 5 }, + this.apiKey, + ); + + const edges = result.edges || []; + if (edges.length === 0) return null; + + // Parse out useful facts from edges + let bestLength = null; + let lastKiller = null; + for (const edge of edges) { + const fact = (edge.fact || '').toLowerCase(); + const m = fact.match(/length[^0-9]*(\d+)/); + if (m) bestLength = Math.max(bestLength || 0, parseInt(m[1], 10)); + const k = fact.match(/killed by ([^,.]+)/i); + if (k) lastKiller = k[1].trim(); + } + + // Merge with local + const merged = { + bestLength: bestLength || (local && local.bestLength), + lastKiller: lastKiller || (local && local.lastKiller), + runCount: local ? local.runCount : 0, + }; + saveLocal(name, merged); + return this._buildGreeting(name, merged); + } catch (err) { + console.warn(`[Breeth] recallPlayer failed (${err.message}), using local fallback`); + return local ? this._buildGreeting(name, local) : null; + } + } + + /** + * Called on death. Stores the run in Breeth. + */ + async rememberDeath(name, length, killerName) { + // Update local + const prev = getLocal(name) || {}; + const updated = { + bestLength: Math.max(prev.bestLength || 0, length), + lastKiller: killerName || prev.lastKiller || null, + runCount: (prev.runCount || 0) + 1, + }; + saveLocal(name, updated); + + if (!this.enabled) return; + + const killerPart = killerName ? ` and was killed by ${killerName}` : ' and hit a wall'; + const content = + `Snake game: player "${name}" completed a run reaching length ${length}${killerPart}. ` + + `This is run #${updated.runCount}. Best length so far: ${updated.bestLength}.`; + + try { + await breethPost('/episodes', { content, extract_intent: true }, this.apiKey); + } catch (err) { + console.warn(`[Breeth] rememberDeath failed (${err.message})`); + } + } + + _buildGreeting(name, data) { + if (!data) return null; + const parts = []; + if (data.runCount > 1) parts.push(`Run #${data.runCount + 1} for ${name}!`); + if (data.bestLength) parts.push(`Your best was length ${data.bestLength}.`); + if (data.lastKiller) parts.push(`Last time you were eaten by ${data.lastKiller}.`); + return parts.length > 0 ? parts.join(' ') : null; + } +} + +module.exports = { BreethClient }; diff --git a/server/flight-test.js b/server/flight-test.js new file mode 100644 index 0000000..e9df4ef --- /dev/null +++ b/server/flight-test.js @@ -0,0 +1,310 @@ +'use strict'; +/** + * Flight-test suite — runs against the live server (must be running on PORT 3001). + * Usage: node flight-test.js + */ + +require('dotenv').config(); +const WebSocket = require('ws'); +const Redis = require('ioredis'); + +const SERVER_HTTP = 'http://localhost:3001'; +const SERVER_WS = 'ws://localhost:3001'; +const VALKEY_URL = process.env.VALKEY_URL; +const BREETH_KEY = process.env.BREETH_API_KEY; + +let passed = 0, failed = 0; + +function ok(label, cond, detail = '') { + if (cond) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.error(` ❌ ${label}${detail ? ' — ' + detail : ''}`); + failed++; + } +} + +async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function httpGet(path) { + const res = await fetch(`${SERVER_HTTP}${path}`); + return { status: res.status, body: await res.json() }; +} + +// ─── Test helpers ───────────────────────────────────────────────────────────── + +function connectPlayer(name) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(SERVER_WS); + const state = { ws, id: null, snapshots: [], killfeed: [], death: null, greeting: null }; + + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'join', name })); + }); + + ws.on('message', (raw) => { + const msg = JSON.parse(raw); + if (msg.type === 'joined') { state.id = msg.id; state.greeting = msg.greeting; resolve(state); } + if (msg.type === 'snapshot') state.snapshots.push(msg.data); + if (msg.type === 'killfeed') state.killfeed.push(msg.data); + if (msg.type === 'death') state.death = msg.data; + }); + + ws.on('error', reject); + setTimeout(() => reject(new Error('join timeout')), 5000); + }); +} + +function sendInput(state, dir, boost = false) { + if (state.ws.readyState === WebSocket.OPEN) { + state.ws.send(JSON.stringify({ type: 'input', dir, boost })); + } +} + +function disconnect(state) { + if (state.ws.readyState === WebSocket.OPEN) state.ws.close(); +} + +// ─── Test suites ────────────────────────────────────────────────────────────── + +async function testHealth() { + console.log('\n── 1. Health & Server ──────────────────────────────────'); + const { status, body } = await httpGet('/api/health'); + ok('HTTP 200 from /api/health', status === 200); + ok('ok: true', body.ok === true); + ok('Valkey flag present', 'valkey' in body); + ok('Breeth flag present', 'breeth' in body); + console.log(` valkey=${body.valkey} breeth=${body.breeth} players=${body.players}`); +} + +async function testWebSocket() { + console.log('\n── 2. WebSocket — Join & Snapshot ──────────────────────'); + const p1 = await connectPlayer('TestPilot'); + ok('Player joined and got an id', !!p1.id, p1.id); + + // Wait for a few snapshots + await sleep(300); + ok('Receiving snapshots', p1.snapshots.length > 0, + `got ${p1.snapshots.length} snapshots`); + + const snap = p1.snapshots[p1.snapshots.length - 1]; + ok('Snapshot has snakes array', Array.isArray(snap?.snakes)); + ok('Snapshot has pellets array', Array.isArray(snap?.pellets)); + ok('Snapshot has worldSize', typeof snap?.worldSize === 'number'); + + const me = snap?.snakes?.find(s => s.id === p1.id); + ok('Own snake in snapshot', !!me, me ? `length=${me.length}` : 'not found'); + ok('Snake has body nodes', Array.isArray(me?.body) && me.body.length > 0); + + disconnect(p1); + return p1; +} + +async function testMultiplayer() { + console.log('\n── 3. Multiplayer — 3 simultaneous players ─────────────'); + const [p1, p2, p3] = await Promise.all([ + connectPlayer('Alpha'), + connectPlayer('Beta'), + connectPlayer('Gamma'), + ]); + + ok('All 3 players joined', !!p1.id && !!p2.id && !!p3.id); + ok('All ids are unique', new Set([p1.id, p2.id, p3.id]).size === 3); + + // Send inputs + sendInput(p1, 0); // East + sendInput(p2, Math.PI); // West + sendInput(p3, Math.PI / 2); // South + + await sleep(400); + + const snap = p1.snapshots[p1.snapshots.length - 1]; + const ids = new Set(snap?.snakes?.map(s => s.id)); + ok('All 3 snakes visible in snapshot', [p1.id, p2.id, p3.id].every(id => ids.has(id)), + `visible: ${ids.size}`); + + // Check leaderboard + const { body: lb } = await httpGet('/api/leaderboard'); + ok('/api/leaderboard returns array', Array.isArray(lb)); + ok('Leaderboard entries have name+score', lb.every(e => e.name && typeof e.score === 'number')); + + disconnect(p1); disconnect(p2); disconnect(p3); + return { p1, p2, p3 }; +} + +async function testBoostAndMovement() { + console.log('\n── 4. Input — movement & boost ─────────────────────────'); + const p = await connectPlayer('SpeedTest'); + await sleep(200); + + const snap0 = p.snapshots[p.snapshots.length - 1]; + const me0 = snap0?.snakes?.find(s => s.id === p.id); + const x0 = me0?.x ?? 0, y0 = me0?.y ?? 0; + + // Move East for 500ms + sendInput(p, 0, false); + await sleep(500); + + const snap1 = p.snapshots[p.snapshots.length - 1]; + const me1 = snap1?.snakes?.find(s => s.id === p.id); + ok('Snake moved after input', me1 && (me1.x !== x0 || me1.y !== y0), + `(${x0},${y0}) → (${me1?.x},${me1?.y})`); + + // Boost + const len0 = me1?.length ?? 0; + sendInput(p, 0, true); + await sleep(600); + const snap2 = p.snapshots[p.snapshots.length - 1]; + const me2 = snap2?.snakes?.find(s => s.id === p.id); + ok('Boost flag visible in snapshot', me2?.boosting === true); + + disconnect(p); +} + +async function testValkey() { + console.log('\n── 5. Valkey ────────────────────────────────────────────'); + if (!VALKEY_URL) { + console.log(' ⚠️ VALKEY_URL not set — skipping Valkey tests'); + return; + } + const v = new Redis(VALKEY_URL, { + tls: VALKEY_URL.startsWith('rediss://') ? {} : undefined, + maxRetriesPerRequest: 1, + retryStrategy: () => null, + }); + + try { + await v.ping(); + ok('Valkey PING', true); + + // Join a player and wait for persist cycle (2 s) + const p = await connectPlayer('ValkeyTester'); + sendInput(p, 0); + await sleep(2500); // wait for the 2s persist cycle + + const world = await v.get('game:world'); + ok('game:world key exists (SET EX 5)', !!world, world ? `${world.length} bytes` : 'null'); + + const worldObj = world ? JSON.parse(world) : null; + ok('game:world is valid JSON snapshot', !!worldObj?.snakes && !!worldObj?.pellets); + + const lbEntries = await v.zrevrange('game:leaderboard', 0, 9, 'WITHSCORES'); + ok('game:leaderboard sorted set has entries', lbEntries.length > 0, + `${lbEntries.length / 2} entries`); + + const names = await v.hgetall('game:names'); + ok('game:names hash populated', Object.keys(names || {}).length > 0); + + // Test pub/sub round-trip + let pubSubReceived = false; + const sub = new Redis(VALKEY_URL, { tls: { rejectUnauthorized: false }, retryStrategy: () => null }); + await sub.subscribe('game:events'); + sub.on('message', () => { pubSubReceived = true; }); + await v.publish('game:events', JSON.stringify({ type: 'test', ts: Date.now() })); + await sleep(300); + ok('Pub/sub round-trip works', pubSubReceived); + sub.disconnect(); + + disconnect(p); + } catch (err) { + ok('Valkey connection', false, err.message); + } finally { + v.disconnect(); + } +} + +async function testBreeth() { + console.log('\n── 6. Breeth AI ─────────────────────────────────────────'); + if (!BREETH_KEY) { + console.log(' ⚠️ BREETH_API_KEY not set — skipping Breeth tests'); + return; + } + + // Direct API test + try { + const writeRes = await fetch('https://api.thebreeth.com/v1/episodes', { + method: 'POST', + headers: { 'Authorization': `Bearer ${BREETH_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ content: 'FlightTest player reached length 99 and was killed by TestBot.', extract_intent: true }), + signal: AbortSignal.timeout(6000), + }); + ok('Breeth POST /v1/episodes reachable', writeRes.status === 200, + `HTTP ${writeRes.status}`); + + const searchRes = await fetch('https://api.thebreeth.com/v1/search', { + method: 'POST', + headers: { 'Authorization': `Bearer ${BREETH_KEY}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ query: 'FlightTest player snake game', limit: 5 }), + signal: AbortSignal.timeout(6000), + }); + ok('Breeth POST /v1/search reachable', searchRes.status === 200, + `HTTP ${searchRes.status}`); + const sBody = await searchRes.json(); + ok('Search returns edges array', Array.isArray(sBody.edges)); + } catch (err) { + ok('Breeth API reachable', false, err.message); + } + + // In-game: join with a known name → check greeting after a death cycle + console.log(' Joining as "FlightTest" to trigger Breeth recall…'); + const p = await connectPlayer('FlightTest'); + await sleep(500); + console.log(` Greeting received: ${p.greeting ?? '(none yet — first run)'}`); + ok('Player joined successfully (Breeth did not crash join)', !!p.id); + disconnect(p); +} + +async function testRespawn() { + console.log('\n── 7. Respawn flow ──────────────────────────────────────'); + const p = await connectPlayer('RespawnTest'); + ok('Joined', !!p.id); + + // Drive snake into a wall instantly + p.ws.send(JSON.stringify({ type: 'input', dir: 0 })); + // Force it to the wall by teleporting via many ticks (just wait and steer toward x=4001) + // Instead we test the respawn message directly after simulated death + // (wall deaths take time; just verify respawn message works) + p.ws.send(JSON.stringify({ type: 'respawn' })); + await sleep(200); + + // Should still be receiving snapshots + const prevLen = p.snapshots.length; + await sleep(300); + ok('Snapshots still flowing after respawn message', p.snapshots.length > prevLen); + disconnect(p); +} + +// ─── Runner ─────────────────────────────────────────────────────────────────── + +(async () => { + console.log('╔══════════════════════════════════════════════════════════╗'); + console.log('║ Slither Quest — Flight Test Suite ║'); + console.log('╚══════════════════════════════════════════════════════════╝'); + console.log(` Server : ${SERVER_WS}`); + console.log(` Valkey : ${VALKEY_URL ? '✅ configured' : '⚠️ not set'}`); + console.log(` Breeth : ${BREETH_KEY ? '✅ configured' : '⚠️ not set'}`); + + try { + await testHealth(); + await testWebSocket(); + await testMultiplayer(); + await testBoostAndMovement(); + await testValkey(); + await testBreeth(); + await testRespawn(); + } catch (err) { + console.error('\n FATAL:', err.message); + failed++; + } + + console.log('\n══════════════════════════════════════════════════════════'); + console.log(` Results: ${passed} passed, ${failed} failed`); + if (failed === 0) { + console.log(' 🚀 All systems go!'); + } else { + console.log(' ⚠️ Some checks failed — see above.'); + } + console.log('══════════════════════════════════════════════════════════\n'); + process.exit(failed > 0 ? 1 : 0); +})(); diff --git a/server/game.js b/server/game.js new file mode 100644 index 0000000..9dfb923 --- /dev/null +++ b/server/game.js @@ -0,0 +1,236 @@ +'use strict'; + +const WORLD_SIZE = 4000; +const BASE_SPEED = 3; +const BOOST_SPEED = 5; +const SEGMENT_SPACING = 8; // pixels between sampled body nodes +const HEAD_RADIUS = 10; +const SEGMENT_RADIUS = 8; +const PELLET_RADIUS = 5; +const MIN_LENGTH = 5; +const INITIAL_LENGTH = 20; +const MAX_PELLETS = 400; +const BOOST_DRAIN = 0.05; // length lost per tick while boosting +const MAX_TURN_RATE = 0.08; // radians per tick + +let _nextPelletId = 1; + +class GameEngine { + constructor() { + this.snakes = new Map(); // id -> snake + this.pellets = new Map(); // id -> pellet + this.tickCount = 0; + + for (let i = 0; i < MAX_PELLETS; i++) this._spawnPellet(); + } + + // ─── Pellets ──────────────────────────────────────────────────────────────── + + _spawnPellet(x, y) { + const id = _nextPelletId++; + const pellet = { + id, + x: x !== undefined + ? Math.max(10, Math.min(WORLD_SIZE - 10, x + (Math.random() - 0.5) * 40)) + : 10 + Math.random() * (WORLD_SIZE - 20), + y: y !== undefined + ? Math.max(10, Math.min(WORLD_SIZE - 10, y + (Math.random() - 0.5) * 40)) + : 10 + Math.random() * (WORLD_SIZE - 20), + hue: Math.floor(Math.random() * 360), + }; + this.pellets.set(id, pellet); + return pellet; + } + + // ─── Snakes ───────────────────────────────────────────────────────────────── + + addSnake(id, name) { + const x = 300 + Math.random() * (WORLD_SIZE - 600); + const y = 300 + Math.random() * (WORLD_SIZE - 600); + const dir = Math.random() * Math.PI * 2; + + // Pre-fill path so body is visible immediately + const path = []; + const pathLen = (INITIAL_LENGTH + 5) * SEGMENT_SPACING; + for (let i = 0; i < pathLen; i++) { + path.push({ x: x - Math.cos(dir) * i, y: y - Math.sin(dir) * i }); + } + + const snake = { + id, name, + x, y, dir, + path, + length: INITIAL_LENGTH, + score: 0, + boosting: false, + alive: true, + color: `hsl(${Math.floor(Math.random() * 360)},80%,55%)`, + input: { dir, boost: false }, + }; + + this.snakes.set(id, snake); + return snake; + } + + removeSnake(id) { + this.snakes.delete(id); + } + + setInput(id, input) { + const s = this.snakes.get(id); + if (s && s.alive) s.input = input; + } + + // ─── Body nodes ───────────────────────────────────────────────────────────── + + _getBodyNodes(snake) { + const nodes = []; + const count = Math.ceil(snake.length); + for (let i = 0; i < count; i++) { + const idx = i * SEGMENT_SPACING; + if (idx < snake.path.length) nodes.push(snake.path[idx]); + } + return nodes; + } + + // ─── Kill ─────────────────────────────────────────────────────────────────── + + _killSnake(id, killerId, events) { + const s = this.snakes.get(id); + if (!s || !s.alive) return; + s.alive = false; + + // Drop pellets along body + const body = this._getBodyNodes(s); + const step = Math.max(1, Math.floor(body.length / 40)); + for (let i = 0; i < body.length; i += step) { + if (this.pellets.size < MAX_PELLETS * 2) this._spawnPellet(body[i].x, body[i].y); + } + + const killer = killerId ? this.snakes.get(killerId) : null; + events.push({ + type: 'death', + victimId: id, + victimName: s.name, + killerId: killerId || null, + killerName: killer ? killer.name : null, + length: Math.floor(s.length), + timestamp: Date.now(), + }); + } + + // ─── Game tick ────────────────────────────────────────────────────────────── + + tick() { + this.tickCount++; + const events = []; + const alive = []; + + // Move all snakes + for (const [id, s] of this.snakes) { + if (!s.alive) continue; + alive.push(s); + + // Smooth turn + let diff = s.input.dir - s.dir; + while (diff > Math.PI) diff -= Math.PI * 2; + while (diff < -Math.PI) diff += Math.PI * 2; + s.dir += Math.max(-MAX_TURN_RATE, Math.min(MAX_TURN_RATE, diff)); + + // Speed & boost + const canBoost = s.input.boost && s.length > MIN_LENGTH + 1; + s.boosting = canBoost; + const speed = canBoost ? BOOST_SPEED : BASE_SPEED; + + s.x += Math.cos(s.dir) * speed; + s.y += Math.sin(s.dir) * speed; + s.path.unshift({ x: s.x, y: s.y }); + + if (canBoost) { + const prevFloor = Math.floor(s.length); + s.length -= BOOST_DRAIN; + if (Math.floor(s.length) < prevFloor) this._spawnPellet(s.x, s.y); + if (s.length < MIN_LENGTH) s.length = MIN_LENGTH; + } + + // Trim path to needed length + const maxLen = Math.ceil(s.length) * SEGMENT_SPACING + SEGMENT_SPACING + 1; + if (s.path.length > maxLen) s.path.length = maxLen; + + // Wall death + if (s.x < 0 || s.x > WORLD_SIZE || s.y < 0 || s.y > WORLD_SIZE) { + this._killSnake(id, null, events); + } + } + + // Pellet collisions + for (const s of alive) { + if (!s.alive) continue; + for (const [pid, p] of this.pellets) { + const dx = s.x - p.x, dy = s.y - p.y; + if (dx * dx + dy * dy < (HEAD_RADIUS + PELLET_RADIUS) ** 2) { + s.length += 1; + s.score += 1; + this.pellets.delete(pid); + this._spawnPellet(); + } + } + } + + // Snake vs snake + for (const s of alive) { + if (!s.alive) continue; + for (const other of alive) { + if (!other.alive || s.id === other.id) continue; + const body = this._getBodyNodes(other); + for (let i = 0; i < body.length; i++) { + const node = body[i]; + const dx = s.x - node.x, dy = s.y - node.y; + // Head-to-head uses doubled radius; body uses combined radii + const r = i === 0 ? HEAD_RADIUS * 1.8 : HEAD_RADIUS + SEGMENT_RADIUS; + if (dx * dx + dy * dy < r * r) { + this._killSnake(s.id, i === 0 ? null : other.id, events); + break; + } + } + } + } + + // Replenish pellets + while (this.pellets.size < MAX_PELLETS) this._spawnPellet(); + + return events; + } + + // ─── Snapshot ─────────────────────────────────────────────────────────────── + + getSnapshot() { + const snakes = []; + for (const s of this.snakes.values()) { + if (!s.alive) continue; + snakes.push({ + id: s.id, + name: s.name, + x: Math.round(s.x), + y: Math.round(s.y), + dir: +s.dir.toFixed(3), + length: Math.floor(s.length), + score: s.score, + boosting: s.boosting, + color: s.color, + // Compact body: array of [x, y] pairs + body: this._getBodyNodes(s).map(p => [Math.round(p.x), Math.round(p.y)]), + }); + } + + // Compact pellets: [id, x, y, hue] + const pellets = []; + for (const p of this.pellets.values()) { + pellets.push([p.id, Math.round(p.x), Math.round(p.y), p.hue]); + } + + return { t: this.tickCount, snakes, pellets, worldSize: WORLD_SIZE }; + } +} + +module.exports = { GameEngine, WORLD_SIZE }; diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..fc2dbc6 --- /dev/null +++ b/server/index.js @@ -0,0 +1,356 @@ +'use strict'; + +require('dotenv').config(); + +const express = require('express'); +const http = require('http'); +const WebSocket = require('ws'); +const crypto = require('crypto'); +const path = require('path'); +const { v4: uuidv4 } = require('uuid'); +const { RoomManager } = require('./room-manager'); +const { GameEngine } = require('./game'); +const { BreethClient } = require('./breeth'); + +const PORT = parseInt(process.env.PORT || '3001', 10); +const TICK_MS = 1000 / 25; // 25 Hz + +const hashPin = (pin) => crypto.createHash('sha256').update(String(pin)).digest('hex'); + +// ─── Express ───────────────────────────────────────────────────────────────── + +const app = express(); +app.use(express.json()); +app.use((_, res, next) => { res.setHeader('Access-Control-Allow-Origin', '*'); next(); }); +app.use(express.static(path.join(__dirname, '../client/dist'))); + +const server = http.createServer(app); +const wss = new WebSocket.Server({ server, perMessageDeflate: true }); + +// ─── Valkey ────────────────────────────────────────────────────────────────── + +let valkey = null, valkeyPub = null, valkeySub = null; +const VALKEY_URL = process.env.VALKEY_URL; + +if (VALKEY_URL) { + const Redis = require('ioredis'); + const mkClient = () => new Redis(VALKEY_URL, { + tls: VALKEY_URL.startsWith('rediss://') ? {} : undefined, + lazyConnect: false, + maxRetriesPerRequest: 1, + enableReadyCheck: false, + retryStrategy: (t) => t > 3 ? null : t * 500, + }); + valkey = mkClient(); + valkeyPub = mkClient(); + valkeySub = mkClient(); + + valkey.on('error', e => console.warn('[Valkey] main:', e.message)); + valkeyPub.on('error', e => console.warn('[Valkey] pub:', e.message)); + valkeySub.on('error', e => console.warn('[Valkey] sub:', e.message)); + + valkeySub.subscribe('game:events', err => { + if (err) console.warn('[Valkey] subscribe error:', err.message); + }); + // Relay pub/sub kill events to the right room + valkeySub.on('message', (_ch, raw) => { + try { + const event = JSON.parse(raw); + if (event.roomCode) { + const room = roomManager.get(event.roomCode); + if (room) room.broadcastAll({ type: 'killfeed', data: event }); + } + } catch (_) {} + }); + console.log('[Valkey] connecting…'); +} else { + console.warn('[Valkey] VALKEY_URL not set — running without persistence'); +} + +// ─── PIN / user store ───────────────────────────────────────────────────────── +// Valkey hash: game:users → { lowerName: hashedPin } +// In-memory fallback when Valkey unavailable +const localUsers = new Map(); // lowerName → hashedPin + +async function getStoredPin(name) { + const key = name.toLowerCase(); + if (valkey) { + try { return await valkey.hget('game:users', key); } catch (_) {} + } + return localUsers.get(key) || null; +} + +async function savePin(name, pin) { + const key = name.toLowerCase(); + const h = hashPin(pin); + localUsers.set(key, h); + if (valkey) { + try { await valkey.hset('game:users', key, h); } catch (_) {} + } + return h; +} + +// ─── Room manager ───────────────────────────────────────────────────────────── + +const roomManager = new RoomManager(); +const breeth = new BreethClient(process.env.BREETH_API_KEY); + +// clientId → roomCode (global lookup so we can clean up on disconnect) +const clientRoom = new Map(); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function computeLeaderboard(snapshot) { + return snapshot.snakes.slice().sort((a, b) => b.score - a.score).slice(0, 10) + .map(s => ({ id: s.id, name: s.name, score: s.score })); +} + +async function persistRoom(room, snapshot) { + if (!valkey) return; + try { + const pipe = valkey.pipeline(); + for (const s of snapshot.snakes) { + pipe.zadd(`game:${room.code}:leaderboard`, s.score, s.id); + pipe.hset(`game:${room.code}:names`, s.id, s.name); + } + pipe.set(`game:${room.code}:world`, JSON.stringify(snapshot), 'EX', 5); + await pipe.exec(); + + const raw = await valkey.zrevrange(`game:${room.code}:leaderboard`, 0, 9, 'WITHSCORES'); + const lb = []; + for (let i = 0; i < raw.length; i += 2) { + const id = raw[i]; + const score = parseInt(raw[i + 1], 10); + const name = await valkey.hget(`game:${room.code}:names`, id); + lb.push({ id, name: name || 'Unknown', score }); + } + room.leaderboard = lb; + } catch (err) { + console.warn(`[Valkey] persist room ${room.code}:`, err.message); + } +} + +async function handleRoomDeath(room, event) { + room.send(event.victimId, { type: 'death', data: event }); + + if (valkeyPub) { + valkeyPub.publish('game:events', JSON.stringify({ ...event, roomCode: room.code })) + .catch(() => room.broadcastAll({ type: 'killfeed', data: event })); + } else { + room.broadcastAll({ type: 'killfeed', data: event }); + } + + const player = room.players.get(event.victimId); + if (player) breeth.rememberDeath(player.name, event.length, event.killerName).catch(() => {}); +} + +// ─── Master game loop — ticks all active rooms ──────────────────────────────── + +setInterval(() => { + const now = Date.now(); + for (const room of roomManager.rooms.values()) { + if (room.clients.size === 0) continue; + + const events = room.game.tick(); + const snapshot = room.game.getSnapshot(); + + for (const ev of events) { + if (ev.type === 'death') handleRoomDeath(room, ev).catch(() => {}); + } + + if (now - room.lastPersist >= 2000) { + room.lastPersist = now; + if (valkey) persistRoom(room, snapshot).catch(() => {}); + else room.leaderboard = computeLeaderboard(snapshot); + } + + room.broadcastAll({ type: 'snapshot', data: snapshot, leaderboard: room.leaderboard, timeLeftMs: room.timeLeftMs() }); + + // ── 5-minute round end ────────────────────────────────────────────────── + if (room.shouldEndGame()) { + room.inIntermission = true; + const finalLB = [...snapshot.snakes] + .sort((a, b) => b.score - a.score) + .slice(0, 10) + .map(s => ({ id: s.id, name: s.name, score: s.score })); + const winner = finalLB[0] || null; + + room.broadcastAll({ type: 'game_over', leaderboard: finalLB, winner, nextRoundIn: 10 }); + console.log(`[Room] ${room.code} round ended. Winner: ${winner?.name || 'none'}`); + + // Persist final leaderboard to Valkey + if (valkey && finalLB.length > 0) { + const pipe = valkey.pipeline(); + for (const s of finalLB) { + pipe.zadd(`game:${room.code}:leaderboard`, s.score, s.id); + } + pipe.exec().catch(() => {}); + } + + // Auto-reset after 10 seconds — new round for all players + setTimeout(() => { + if (!roomManager.rooms.has(room.code)) return; // room was deleted + room.game = new GameEngine(); + room.startRound(); + for (const [cId, player] of room.players) { + room.game.addSnake(cId, player.name); + room.send(cId, { type: 'respawned', id: cId }); + } + console.log(`[Room] ${room.code} new round started.`); + }, 10_000); + } + } +}, TICK_MS); + +// ─── WebSocket ─────────────────────────────────────────────────────────────── + +wss.on('connection', (ws) => { + const clientId = uuidv4(); + + const send = (obj) => { + if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); + }; + + ws.on('message', async (raw) => { + let msg; + try { msg = JSON.parse(raw); } catch { return; } + + // ── check_name ────────────────────────────────────────────────────────── + if (msg.type === 'check_name') { + const name = String(msg.name || '').trim(); + if (!name) return; + const stored = await getStoredPin(name); + send({ type: 'name_status', isNew: !stored }); + return; + } + + // ── check_room ────────────────────────────────────────────────────────── + if (msg.type === 'check_room') { + const room = roomManager.get(msg.roomCode); + send({ type: 'room_info', exists: !!room, passwordRequired: room ? !!room.password : false }); + return; + } + + // ── create_room ────────────────────────────────────────────────────────── + if (msg.type === 'create_room') { + const name = String(msg.name || '').trim().slice(0, 20) || 'Anonymous'; + const pin = String(msg.pin || '').trim(); + const roomPass = String(msg.roomPassword || '').trim() || null; + + if (!pin || pin.length < 4) { send({ type: 'error', code: 'pin_required', message: 'PIN must be 4 digits.' }); return; } + + // Authenticate / register PIN + const stored = await getStoredPin(name); + if (stored) { + if (stored !== hashPin(pin)) { send({ type: 'error', code: 'wrong_pin', message: 'Wrong PIN for this name.' }); return; } + } else { + await savePin(name, pin); + } + + let room; + try { room = roomManager.create(roomPass); } + catch (err) { send({ type: 'error', code: 'server_full', message: err.message }); return; } + + room.addClient(clientId, ws); + room.players.set(clientId, { name }); + room.game.addSnake(clientId, name); + clientRoom.set(clientId, room.code); + room.startRound(); // begin 5-minute timer + + let greeting = null; + try { greeting = await breeth.recallPlayer(name); } catch (_) {} + if (valkey) valkey.hset('game:users:display', name.toLowerCase(), name).catch(() => {}); + + send({ type: 'room_created', roomCode: room.code, id: clientId, greeting, passwordRequired: !!roomPass }); + return; + } + + // ── join_room ──────────────────────────────────────────────────────────── + if (msg.type === 'join_room') { + const roomCode = String(msg.roomCode || '').toUpperCase().trim(); + const name = String(msg.name || '').trim().slice(0, 20) || 'Anonymous'; + const pin = String(msg.pin || '').trim(); + const password = String(msg.password || '').trim(); + + if (!pin || pin.length < 4) { send({ type: 'error', code: 'pin_required', message: 'PIN must be 4 digits.' }); return; } + + const room = roomManager.get(roomCode); + if (!room) { send({ type: 'error', code: 'room_not_found', message: `Room "${roomCode}" does not exist.` }); return; } + + if (room.password && password !== room.password) { + send({ type: 'error', code: 'wrong_password', message: 'Wrong room password.' }); return; + } + + // Authenticate / register PIN + const stored = await getStoredPin(name); + if (stored) { + if (stored !== hashPin(pin)) { send({ type: 'error', code: 'wrong_pin', message: 'Wrong PIN for this name.' }); return; } + } else { + await savePin(name, pin); + } + + room.addClient(clientId, ws); + room.players.set(clientId, { name }); + room.game.addSnake(clientId, name); + clientRoom.set(clientId, room.code); + + let greeting = null; + try { greeting = await breeth.recallPlayer(name); } catch (_) {} + if (valkey) valkey.hset('game:users:display', name.toLowerCase(), name).catch(() => {}); + + send({ type: 'joined', id: clientId, roomCode: room.code, greeting }); + return; + } + + // ── input ──────────────────────────────────────────────────────────────── + if (msg.type === 'input') { + const room = roomManager.get(clientRoom.get(clientId)); + if (room) room.game.setInput(clientId, { dir: msg.dir || 0, boost: !!msg.boost }); + return; + } + + // ── respawn ────────────────────────────────────────────────────────────── + if (msg.type === 'respawn') { + const room = roomManager.get(clientRoom.get(clientId)); + if (room) { + const player = room.players.get(clientId); + if (player) { + room.game.addSnake(clientId, player.name); + send({ type: 'respawned', id: clientId }); + } + } + return; + } + }); + + ws.on('close', () => { + const code = clientRoom.get(clientId); + if (code) { + const room = roomManager.get(code); + if (room) room.removeClient(clientId, (c) => roomManager.delete(c)); + clientRoom.delete(clientId); + } + }); + + ws.on('error', () => ws.close()); +}); + +// ─── REST ───────────────────────────────────────────────────────────────────── + +app.get('/api/rooms', (_req, res) => res.json(roomManager.list())); + +app.get('/api/health', (_req, res) => res.json({ + ok: true, + rooms: roomManager.rooms.size, + totalPlayers: [...roomManager.rooms.values()].reduce((n, r) => n + r.clients.size, 0), + valkey: !!valkey, + breeth: breeth.enabled, +})); + +// ─── Start ──────────────────────────────────────────────────────────────────── + +server.listen(PORT, () => { + console.log(`[Server] ws://localhost:${PORT}`); + console.log(`[Valkey] ${VALKEY_URL ? 'connected' : 'disabled'}`); + console.log(`[Breeth] ${breeth.enabled ? 'enabled' : 'fallback'}`); +}); diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..51123b4 --- /dev/null +++ b/server/package-lock.json @@ -0,0 +1,974 @@ +{ + "name": "slither-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "slither-server", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.19.2", + "ioredis": "^5.3.2", + "uuid": "^9.0.1", + "ws": "^8.17.1" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.10.0.tgz", + "integrity": "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.11.1.tgz", + "integrity": "sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.10.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "redis-parser": "3.0.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..676b929 --- /dev/null +++ b/server/package.json @@ -0,0 +1,16 @@ +{ + "name": "slither-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "dev": "node --watch index.js", + "start": "node index.js" + }, + "dependencies": { + "dotenv": "^16.4.5", + "express": "^4.19.2", + "ioredis": "^5.3.2", + "uuid": "^9.0.1", + "ws": "^8.17.1" + } +} diff --git a/server/render.yaml b/server/render.yaml new file mode 100644 index 0000000..0729320 --- /dev/null +++ b/server/render.yaml @@ -0,0 +1,16 @@ +services: + - type: web + name: slither-quest-server + runtime: node + plan: free + buildCommand: npm install + startCommand: node index.js + envVars: + - key: PORT + value: 3001 + - key: VALKEY_URL + sync: false + - key: BREETH_API_KEY + sync: false + - key: ROOM_PASSWORD + sync: false diff --git a/server/room-manager.js b/server/room-manager.js new file mode 100644 index 0000000..800fadc --- /dev/null +++ b/server/room-manager.js @@ -0,0 +1,128 @@ +'use strict'; + +const { GameEngine } = require('./game'); +const WebSocket = require('ws'); + +const ROOM_CLEANUP_MS = 30_000; +const MAX_ROOMS = 50; +const GAME_DURATION_MS = 5 * 60 * 1000; // 5-minute rounds + +function genCode() { + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + return Array.from({ length: 4 }, () => chars[Math.floor(Math.random() * chars.length)]).join(''); +} + +// ─── Room ──────────────────────────────────────────────────────────────────── + +class Room { + constructor(code, roomPassword) { + this.code = code; + this.password = roomPassword || null; + this.game = new GameEngine(); + this.clients = new Map(); // clientId → ws + this.players = new Map(); // clientId → { name } + this.leaderboard = []; + this.lastPersist = 0; + this.createdAt = Date.now(); + this._cleanupTimer = null; + + // 5-minute round timer + this.gameStartMs = null; + this.inIntermission = false; + } + + // ── Timer ────────────────────────────────────────────────────────────────── + + startRound() { + this.gameStartMs = Date.now(); + this.inIntermission = false; + } + + timeLeftMs() { + if (!this.gameStartMs || this.inIntermission) return GAME_DURATION_MS; + return Math.max(0, GAME_DURATION_MS - (Date.now() - this.gameStartMs)); + } + + shouldEndGame() { + return ( + this.gameStartMs !== null && + !this.inIntermission && + this.clients.size > 0 && + this.timeLeftMs() === 0 + ); + } + + // ── Client management ────────────────────────────────────────────────────── + + addClient(clientId, ws) { + this.clients.set(clientId, ws); + clearTimeout(this._cleanupTimer); + this._cleanupTimer = null; + } + + removeClient(clientId, onEmpty) { + this.clients.delete(clientId); + this.players.delete(clientId); + this.game.removeSnake(clientId); + if (this.clients.size === 0) { + this._cleanupTimer = setTimeout(() => onEmpty(this.code), ROOM_CLEANUP_MS); + } + } + + send(clientId, obj) { + const ws = this.clients.get(clientId); + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); + } + + broadcastAll(obj) { + const msg = JSON.stringify(obj); + for (const ws of this.clients.values()) { + if (ws.readyState === WebSocket.OPEN) ws.send(msg); + } + } +} + +// ─── RoomManager ───────────────────────────────────────────────────────────── + +class RoomManager { + constructor() { + this.rooms = new Map(); // code → Room + } + + create(roomPassword) { + if (this.rooms.size >= MAX_ROOMS) throw new Error('Server is full — too many rooms'); + let code, tries = 0; + do { + code = genCode(); + if (++tries > 200) throw new Error('Cannot generate unique room code'); + } while (this.rooms.has(code)); + + const room = new Room(code, roomPassword); + this.rooms.set(code, room); + console.log(`[Room] ${code} created. Active: ${this.rooms.size}`); + return room; + } + + get(code) { + return this.rooms.get((code || '').toUpperCase().trim()) || null; + } + + delete(code) { + this.rooms.delete(code); + console.log(`[Room] ${code} deleted (empty). Active: ${this.rooms.size}`); + } + + list() { + return Array.from(this.rooms.values()) + .filter(r => !r.inIntermission) + .map(r => ({ + code: r.code, + players: r.clients.size, + passwordRequired: !!r.password, + timeLeftMs: r.timeLeftMs(), + createdAt: r.createdAt, + })); + } +} + +module.exports = { RoomManager, GAME_DURATION_MS }; diff --git a/submissions/SlitherQuest_ICSCoreTeam.md b/submissions/SlitherQuest_ICSCoreTeam.md new file mode 100644 index 0000000..7b45c19 --- /dev/null +++ b/submissions/SlitherQuest_ICSCoreTeam.md @@ -0,0 +1,135 @@ +# Slither Quest + +--- + +## Attendee/Team Details + +**Team Name:** ICS Core Team +**Name:** Prabhas Satti +**GitHub Username:** prabii +**LinkedIn Profile:** https://www.linkedin.com/in/prabii +**GitHub Project Repository:** https://github.com/prabii/slither-quest-game + +--- + +## Problem Statement Selected + +Custom — Real-time multiplayer game powered by Valkey and Breeth AI + +--- + +## Project Description + +Slither Quest is a real-time browser-based multiplayer snake game (Slither.io-style) built for the Build Beyond Limits 2.0 Hackathon, powered by **Valkey** and **Breeth AI**. + +- **What it does:** Players control a growing snake on a shared 4000×4000 canvas. Eat pellets, grow longer, avoid other snakes. Boost burns your length but drops pellets for others. The last snake alive wins. +- **Who it's for:** Anyone who wants to play a real-time multiplayer game in the browser — no install needed. +- **Problem solved:** Demonstrates how Valkey (Redis-protocol) can serve as a low-latency distributed state store and pub/sub event bus, and how Breeth AI can give a stateless web game persistent, personalised memory across sessions. +- **How it helps:** Players get a personalised greeting on join ("Run #3 — best was length 45, last killed by Bob") and every death is stored so the AI recalls it next session. + +--- + +## Approach + +- **Server-authoritative 25Hz game loop**: browser sends only `{dir, boost}`, server owns all positions, growth, and collision. Prevents cheating, keeps all players in sync. +- **Multi-room system**: Each room has an isolated game world, a 4-char code (e.g. `XK92`), an optional password. Rooms auto-delete 30s after the last player leaves. +- **PIN-based login**: 4-digit PIN stored SHA-256 hashed in Valkey (`game:users` hash). First use = register, return use = verify. Tied to Breeth AI memory so history follows you. +- **Valkey**: Sorted-set leaderboard, player name hash, TTL world snapshot, pub/sub killfeed — all namespaced per room. Updated every 2s to avoid per-tick round trips to the remote Aiven instance. +- **Breeth AI**: On join → `POST /v1/search` recalls player history → personalised greeting. On death → `POST /v1/episodes` stores run for future recall. Full in-memory fallback if no key. + +--- + +## Tech Stack and Tools Used + +**Frontend:** React 18, Vite, HTML5 Canvas (no game engine) +**Backend:** Node.js, Express, `ws` WebSocket +**Database:** Valkey (Aiven cloud) via `ioredis` — sorted set, hash, pub/sub, TTL string +**AI Tools/API:** Breeth AI — `POST /v1/episodes` (write memory), `POST /v1/search` (recall memory) +**Cloud/Deployment:** Aiven (Valkey cloud), localtunnel / ngrok for public tunnel hosting +**Other Tools:** SHA-256 PIN hashing via Node.js built-in `crypto` + +--- + +## Key Features + +1. **Multi-room system** — create rooms with 4-char codes, optional password, isolated game worlds +2. **PIN-based login** — 4-digit PIN protects your name and ties to Breeth AI memory across sessions +3. **Server-authoritative 25Hz game loop** — all physics and collision run on the server +4. **Valkey leaderboard + killfeed** — sorted-set leaderboard per room, pub/sub kill events broadcast to room +5. **Breeth AI player memory** — personalised greeting on join; death stored as episode for future recall +6. **Boost mechanic** — burns snake length, drops pellets, creates strategic trade-offs +7. **WASD + arrow keys + mouse** — full keyboard steering with diagonal support +8. **Room code badge** — shown in-game HUD, click to copy and share with friends +9. **One-URL hosting** — Express serves built client; shareable via localtunnel/ngrok +10. **30/30 automated flight-test suite** — `node server/flight-test.js` + +--- + +## What is Working? + +- Full real-time multiplayer (tested with multiple simultaneous players) +- Multi-room: create room (get shareable 4-char code), join room (enter code + PIN) +- PIN auth: register on first use, verify on return — SHA-256 hashed and stored in Valkey +- Snake movement, growth, boost, death/respawn cycle +- Server-side collision detection (head vs body, wall boundary) +- Valkey: PING, per-room world snapshot (EX 5), leaderboard sorted set, names hash, pub/sub killfeed — all verified +- Breeth AI: episodes write and search recall — verified; in-game greeting banner shown on join +- HUD: leaderboard (top-right), room code badge (top-left), killfeed (bottom-left), score bar (bottom-center), mini-map (bottom-right) +- Death screen with 3-second cooldown and respawn +- Flight test: **30/30 passing** + +--- + +## What is Still in Progress? + +- Mobile touch controls +- Spectator mode using Valkey world snapshots +- Skin/colour customisation persisted via Breeth AI +- Persistent score history across server restarts +- Room browser (list all active rooms) + +--- + +## Screenshots or Demo + +**Deployed Link:** Run locally — see README in project repo +**Demo Video Link:** — +**Screenshots:** — + +--- + +## Challenges Faced + +- **Valkey remote latency:** Updating every tick caused lag. Fixed by batching writes every 2 seconds and caching leaderboard in memory between syncs. +- **Windows filesystem bug:** Upstream repo has `SECURITY.md ` (trailing space) — Windows cannot check it out. Resolved using `git commit-tree` to link branch histories without touching the working directory. +- **Multi-room game loop:** Moved from a single `setInterval` to a master loop that ticks all active rooms. Rooms with zero clients are skipped entirely. +- **Breeth cold-start:** First search on a new name returns no edges. Handled gracefully — no greeting shown on first run, no crash or fallback error. + +--- + +## Learnings + +- Valkey pub/sub requires a **separate ioredis connection** for the subscriber — using the same connection blocks the event loop. +- Breeth stores knowledge as a graph of entities and edges — framing memories as natural-language sentences lets the search extract structured facts automatically. +- Server-authoritative game loops are simpler than expected: one `setInterval`, broadcast the result, trust nothing from the client. +- `git commit-tree` can create merge commits without touching the working directory — very useful for cross-platform filename issues. + +--- + +## Future Improvements + +- Deploy to a cloud VM for persistent public access +- Breeth-powered seasonal events and win-streak messages +- Valkey streams instead of pub/sub for a persistent, replayable kill log +- Leaderboard history across server restarts (persist player scores permanently) + +--- + +## Final Note + +Built end-to-end as a working prototype — server, client, Valkey integration, Breeth AI memory, multi-room system, PIN-based login, and a 30-check automated flight test suite. Everything runs with two commands. Both Valkey and Breeth have graceful in-memory fallbacks so the game is always playable even without credentials. + +```bash +cd client && npm install && npm run build +cd server && npm install && npm run dev +``` diff --git a/submissions/putYourSubmissionsHere.md b/submissions/putYourSubmissionsHere.md deleted file mode 100644 index e69de29..0000000