From e8c3f9862a07500de4cb05962a7013d1c5c6be70 Mon Sep 17 00:00:00 2001 From: Armel van Ravels Date: Mon, 7 Sep 2026 20:37:40 +0200 Subject: [PATCH] initial commit --- .agents/skills/infer-conventions/SKILL.md | 104 + .../infer-conventions/references/checklist.md | 137 + .../skills/laravel-best-practices/SKILL.md | 59 + .../rules/advanced-queries.md | 106 + .../rules/architecture.md | 219 + .../rules/blade-views.md | 36 + .../laravel-best-practices/rules/caching.md | 100 + .../rules/collections.md | 72 + .../laravel-best-practices/rules/config.md | 87 + .../rules/db-performance.md | 189 + .../laravel-best-practices/rules/eloquent.md | 158 + .../rules/error-handling.md | 77 + .../rules/events-notifications.md | 52 + .../rules/http-client.md | 157 + .../laravel-best-practices/rules/mail.md | 54 + .../rules/migrations.md | 67 + .../rules/queue-jobs.md | 117 + .../laravel-best-practices/rules/routing.md | 106 + .../rules/scheduling.md | 61 + .../laravel-best-practices/rules/security.md | 156 + .../laravel-best-practices/rules/style.md | 110 + .../rules/validation.md | 89 + .../skills/tailwindcss-development/SKILL.md | 96 + .../skills/testing-best-practices/SKILL.md | 57 + .../rules/assertions.md | 60 + .../rules/endpoint-tests.md | 47 + .../rules/finding-features.md | 33 + .../testing-best-practices/rules/isolation.md | 52 + .../testing-best-practices/rules/naming.md | 37 + .../rules/performance.md | 46 + .../testing-best-practices/rules/review.md | 42 + .../testing-best-practices/rules/security.md | 27 + .../testing-best-practices/rules/test-data.md | 68 + .claude/skills/infer-conventions/SKILL.md | 104 + .../infer-conventions/references/checklist.md | 137 + .../skills/laravel-best-practices/SKILL.md | 59 + .../rules/advanced-queries.md | 106 + .../rules/architecture.md | 219 + .../rules/blade-views.md | 36 + .../laravel-best-practices/rules/caching.md | 100 + .../rules/collections.md | 72 + .../laravel-best-practices/rules/config.md | 87 + .../rules/db-performance.md | 189 + .../laravel-best-practices/rules/eloquent.md | 158 + .../rules/error-handling.md | 77 + .../rules/events-notifications.md | 52 + .../rules/http-client.md | 157 + .../laravel-best-practices/rules/mail.md | 54 + .../rules/migrations.md | 67 + .../rules/queue-jobs.md | 117 + .../laravel-best-practices/rules/routing.md | 106 + .../rules/scheduling.md | 61 + .../laravel-best-practices/rules/security.md | 156 + .../laravel-best-practices/rules/style.md | 110 + .../rules/validation.md | 89 + .../skills/tailwindcss-development/SKILL.md | 96 + .../skills/testing-best-practices/SKILL.md | 57 + .../rules/assertions.md | 60 + .../rules/endpoint-tests.md | 47 + .../rules/finding-features.md | 33 + .../testing-best-practices/rules/isolation.md | 52 + .../testing-best-practices/rules/naming.md | 37 + .../rules/performance.md | 46 + .../testing-best-practices/rules/review.md | 42 + .../testing-best-practices/rules/security.md | 27 + .../testing-best-practices/rules/test-data.md | 68 + .dockerignore | 20 + .editorconfig | 18 + .env.example | 65 + .gitattributes | 11 + .gitignore | 29 + .mcp.json | 11 + .npmrc | 2 + AGENTS.md | 167 + CLAUDE.md | 167 + Dockerfile | 62 + README.md | 58 + app/Console/Commands/GameSimulate.php | 130 + app/Events/GameRoomError.php | 45 + app/Events/GameSnapshot.php | 55 + app/Game/Entities/Boss.php | 84 + app/Game/Entities/Bullet.php | 82 + app/Game/Entities/Leaper.php | 103 + app/Game/Entities/MegaTank.php | 49 + app/Game/Entities/MegaWitch.php | 142 + app/Game/Entities/Pickup.php | 109 + app/Game/Entities/Witch.php | 125 + app/Game/Entities/WitchProjectile.php | 77 + app/Game/Entities/Zombie.php | 173 + app/Game/EntityList.php | 41 + app/Game/GameEngine.php | 319 + app/Game/GameObject.php | 56 + app/Game/GameSimulator.php | 308 + app/Game/InputState.php | 38 + app/Game/Player.php | 310 + app/Game/Room.php | 136 + app/Game/Spawner.php | 331 + app/Game/Vector2.php | 91 + app/Http/Controllers/Controller.php | 8 + app/Http/Controllers/GameInputController.php | 42 + app/Listeners/PushClientEventToInputQueue.php | 86 + app/Models/User.php | 32 + app/Providers/AppServiceProvider.php | 24 + artisan | 18 + boost.json | 17 + bootstrap/app.php | 23 + bootstrap/cache/.gitignore | 2 + bootstrap/providers.php | 7 + composer.json | 89 + composer.lock | 9646 +++++++++++++++++ config/app.php | 126 + config/auth.php | 117 + config/broadcasting.php | 82 + config/cache.php | 136 + config/cors.php | 37 + config/database.php | 184 + config/filesystems.php | 80 + config/logging.php | 140 + config/mail.php | 118 + config/queue.php | 129 + config/reverb.php | 102 + config/services.php | 38 + config/session.php | 233 + database/.gitignore | 1 + database/factories/UserFactory.php | 45 + .../0001_01_01_000000_create_users_table.php | 49 + .../0001_01_01_000001_create_cache_table.php | 35 + .../0001_01_01_000002_create_jobs_table.php | 59 + database/seeders/DatabaseSeeder.php | 25 + devenv.lock | 45 + devenv.nix | 26 + devenv.yaml | 3 + docker-compose.yml | 97 + entrypoint.sh | 18 + nginx/default.conf | 53 + opencode.json | 14 + package-lock.json | 2354 ++++ package.json | 19 + phpunit.xml | 36 + public/.htaccess | 25 + public/favicon.ico | 0 public/index.php | 20 + public/robots.txt | 2 + resources/css/game.css | 245 + resources/js/engine/Effects.js | 231 + resources/js/engine/GameObject.js | 29 + resources/js/engine/Input.js | 56 + resources/js/engine/ParticleSystem.js | 109 + resources/js/engine/Renderer.js | 251 + resources/js/engine/SoundSystem.js | 372 + resources/js/engine/TextRenderer.js | 70 + resources/js/engine/Texture.js | 56 + resources/js/engine/Vector2D.js | 67 + resources/js/game/Boss.js | 107 + resources/js/game/Bullet.js | 46 + resources/js/game/Leaper.js | 263 + resources/js/game/MegaTank.js | 44 + resources/js/game/MegaWitch.js | 163 + resources/js/game/Pickup.js | 75 + resources/js/game/Player.js | 391 + resources/js/game/Witch.js | 265 + resources/js/game/WitchProjectile.js | 49 + resources/js/game/Zombie.js | 377 + resources/js/main.js | 1103 ++ resources/js/net/NetEntities.js | 172 + resources/js/net/NetworkClient.js | 201 + resources/js/net/SocketClient.js | 155 + resources/views/app.blade.php | 56 + routes/api.php | 6 + routes/channels.php | 6 + routes/console.php | 8 + routes/web.php | 7 + storage/app/.gitignore | 4 + storage/app/private/.gitignore | 2 + storage/app/public/.gitignore | 2 + storage/framework/.gitignore | 10 + storage/framework/cache/.gitignore | 3 + storage/framework/cache/data/.gitignore | 2 + storage/framework/sessions/.gitignore | 2 + storage/framework/testing/.gitignore | 2 + storage/framework/views/.gitignore | 2 + storage/logs/.gitignore | 2 + tests/Feature/ExampleTest.php | 19 + tests/TestCase.php | 10 + tests/Unit/ExampleTest.php | 16 + tests/Unit/Game/EntityListTest.php | 83 + tests/Unit/Game/GameEngineTest.php | 226 + tests/Unit/Game/GameSimulatorTest.php | 358 + tests/Unit/Game/PlayerTest.php | 175 + tests/Unit/Game/RoomTest.php | 69 + tests/Unit/Game/Vector2Test.php | 126 + tests/Unit/Game/ZombieTest.php | 74 + vite.config.js | 16 + 193 files changed, 29795 insertions(+) create mode 100644 .agents/skills/infer-conventions/SKILL.md create mode 100644 .agents/skills/infer-conventions/references/checklist.md create mode 100644 .agents/skills/laravel-best-practices/SKILL.md create mode 100644 .agents/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 .agents/skills/laravel-best-practices/rules/architecture.md create mode 100644 .agents/skills/laravel-best-practices/rules/blade-views.md create mode 100644 .agents/skills/laravel-best-practices/rules/caching.md create mode 100644 .agents/skills/laravel-best-practices/rules/collections.md create mode 100644 .agents/skills/laravel-best-practices/rules/config.md create mode 100644 .agents/skills/laravel-best-practices/rules/db-performance.md create mode 100644 .agents/skills/laravel-best-practices/rules/eloquent.md create mode 100644 .agents/skills/laravel-best-practices/rules/error-handling.md create mode 100644 .agents/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 .agents/skills/laravel-best-practices/rules/http-client.md create mode 100644 .agents/skills/laravel-best-practices/rules/mail.md create mode 100644 .agents/skills/laravel-best-practices/rules/migrations.md create mode 100644 .agents/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 .agents/skills/laravel-best-practices/rules/routing.md create mode 100644 .agents/skills/laravel-best-practices/rules/scheduling.md create mode 100644 .agents/skills/laravel-best-practices/rules/security.md create mode 100644 .agents/skills/laravel-best-practices/rules/style.md create mode 100644 .agents/skills/laravel-best-practices/rules/validation.md create mode 100644 .agents/skills/tailwindcss-development/SKILL.md create mode 100644 .agents/skills/testing-best-practices/SKILL.md create mode 100644 .agents/skills/testing-best-practices/rules/assertions.md create mode 100644 .agents/skills/testing-best-practices/rules/endpoint-tests.md create mode 100644 .agents/skills/testing-best-practices/rules/finding-features.md create mode 100644 .agents/skills/testing-best-practices/rules/isolation.md create mode 100644 .agents/skills/testing-best-practices/rules/naming.md create mode 100644 .agents/skills/testing-best-practices/rules/performance.md create mode 100644 .agents/skills/testing-best-practices/rules/review.md create mode 100644 .agents/skills/testing-best-practices/rules/security.md create mode 100644 .agents/skills/testing-best-practices/rules/test-data.md create mode 100644 .claude/skills/infer-conventions/SKILL.md create mode 100644 .claude/skills/infer-conventions/references/checklist.md create mode 100644 .claude/skills/laravel-best-practices/SKILL.md create mode 100644 .claude/skills/laravel-best-practices/rules/advanced-queries.md create mode 100644 .claude/skills/laravel-best-practices/rules/architecture.md create mode 100644 .claude/skills/laravel-best-practices/rules/blade-views.md create mode 100644 .claude/skills/laravel-best-practices/rules/caching.md create mode 100644 .claude/skills/laravel-best-practices/rules/collections.md create mode 100644 .claude/skills/laravel-best-practices/rules/config.md create mode 100644 .claude/skills/laravel-best-practices/rules/db-performance.md create mode 100644 .claude/skills/laravel-best-practices/rules/eloquent.md create mode 100644 .claude/skills/laravel-best-practices/rules/error-handling.md create mode 100644 .claude/skills/laravel-best-practices/rules/events-notifications.md create mode 100644 .claude/skills/laravel-best-practices/rules/http-client.md create mode 100644 .claude/skills/laravel-best-practices/rules/mail.md create mode 100644 .claude/skills/laravel-best-practices/rules/migrations.md create mode 100644 .claude/skills/laravel-best-practices/rules/queue-jobs.md create mode 100644 .claude/skills/laravel-best-practices/rules/routing.md create mode 100644 .claude/skills/laravel-best-practices/rules/scheduling.md create mode 100644 .claude/skills/laravel-best-practices/rules/security.md create mode 100644 .claude/skills/laravel-best-practices/rules/style.md create mode 100644 .claude/skills/laravel-best-practices/rules/validation.md create mode 100644 .claude/skills/tailwindcss-development/SKILL.md create mode 100644 .claude/skills/testing-best-practices/SKILL.md create mode 100644 .claude/skills/testing-best-practices/rules/assertions.md create mode 100644 .claude/skills/testing-best-practices/rules/endpoint-tests.md create mode 100644 .claude/skills/testing-best-practices/rules/finding-features.md create mode 100644 .claude/skills/testing-best-practices/rules/isolation.md create mode 100644 .claude/skills/testing-best-practices/rules/naming.md create mode 100644 .claude/skills/testing-best-practices/rules/performance.md create mode 100644 .claude/skills/testing-best-practices/rules/review.md create mode 100644 .claude/skills/testing-best-practices/rules/security.md create mode 100644 .claude/skills/testing-best-practices/rules/test-data.md create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .mcp.json create mode 100644 .npmrc create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/Console/Commands/GameSimulate.php create mode 100644 app/Events/GameRoomError.php create mode 100644 app/Events/GameSnapshot.php create mode 100644 app/Game/Entities/Boss.php create mode 100644 app/Game/Entities/Bullet.php create mode 100644 app/Game/Entities/Leaper.php create mode 100644 app/Game/Entities/MegaTank.php create mode 100644 app/Game/Entities/MegaWitch.php create mode 100644 app/Game/Entities/Pickup.php create mode 100644 app/Game/Entities/Witch.php create mode 100644 app/Game/Entities/WitchProjectile.php create mode 100644 app/Game/Entities/Zombie.php create mode 100644 app/Game/EntityList.php create mode 100644 app/Game/GameEngine.php create mode 100644 app/Game/GameObject.php create mode 100644 app/Game/GameSimulator.php create mode 100644 app/Game/InputState.php create mode 100644 app/Game/Player.php create mode 100644 app/Game/Room.php create mode 100644 app/Game/Spawner.php create mode 100644 app/Game/Vector2.php create mode 100644 app/Http/Controllers/Controller.php create mode 100644 app/Http/Controllers/GameInputController.php create mode 100644 app/Listeners/PushClientEventToInputQueue.php create mode 100644 app/Models/User.php create mode 100644 app/Providers/AppServiceProvider.php create mode 100755 artisan create mode 100644 boost.json create mode 100644 bootstrap/app.php create mode 100644 bootstrap/cache/.gitignore create mode 100644 bootstrap/providers.php create mode 100644 composer.json create mode 100644 composer.lock create mode 100644 config/app.php create mode 100644 config/auth.php create mode 100644 config/broadcasting.php create mode 100644 config/cache.php create mode 100644 config/cors.php create mode 100644 config/database.php create mode 100644 config/filesystems.php create mode 100644 config/logging.php create mode 100644 config/mail.php create mode 100644 config/queue.php create mode 100644 config/reverb.php create mode 100644 config/services.php create mode 100644 config/session.php create mode 100644 database/.gitignore create mode 100644 database/factories/UserFactory.php create mode 100644 database/migrations/0001_01_01_000000_create_users_table.php create mode 100644 database/migrations/0001_01_01_000001_create_cache_table.php create mode 100644 database/migrations/0001_01_01_000002_create_jobs_table.php create mode 100644 database/seeders/DatabaseSeeder.php create mode 100644 devenv.lock create mode 100644 devenv.nix create mode 100644 devenv.yaml create mode 100644 docker-compose.yml create mode 100644 entrypoint.sh create mode 100644 nginx/default.conf create mode 100644 opencode.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 phpunit.xml create mode 100644 public/.htaccess create mode 100644 public/favicon.ico create mode 100644 public/index.php create mode 100644 public/robots.txt create mode 100644 resources/css/game.css create mode 100644 resources/js/engine/Effects.js create mode 100644 resources/js/engine/GameObject.js create mode 100644 resources/js/engine/Input.js create mode 100644 resources/js/engine/ParticleSystem.js create mode 100644 resources/js/engine/Renderer.js create mode 100644 resources/js/engine/SoundSystem.js create mode 100644 resources/js/engine/TextRenderer.js create mode 100644 resources/js/engine/Texture.js create mode 100644 resources/js/engine/Vector2D.js create mode 100644 resources/js/game/Boss.js create mode 100644 resources/js/game/Bullet.js create mode 100644 resources/js/game/Leaper.js create mode 100644 resources/js/game/MegaTank.js create mode 100644 resources/js/game/MegaWitch.js create mode 100644 resources/js/game/Pickup.js create mode 100644 resources/js/game/Player.js create mode 100644 resources/js/game/Witch.js create mode 100644 resources/js/game/WitchProjectile.js create mode 100644 resources/js/game/Zombie.js create mode 100644 resources/js/main.js create mode 100644 resources/js/net/NetEntities.js create mode 100644 resources/js/net/NetworkClient.js create mode 100644 resources/js/net/SocketClient.js create mode 100644 resources/views/app.blade.php create mode 100644 routes/api.php create mode 100644 routes/channels.php create mode 100644 routes/console.php create mode 100644 routes/web.php create mode 100644 storage/app/.gitignore create mode 100644 storage/app/private/.gitignore create mode 100644 storage/app/public/.gitignore create mode 100644 storage/framework/.gitignore create mode 100644 storage/framework/cache/.gitignore create mode 100644 storage/framework/cache/data/.gitignore create mode 100644 storage/framework/sessions/.gitignore create mode 100644 storage/framework/testing/.gitignore create mode 100644 storage/framework/views/.gitignore create mode 100644 storage/logs/.gitignore create mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ExampleTest.php create mode 100644 tests/Unit/Game/EntityListTest.php create mode 100644 tests/Unit/Game/GameEngineTest.php create mode 100644 tests/Unit/Game/GameSimulatorTest.php create mode 100644 tests/Unit/Game/PlayerTest.php create mode 100644 tests/Unit/Game/RoomTest.php create mode 100644 tests/Unit/Game/Vector2Test.php create mode 100644 tests/Unit/Game/ZombieTest.php create mode 100644 vite.config.js diff --git a/.agents/skills/infer-conventions/SKILL.md b/.agents/skills/infer-conventions/SKILL.md new file mode 100644 index 0000000..e7fcafc --- /dev/null +++ b/.agents/skills/infer-conventions/SKILL.md @@ -0,0 +1,104 @@ +--- +name: infer-conventions +description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand." +license: MIT +metadata: + author: laravel +--- + +# Infer Conventions + +Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it. + +## Ground Rules (read before you start) + +- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer. +- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record. +- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule. +- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering. +- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped. +- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar. +- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details. + +## Process + +Each step ends on a checkable completion criterion. Do not advance until it holds. + +Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output. + +### Step 0: Orient + +Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2. + +This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions. + +Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents. + +### Step 1: Predefined sweep + +Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict: + +- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files. +- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled. +- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention. +- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most). +- Tooling-owned or Already-recorded. Skip per the ground rules. + +Done when: every applicable dimension carries exactly one of those verdicts. + +### Step 2: Open-ended pass + +First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude. + +Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal. + +Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none). + +### Step 3: Confirm + +Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style. + +Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo. + +Done when: every candidate is approved, rejected, or (conflicts) decided. + +### Step 4: Record + +Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand. + +Record this: + +> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models. + +Not this: + +> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models. + +Done when: every approved item has a successful tool response, and any failure is reported with its rule text. + +### Step 5: Summarize + +List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions. + +## Glob mapping + +Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path. + +Examples: + +- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one. +- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer. +- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses. +- Tests: `tests/**`. +- Migrations and database: `database/migrations/**`. +- Truly app-wide (rare, e.g. auth retrieval): `app/**`. + +`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there. + +## Edge cases + +- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4. +- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing. +- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything. +- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface. +- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths. diff --git a/.agents/skills/infer-conventions/references/checklist.md b/.agents/skills/infer-conventions/references/checklist.md new file mode 100644 index 0000000..80cef34 --- /dev/null +++ b/.agents/skills/infer-conventions/references/checklist.md @@ -0,0 +1,137 @@ +# Detection Checklist + +Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`). + +Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence. + +--- + +## A. Validation & HTTP input + +1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`. + - Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`. +2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal. + - Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`. +3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties. + - Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`. +4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods. + - Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`. + +## B. Controllers & routing + +5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method. + - Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes. +6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs. + - Hint: read a few controller methods; `ls app/Actions app/Services`. +7. Route handler style: closures in `routes/*.php` vs controller classes. + - Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`. +8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute. + - Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes. +9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`. + - Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`. +10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`. + - Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files. + +## C. Authorization + +11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`. + - Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`. +12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade. + - Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`. + +## D. Eloquent & models + +13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list. + - Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`. +14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain. + - Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`. +15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`. + - Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`. +16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings. + - Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models. +17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`). + - Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built. +18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes. + - Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`. +19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes. + - Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`. +20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture. + - Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`. + +## E. Architecture & organization + +21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked. + - Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find. +22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere. + - Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`. +23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location. + - Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps. +24. Decoupling: events + listeners vs direct service calls. + - Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`. +25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`). + - Hint: ratio of `config(` vs `Config::` (etc.) across `app/`. +26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules). + - Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders. +27. Enums: backed vs pure; case naming; where they live. + - Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`. + +## F. Frontend & views + +No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable. + +28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA. + - Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`. +29. Blade composition: class `` components vs anonymous components (`@props`) vs `@include` partials. + - Hint: `ls app/View/Components`; grep `constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`. + - Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`. +34. `down()` methods: real reverse logic vs omitted / one-way migrations. + - Hint: grep `function down` vs the migration count. +35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model. + - Hint: grep `->enum(` in migrations vs string columns cast to enums. +36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`. + - Hint: grep `DB::transaction`, `beginTransaction` in `app/`. +37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save. + - Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`. + +## H. Testing + +38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes. + - Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`. +39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`. + - Hint: grep those trait names in `tests/`. +40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories. + - Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide. +41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery. + - Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`. +42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`. + - Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`. + +## I. Responses & API resources + +43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly. + - Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers. +44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately. + - Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`. +45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority. + - Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them. +46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`. + - Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views. + +## J. Strings, collections & dates + +47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`. + - Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`. +48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`). + - Hint: grep `Str::of(` vs `Str::` vs native string funcs. +49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting. + - Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy. + +--- + +Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from. diff --git a/.agents/skills/laravel-best-practices/SKILL.md b/.agents/skills/laravel-best-practices/SKILL.md new file mode 100644 index 0000000..311ab84 --- /dev/null +++ b/.agents/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Tests: coverage, factories, fakes, and assertions | the `testing-best-practices` skill | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.agents/skills/laravel-best-practices/rules/advanced-queries.md b/.agents/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 0000000..54dd783 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Best Practices + +## Select Single Relationship Values with Subqueries + +When only one value from a has-many relationship is needed, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. This selects the value as part of the main query without an additional relationship query. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships with a Subquery Foreign Key + +The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still executes a separate query, but it avoids loading the full has-many collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class, 'last_login_id'); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Combine Related Counts with Conditional Aggregates + +Combine several counts over the same filtered data set into one query by using conditional aggregates. Use `toBase()` when only scalar values are needed and model hydration provides no benefit. Confirm the expression syntax against the application's database engine. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Reuse Loaded Parent Models with `setRelation()` + +When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. This avoids an additional lazy-loading query for each child. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Compare `whereHas()` with an `IN` Subquery + +`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. Measure both forms with representative data; neither subquery loads its result set into PHP memory. + +Option using `EXISTS`: + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Option using `IN`: + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Measure Two Simple Queries Against One Complex Query + +Two targeted queries can outperform one complex correlated subquery or join when the first query is highly selective. They also add a database round trip, can transfer a large identifier list, and do not provide a single-query consistency snapshot. Decide from query plans and production-like measurements. + +## Design Composite Indexes for the Query + +For common multi-column sorts, consider a composite index whose column order supports the query's filters and ordering. Database engines may combine indexes or choose an explicit sort, so matching the `ORDER BY` list alone does not guarantee that an index will be used. Verify the query plan. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query that this index may support +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Consider a Correlated Subquery for Has-Many Ordering + +When sorting by one value from a has-many relationship, a direct join can duplicate parent rows unless it first reduces the related table to one row per parent. A correlated subquery in `orderBy()` is often simpler, but its performance depends on the query plan and supporting indexes. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/architecture.md b/.agents/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 0000000..5e7af23 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,219 @@ +# Architecture Best Practices + +## Extract Focused Business Operations + +Extract a discrete business operation into an action class when doing so makes the operation easier to reuse or test. An action class has no special meaning to Laravel; follow the project's naming and invocation conventions. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Inject Required Dependencies + +Prefer constructor injection for dependencies required throughout an object's lifetime. Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit. + +Hidden dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Injected dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request, OrderService $service) + { + return $service->create($request->validated()); + } +} +``` + +## Depend on Contracts at Boundaries + +Depend on contracts at system boundaries, such as payment gateways, notification channels, and external services, when testability or interchangeable implementations justify the abstraction. + +Concrete boundary dependency: + +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Contract boundary dependency: + +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Specify a Deterministic Sort Order + +Without an explicit `ORDER BY`, row order is undefined. Choose an order that matches the feature, and add a unique tie-breaker when stable pagination matters. + +Unspecified order: + +```php +$posts = Post::paginate(); +``` + +Newest first with a stable tie-breaker: + +```php +$posts = Post::query() + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level, inside a transaction +DB::transaction(function () use ($id) { + $product = Product::where('id', $id)->lockForUpdate()->first(); + + // Read and update the product while the database lock is held. +}); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer multibyte-aware functions such as `mb_strlen()` and `mb_strtolower()` for UTF-8 text. For example, `strlen()` counts bytes, while `strtolower()` is not multibyte-aware. + +Incorrect: + +```php +strlen('José'); // 5 bytes, not 4 characters +strtolower('MÜNCHEN'); // Does not lowercase Ü +``` + +Correct: + +```php +mb_strlen('José'); // 4 characters +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight work that does not need retries or crash durability, consider `defer()` instead of dispatching a job. During an HTTP request, the callback normally runs after the response has been sent but remains in the same PHP process. + +Queued and durable: + +```php +dispatch(new LogPageView($page)); +``` + +Deferred in the current process: + +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use a queued job when the work needs retries, queue controls, or durability across process failures. + +## Use `Context` for Request-Scoped Data + +The `Context` facade makes contextual data available across the current execution lifecycle without manually passing arguments through every layer. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Later in the same execution lifecycle +$tenantId = Context::get('tenant_id'); +``` + +Visible context is added to log context, and both visible and hidden context are captured and restored for queued jobs. Use `Context::addHidden()` for data that should propagate to queued jobs without appearing in logs. Do not place secrets in context unless that propagation is intended. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations concurrently through Laravel's configured concurrency driver. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +With a process-based driver, each closure runs in a separate PHP process that boots the application. Use concurrency when independent database queries, HTTP client calls, or computations benefit enough to offset process and serialization overhead. The `sync` driver executes closures sequentially and is useful primarily during testing. + +## Follow Framework Conventions + +Follow Laravel conventions unless the domain or an existing schema requires an override. + +Customized schema: + +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Conventional schema: + +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/blade-views.md b/.agents/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 0000000..4ea2c6c --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade and View Best Practices + +## Use `$attributes->merge()` in Component Templates + +Use the component attribute bag so callers can add attributes. `merge()` combines default attributes with caller-provided values; class values receive special merging behavior. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders repeatedly, `@push` adds its script on every render. Use a consistently named `@pushOnce` block to add that content once per rendered response. + +## Prefer Components for Explicit Interfaces + +Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include remains suitable for a small partial that intentionally uses the current view data; pass an explicit data array when implicit variable sharing would obscure its dependencies. + +## Share Compatible View Data with a View Composer + +Use a view composer to centralize data needed whenever one or more named Blade views are rendered. Keep the composer compatible with every view it targets, and avoid broad wildcards when views require different data shapes. A view composer runs when Laravel renders the matching view; it does not supply data to JSON, streamed, or other non-view responses. + +## Return Blade Fragments for Partial Rendering + +A route can return either a full view or a named fragment for clients such as htmx or Turbo. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Share Parent Component Props with `@aware` + +Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. It does not expose an ancestor's default prop value unless that value was passed through the attribute bag. diff --git a/.agents/skills/laravel-best-practices/rules/caching.md b/.agents/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 0000000..cd2ffd6 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,100 @@ +# Caching Best Practices + +## Use `Cache::remember()` for Cache-Aside Reads + +`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value; use an atomic lock when duplicate computation must be prevented. + +The manual version below incorrectly treats valid falsy values, such as `false` or `0`, as cache misses. + +Incorrect: + +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: + +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Consider `Cache::flexible()` for Stale-While-Revalidate + +For frequently read keys, `Cache::flexible()` can serve stale data during a defined stale period and register a deferred refresh. During an HTTP request, that refresh normally runs after the response; it is not a durable background job. Once the stale period has elapsed, the request recomputes the value synchronously. + +Synchronous expiration: + +```php +Cache::remember('users', 300, fn () => User::all()); +``` + +Stale-while-revalidate tradeoff: + +```php +Cache::flexible('users', [300, 600], fn () => User::all()); +``` + +This value is fresh for five minutes and may be served stale until ten minutes after it was cached. + +## Use `Cache::memo()` to Avoid Redundant Hits Within an Execution + +If the same cache key is read repeatedly during one request or job, `memo()` decorates a cache store and retains resolved values in memory for that execution. + +```php +$settings = Cache::memo()->get('settings'); +``` + +Repeated reads through the same memoized store avoid additional store lookups. Writes through the memoized store update or invalidate its in-memory values as appropriate. + +## Use Cache Tags to Invalidate Related Groups + +Tags group related entries for invalidation without tracking each key. Cache tags are not supported by the `file`, `dynamodb`, or `database` drivers; confirm support before choosing a store. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` atomically writes a value only when the key does not already exist. + +Incorrect: + +```php +if (! Cache::has('lock')) { + Cache::put('lock', true, 10); +} +``` + +Correct: + +```php +Cache::add('lock', true, 10); +``` + +Use `Cache::lock()` rather than an ordinary cache key when lock ownership and safe release are required. + +## Use `once()` for In-Process Memoization + +`once()` memoizes a callback's return value for the current request or job. Calls made from an object instance are scoped to that instance. Unlike `Cache::memo()`, `once()` does not read from an external cache store. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Repeated calls return the memoized result without rerunning the callback. Use `once()` for repeated computation within one execution. Use `Cache::memo()` to memoize access to an underlying store that can also persist values across executions. + +## Configure Failover Cache Stores in Production + +The failover driver tries each configured store in order when a store operation throws an exception. It does not consult later stores for an ordinary cache miss, and data is not replicated between stores. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.agents/skills/laravel-best-practices/rules/collections.md b/.agents/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 0000000..211fec1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,72 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Explicit closure: + +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Concise equivalent: + +```php +$users->each->markAsVip(); +``` + +Higher-order messages are available for supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer. + +## Choose Between `cursor()` and `lazy()` + +`cursor()` executes one query and hydrates models individually, but it cannot eager load relationships. The database driver's result buffering can still consume substantial memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable. + +`lazy()` executes multiple chunked queries and returns a flat `LazyCollection`. It supports eager loading relationships for each chunk and avoids holding one database cursor open for the entire iteration. + +With relationships: + +```php +User::with('roles')->lazy()->each(function (User $user) { + // The roles for this chunk have been eager loaded. +}); +``` + +Without relationships: + +```php +User::cursor()->each(function (User $user) { + // Process model attributes. +}); +``` + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination, so updates to columns that affect the query can shift rows and cause records to be skipped or processed twice. `lazyById()` paginates by a monotonic key and is safer when updating other columns during iteration. Do not change the pagination key itself while iterating. + +## Use `toQuery()` for Bulk Operations on Collections + +Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause. + +Manual query: + +```php +User::whereIn('id', $users->modelKeys())->update(['active' => false]); +``` + +Collection query: + +```php +$users->toQuery()->update(['active' => false]); +``` + +`toQuery()` requires a non-empty Eloquent collection whose models are of the same type. Like other bulk Eloquent updates, it does not dispatch per-model update events, so use it only when those events are not required. + +## Use `#[CollectedBy]` for Custom Collection Classes + +The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.agents/skills/laravel-best-practices/rules/config.md b/.agents/skills/laravel-best-practices/rules/config.md new file mode 100644 index 0000000..5a34de1 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,87 @@ +# Configuration Best Practices + +## Read Environment Variables in Configuration Files + +Call `env()` only from configuration files. After configuration is cached, Laravel does not load the application's `.env` file, so application code should read configuration values through `config()`. + +Incorrect: + +```php +$key = env('API_KEY'); +``` + +Correct: + +```php +// config/services.php +return [ + 'key' => env('API_KEY'), +]; + +// Application code +$key = config('services.key'); +``` + +## Protect Production Secrets + +Do not commit plaintext production secrets. Laravel can encrypt an environment file so its encrypted form can be stored safely, while deployment platforms can supply secrets through their native secret stores. + +Incorrect: + +```bash + +# A plaintext .env file committed to the repository + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Encrypted environment file: + +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For hosted deployments, consider the platform's native secret store, such as AWS Secrets Manager or Vault, and inject secrets at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: + +```php +if (env('APP_ENV') === 'production') { + // ... +} +``` + +Correct: + +```php +if (app()->isProduction()) { + // ... +} + +if (App::environment('production')) { + // ... +} +``` + +## Name Repeated Domain Values + +Use an enum or class constant when a domain value is repeated or represents a constrained set. A one-off string literal does not always need a named constant. + +```php +// Repeated literal +return $this->type === 'normal'; + +// Named domain value +return $this->type === self::TYPE_NORMAL; +``` + +If the application supports localization, put user-facing strings in language files and retrieve them with `__()`. Simple literals are reasonable for applications that intentionally do not support multiple languages. + +```php +// In a localized application +return back()->with('message', __('app.article_added')); +``` diff --git a/.agents/skills/laravel-best-practices/rules/db-performance.md b/.agents/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 0000000..3f33977 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,189 @@ +# Database Performance Best Practices + +## Eager Load Relationships Before Iterating + +When a relationship will be accessed for many models, eager load it with `with()` to avoid running one initial query plus one relationship query per model, commonly called an N+1 query pattern. Lazy loading is reasonable when the relationship may not be needed or only one model is involved. + +Lazy-loaded version: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Eager-loaded version: + +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads when large columns are unnecessary. Include the related model's primary key and every column Eloquent needs to match the relationship. In this example, `users.id` and `posts.user_id` match posts to users, while selecting `posts.id` preserves each related model's primary key: + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +By default, accessing an unloaded relationship then throws a `LazyLoadingViolationException`. Applications can customize violation handling with `handleLazyLoadingViolationUsing()`. + +## Select Only Needed Columns + +Select only the columns the operation needs when omitting large text, binary, or JSON columns provides a meaningful benefit. + +All columns: + +```php +$posts = Post::with('author')->get(); +``` + +Selected columns: + +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When limiting selected columns, retain every key Eloquent needs for matching. A `belongsTo` relationship needs its foreign key on the parent query and the owner's key on the related query. A `hasMany` relationship needs the parent's local key and the related model's foreign key. + +## Process Large Data Sets Incrementally + +Use chunking or lazy iteration when loading an entire result set would exceed the application's practical memory budget. + +Loads the complete result set: + +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Processes bounded chunks: + +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, whose result positions can shift as rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +For read-only, attribute-only iteration, `cursor()` hydrates models individually from one query, although some database drivers still buffer raw results. Use `lazy()` when relationships must be eager loaded in chunks, and use `lazyById()` or `chunkById()` when updates can affect query membership. See the collection rules for detailed tradeoffs. + +## Add Indexes for Measured Query Patterns + +Design indexes around frequent, performance-sensitive query patterns. A column's presence in `WHERE`, `ORDER BY`, `JOIN`, or `GROUP BY` does not by itself justify an index; selectivity, write cost, existing indexes, and the database query plan all matter. + +Schema without an application-specific query index: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Schema optimized for `WHERE status = ? ORDER BY created_at`: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Confirm composite index column order and effectiveness with production-like data and the database's query-plan tools. Also check whether the database already created an index to support a foreign key before adding another one. + +## Count Relationships Without Loading Them + +Use `withCount()` when only relationship counts are needed; loading and hydrating every related model wastes memory. + +Loads related models: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Selects relationship counts: + +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Keep Queries Out of Blade Templates + +Prepare data before rendering a Blade template, such as in a controller, query service, or view composer. This keeps query behavior visible and testable. + +Query in the template: + +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Data prepared before rendering: + +```php +// Controller +$users = User::with('profile')->get(); + +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.agents/skills/laravel-best-practices/rules/eloquent.md b/.agents/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 0000000..5a89510 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,158 @@ +# Eloquent Best Practices + +## Define Precise Relationship Types + +Define the relationship that matches the database association, and declare its concrete return type. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Duplicated constraints: + +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Reusable local scope: + +```php +#[Scope] +protected function active(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Global scope tradeoff: + +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} + +// Admin panels, reports, and jobs now omit drafts unless the scope is removed. +``` + +Explicit local scope: + +```php +#[Scope] +protected function published(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date and Time Attributes + +Cast a date or timestamp attribute when application code should treat it as a Carbon instance. Eloquent already casts the conventional `created_at` and `updated_at` timestamps. + +Manual parsing in the template: + +```blade +{{ Carbon::parse($order->ordered_at)->toDateString() }} +``` + +Model cast: + +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +`whereBelongsTo()` expresses the relationship constraint without manually specifying its foreign key. + +Foreign key constraint: + +```php +Post::where('user_id', $user->id)->get(); +``` + +Relationship-aware constraint: + +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Keep Application Queries Model-Aware + +Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL legitimately require table names, so use them when their lower-level behavior is intentional. + +Lower-level alternatives: + +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Model-aware queries: + +```php +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +When a query builder operation should follow a model's configured table name, use `(new User)->getTable()`. For complex joins or raw SQL, explicit table names may be clearer; keep those references covered by tests when schema changes are possible. + +In migrations, use explicit table names rather than application models. Migrations are historical snapshots, while models and their scopes can change after a migration is deployed. diff --git a/.agents/skills/laravel-best-practices/rules/error-handling.md b/.agents/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 0000000..afcdf4a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,77 @@ +# Error Handling Best Practices + +## Choose Where to Report and Render Exceptions + +Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern already established by the project. + +Exception methods keep behavior beside the exception definition: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void + { + // Send the exception to a custom reporter. + } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { + // Send the exception to a custom reporter. + }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +An exception's `report()` method suppresses Laravel's default reporting unless it returns `false`. A report callback allows default reporting unless it returns `false` or is chained with `stop()`. Use `ShouldntReport` or `dontReport()` when the handler should not report an exception at all. By contrast, returning `false` from a `render()` method or render callback defers to Laravel's default rendering. + +## Mark Exceptions the Handler Should Not Report + +Implementing `ShouldntReport` prevents Laravel's exception handler from reporting that exception type and keeps the policy visible on the class. It does not prevent application code from logging the exception explicitly. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exception Reports + +A failing integration can flood logs or error tracking. Configure `throttle()` with a `Lottery` or `Limit` result to sample or rate-limit matching exception reports. Choose keys deliberately when separate exception classes, tenants, or integrations need independent limits. + +## Prevent Duplicate Reports of One Exception Instance + +Enable `dontReportDuplicates()` when the same exception object may pass through multiple `report($exception)` calls. It deduplicates by object identity, not by exception class or message. + +## Define JSON Rendering for API Routes + +Laravel normally uses request content negotiation to decide whether to render an exception as JSON. If the application's API contract requires JSON regardless of the `Accept` header, define that policy explicitly for the relevant routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to an exception through `context()`. Laravel merges that data into the exception's log context when the handler reports it. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/events-notifications.md b/.agents/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 0000000..a8cce31 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events and Notifications Best Practices + +## Rely on Event Discovery + +Laravel discovers listeners in the configured listener directories by inspecting type-hinted event arguments on `handle()` or `__invoke()` methods. Register listeners manually only when discovery is disabled, the listener is outside those directories, or explicit registration is clearer. + +## Cache Event Discovery During Production Deployment + +Cache discovered listeners during production deployment with `php artisan optimize` or `php artisan event:cache`. Rebuild the cache whenever listener definitions change. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +When an event is dispatched inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. This affects synchronous and queued listeners; it is not limited to queue timing. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Queue Slow Notifications + +Queue notifications that call external services, such as email, text messaging, or Slack, when they do not need to complete before the response. Keep a notification synchronous when immediate completion or failure feedback is part of the operation. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Dispatch Queued Notifications After Commit + +A queued notification sent inside a database transaction can run before the transaction commits. Call `afterCommit()` on the queued notification, or enable the queue connection's `after_commit` option, when its delivery depends on committed data. This setting has no scheduling effect on a synchronous notification. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Different notification channels can have different latency and priority requirements. Implement `viaQueues()` when channels should use separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Implement `HasLocalePreference::preferredLocale()` on a notifiable model when notifications and mailables should use the recipient's locale. Laravel also preserves that locale for queued delivery. An explicit `locale()` call can still override the preference for an individual notification. diff --git a/.agents/skills/laravel-best-practices/rules/http-client.md b/.agents/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 0000000..cc8fdd6 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,157 @@ +# HTTP Client Best Practices + +## Set Explicit Timeouts + +Laravel's HTTP client has a 30-second response timeout by default. Choose response and connection timeouts that fit the service and the calling request or job. Remember that retries can multiply the total elapsed time. + +Less resilient: + +```php +$response = Http::get('https://api.example.com/users'); +``` + +Preferred: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users'); +``` + +Define shared settings in a macro or a dedicated client: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->connectTimeout(3) + ->timeout(10) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Retry Only Safe Operations + +Retry transient connection failures, rate-limit responses, and server errors with an appropriate delay. Retry idempotent requests such as `GET` when the operation can safely run more than once. Retry a state-changing request only when the remote API supports an idempotency key or provides equivalent duplicate protection. + +Unsafe without an idempotency guarantee: + +```php +$response = Http::retry([100, 500, 1000]) + ->post('https://api.example.com/v1/charges', $data); +``` + +Safe for an idempotent request: + +```php +$response = Http::connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->get('https://api.example.com/data'); +``` + +For a supported state-changing API, send a stable idempotency key for every attempt: + +```php +$response = Http::withHeaders(['Idempotency-Key' => $paymentAttempt->uuid]) + ->connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->post('https://api.example.com/v1/charges', $data); +``` + +## Handle Errors Explicitly + +The HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing by default. Inspect the expected statuses or call `throw()` before consuming a success payload. + +Unsafe when a success payload is expected: + +```php +$user = Http::get('https://api.example.com/users/1')->json(); +``` + +Preferred: + +```php +$user = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1') + ->throw() + ->json(); +``` + +Handle expected alternatives explicitly when graceful degradation is required: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Pool Independent Requests + +Use `Http::pool()` when several independent requests can run concurrently. Pooling changes execution time, not error handling; inspect or throw for each response as needed. + +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/users'), + $pool->as('posts')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/posts'), +]); + +$users = $responses['users']->throw()->json(); +$posts = $responses['posts']->throw()->json(); +``` + +## Fake HTTP Requests in Tests + +Use `Http::fake()` for external integrations, and use `Http::preventStrayRequests()` when an unexpected real request should fail the test. Also test timeouts, connection failures, and error responses that the application handles. + +```php +it('syncs a user from the API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + (new UserSyncService)->sync(1); + + Http::assertSent(fn (Request $request) => + $request->url() === 'https://api.example.com/users/1' + ); +}); +``` + +For example, fake a connection failure when testing the integration's failure path: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.agents/skills/laravel-best-practices/rules/mail.md b/.agents/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 0000000..f631043 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,54 @@ +# Mail Best Practices + +## Queue Slow Mail Delivery + +Implement `ShouldQueue` on a mailable when delivery should normally happen in the background. Laravel queues that mailable even when the call site uses `Mail::send()`. + +```php +class OrderShipped extends Mailable implements ShouldQueue +{ + use Queueable, SerializesModels; +} +``` + +Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available. + +## Dispatch Queued Mail After Commit + +A queued mailable dispatched during a database transaction can be processed before the transaction commits. Call `afterCommit()` on the mailable, or enable the queue connection's `after_commit` option, when the mail depends on committed records. + +```php +Mail::to($user)->send( + (new OrderShipped($order))->afterCommit() +); +``` + +If the transaction rolls back, an after-commit mailable is not dispatched. This setting affects queued mail only; it does not defer synchronous delivery. + +## Assert the Delivery Mode + +Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables. + +Incorrect for a mailable that implements `ShouldQueue`: + +```php +Mail::assertSent(OrderShipped::class); +``` + +Correct: + +```php +Mail::assertQueued(OrderShipped::class); +``` + +## Use Markdown Mailables When They Fit + +Markdown mailables render HTML and plain-text versions from Laravel's mail components and support publishable themes. They are useful for conventional transactional messages, but a custom HTML and text pair may be more appropriate for a specialized design. + +```bash +php artisan make:mail OrderShipped --markdown=mail.orders.shipped +``` + +## Separate Content and Delivery Tests + +Test rendered content by instantiating the mailable and using assertions such as `assertSeeInHtml()` and `assertSeeInText()`. Test delivery separately with `Mail::fake()` and `assertSent()` or `assertQueued()` so failures identify the affected behavior. diff --git a/.agents/skills/laravel-best-practices/rules/migrations.md b/.agents/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 0000000..3345402 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,67 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Use `php artisan make:migration` to generate the timestamped filename and migration structure. + +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Define Foreign-Key Constraints Deliberately + +Use `constrained()` when its naming conventions and default actions match the relationship. Specify the table or delete behavior when they do not. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); +$table->foreignId('author_id')->constrained('users'); +``` + +Do not add a duplicate single-column index without checking the database driver's treatment of foreign-key indexes and the indexes already created by the migration. + +## Treat Deployed Migrations as Immutable + +After a migration has run in a shared or production environment, create a new migration for subsequent changes. Editing the old file makes fresh installations differ from upgraded installations. + +For a local migration that has not been shared or deployed, editing and rerunning it may be simpler. + +## Design Indexes for Real Queries + +Add indexes based on query patterns, selectivity, write cost, and the database's ability to use composite indexes. A column appearing in `WHERE`, `ORDER BY`, or `JOIN` does not automatically need its own index. + +Declare each selected index in the schema migration that creates or changes the relevant table. Confirm important indexes with representative data and the database's query plan, and avoid redundant indexes whose leading columns duplicate an existing index without serving a distinct query. See the database performance and advanced query rules for index selection and column-order guidance. + +## Stage Changes That Affect Existing Rows + +Adding a required or unique column to a populated table often needs multiple deployment-safe steps. Add a nullable column, deploy code that can handle both states, backfill existing rows in bounded chunks, then add the required constraint or index after the data is valid. + +Do not assume this migration is safe on a populated table: + +```php +$table->string('slug')->unique(); +``` + +Large backfills are usually better implemented as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when their locking, transaction, and deployment behavior is understood. + +## Mirror Defaults Only When Unsaved Models Need Them + +A database default is applied when a row is inserted, not when a model is instantiated. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions synchronized. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Make Rollbacks Honest + +Implement `down()` when the change can be safely reversed. A rollback that drops populated columns or cannot restore transformed data is destructive even if it is syntactically reversible; document that limitation and prefer a forward-fix migration in production. + +## Keep Migrations Focused + +Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when doing so reduces locks and supports phased deployment, but do not split related operations merely to enforce a blanket separation between data definition and data manipulation. diff --git a/.agents/skills/laravel-best-practices/rules/queue-jobs.md b/.agents/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 0000000..011828a --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,117 @@ +# Queue and Job Best Practices + +## Keep Reservation Time Longer Than Execution Time + +For queue drivers that use Laravel's `retry_after` setting, configure it to exceed the longest worker or job timeout by a safety margin. When a reservation expires, another worker can reserve the same job while the first process is still running. Keep the worker's `--timeout` several seconds shorter than `retry_after`. + +```php +// Job +public $timeout = 120; + +// config/queue.php for the connection +'retry_after' => 150, +``` + +Amazon Simple Queue Service uses its visibility timeout instead of Laravel's `retry_after`; configure that timeout at the queue level. Because workers can also stop after side effects but before acknowledging a job, make important jobs idempotent even with correct timeout settings. + +## Back Off Transient Failures + +Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures. + +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 4; + + public $backoff = [1, 5, 10]; +} +``` + +Rate-limiting and exception-throttling middleware can release jobs back to the queue. Released attempts may still count toward the maximum attempt limit, so configure `$tries` or `retryUntil()` to allow the intended retry window. + +## Use Unique Jobs for Dispatch Deduplication + +Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock and is not a substitute for idempotent processing or a database constraint. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public $uniqueFor = 3600; + + public function uniqueId(): string + { + return (string) $this->order->id; + } +} +``` + +All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches. + +Use `ShouldBeUniqueUntilProcessing` only when the lock should be released immediately before processing begins, allowing another instance to be dispatched while the first is running: + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // ... +} +``` + +## Handle Terminal Failure When Needed + +Implement `failed()` when the application must update state, alert an operator, or record domain-specific context after all attempts are exhausted. Logging every failure in each job may duplicate the queue system's failure reporting. + +Laravel invokes `failed()` on a new job instance, so mutations made to the job during `handle()` are not available there. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + + Log::error('Podcast processing failed', [ + 'podcast_id' => $this->podcast->id, + 'exception' => $exception, + ]); +} +``` + +## Rate Limit External Calls + +Use queue middleware such as `RateLimited` when jobs share a third-party API quota. Define the named limiter and choose release delays and attempt limits together. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Jobs for Group Coordination + +Use `Bus::batch()` to monitor a group of jobs and run callbacks when the batch completes or encounters failures. A batch is not a database transaction: completed jobs are not rolled back when another job fails. By default, one failed job cancels the batch; call `allowFailures()` only when partial failure is acceptable. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) + ->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) + ->catch(fn (Batch $batch, Throwable $exception) => Log::error('Import batch failed', [ + 'exception' => $exception, + ])) + ->dispatch(); +``` + +## Configure Time-Based Retry Limits Deliberately + +Use `retryUntil()` as the time-based alternative to a maximum attempt count. Laravel may attempt the job any number of times until this deadline, subject to other failure conditions such as maximum exceptions. The method takes precedence over attempt-based limits, so setting `$tries = 0` is not required. + +```php +public function retryUntil(): DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use Horizon for Redis Queue Operations + +Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers. diff --git a/.agents/skills/laravel-best-practices/rules/routing.md b/.agents/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 0000000..1d19ef2 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,106 @@ +# Routing and Controller Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint. + +Instead of manual lookup: + +```php +public function show(int $id): View +{ + $post = Post::findOrFail($id); + + return view('posts.show', ['post' => $post]); +} +``` + +Use route model binding: + +```php +public function show(Post $post): View +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Scope Nested Bindings + +Use scoped bindings when a nested resource must belong to its parent. This constrains model resolution; it does not replace authorization. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // The resolved post belongs to the resolved user. +})->scopeBindings(); +``` + +## Use Resource Routes for Resourceful Actions + +Use `Route::resource()` or `Route::apiResource()` when the endpoint follows Laravel's resource-controller actions. Define explicit routes when the behavior does not fit that vocabulary. + +```php +Route::resource('posts', PostController::class); + +// Alternatively, for an API-only resource: +Route::apiResource('posts', ApiPostController::class); +``` + +`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not itself add an `/api` prefix; that prefix comes from the application's API route configuration. + +## Organize Controllers Around Resources + +As a general default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This keeps routes predictable and prevents controllers from accumulating unrelated behavior. + +When a controller needs a custom action such as `publish`, `approve`, or `archive`, first consider whether that behavior represents a separate resource. A focused resource controller gives the behavior its own authorization, validation, and middleware boundary. + +Custom action on the primary controller: + +```php +Route::post('/podcasts/{podcast}/publish', [PodcastController::class, 'publish']); +``` + +The published podcast modeled as a resource: + +```php +Route::post('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'store']) + ->name('published-podcasts.store'); + +Route::delete('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'destroy']) + ->name('published-podcasts.destroy'); +``` + +```php +class PublishedPodcastController extends Controller +{ + public function store(Podcast $podcast): RedirectResponse + { + $podcast->publish(); + + return back(); + } + + public function destroy(Podcast $podcast): RedirectResponse + { + $podcast->unpublish(); + + return back(); + } +} +``` + +Treat a custom verb as a design signal, not proof that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions. + +## Keep Controllers Focused on HTTP Concerns + +Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit. + +```php +public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request can perform validation and authorization before the controller runs. Do not repeat its rules in the controller. Keep simple, endpoint-specific validation inline when extraction would not improve reuse or clarity; see the validation rules for detailed guidance. diff --git a/.agents/skills/laravel-best-practices/rules/scheduling.md b/.agents/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 0000000..4c25134 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,61 @@ +# Task Scheduling Best Practices + +## Prevent Unwanted Overlap + +Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. This is appropriate for variable-duration tasks that are not safe to run concurrently. + +```php +Schedule::command('reports:generate') + ->everyFifteenMinutes() + ->withoutOverlapping(30); +``` + +The optional value is the lock expiration time in minutes, not the task timeout. Choose it carefully: the default is 24 hours, stale locks can be cleared with `php artisan schedule:clear-cache`, and an expiration that is too short can permit overlap while the first task still runs. The task itself should still tolerate retries and partial execution where practical. + +## Run a Task on One Server + +Use `onOneServer()` when only one scheduler node should run an eligible task. Scheduler nodes must use the same default cache store, and that store must support atomic locks. Supported stores include `database`, `memcached`, `dynamodb`, and `redis`. + +```php +Schedule::command('billing:charge')->daily()->onOneServer(); +``` + +Name scheduled closures before applying `onOneServer()`, especially when scheduling the same closure with different parameters, so each task has a distinct lock identity. + +## Run Eligible Commands in the Background + +Tasks due at the same time run sequentially by default. Use `runInBackground()` when an independent, long-running scheduled command should not delay later tasks. + +```php +Schedule::command('analytics:process')->hourly()->runInBackground(); +``` + +Laravel restricts `runInBackground()` to tasks scheduled with `command()` and `exec()`; it is not available for scheduled closures. Ensure background processes have appropriate logging and failure monitoring. + +## Restrict Tasks by Environment + +Use `environments()` when a task should run only in named application environments. Treat this as an operational safeguard, not an authorization control. + +```php +Schedule::command('billing:charge') + ->monthly() + ->environments(['production']); +``` + +## Group Shared Configuration + +Use schedule groups when several tasks genuinely share frequency or constraints. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` + +## Bound Work Inside the Task + +The scheduler does not provide a `takeUntilTimeout()` event method or terminate arbitrary tasks at a deadline. Bound work in the command or job itself by processing finite chunks, checking a deadline, or dispatching queue jobs with suitable timeouts. Use operating-system or process controls when hard termination is required. diff --git a/.agents/skills/laravel-best-practices/rules/security.md b/.agents/skills/laravel-best-practices/rules/security.md new file mode 100644 index 0000000..57b57f0 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,156 @@ +# Security Best Practices + +## Control Mass Assignment + +Define `$fillable` when a model is populated from request-derived arrays, or deliberately guard attributes by another consistent model convention. Laravel models guard all attributes by default; `$guarded = []` opts out of that protection. + +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Do not pass untrusted request data to a model with `$guarded = []`. Mass-assignment protection controls which attributes `create()`, `fill()`, and `update()` may set; it does not validate values or authorize the operation. + +## Authorize Protected Actions + +Use policies, gates, or form request authorization for actions that depend on the current user's permissions. Authentication alone does not establish permission, and validation is not authorization. + +```php +public function update(UpdatePostRequest $request, Post $post): RedirectResponse +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +Authorization may instead live in the form request: + +```php +public function authorize(): bool +{ + return $this->user()?->can('update', $this->route('post')) ?? false; +} +``` + +Public actions intentionally available to everyone do not need a redundant authorization check. + +## Bind Query Parameters + +Use Eloquent, the query builder, or explicit bindings instead of interpolating untrusted values into Structured Query Language (SQL). Bindings protect values, not identifiers such as column names or sort directions; map user-selected identifiers to an allow-list. + +Incorrect: + +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: + +```php +User::where('name', $request->name)->get(); +User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get(); +``` + +## Escape Output in Its Context + +Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content that has been sanitized for the exact HTML context in which it is rendered. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets. + +Incorrect for untrusted content: + +```blade +{!! $user->bio !!} +``` + +Correct: + +```blade +{{ $user->bio }} +``` + +## Apply Cross-Site Request Forgery Protection + +Include `@csrf` in state-changing Blade forms handled by Laravel's `web` middleware. Routes intentionally excluded from cross-site request forgery (CSRF) verification, such as validated third-party webhooks, need their own authenticity check. + +```blade +
+ @csrf + +
+``` + +Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. Confirm equivalent configuration when using another HTTP client. Do not disable CSRF protection merely to fix a token mismatch. + +## Rate Limit Sensitive Endpoints + +Apply suitable rate limits to login attempts, password recovery, verification messages, and expensive or abuse-prone application programming interface (API) routes. Choose the limiter key deliberately; an Internet Protocol (IP) address alone can unfairly group users behind a shared network, while an account identifier alone can enable targeted denial of service. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by(Str::transliterate( + Str::lower($request->string('email')).'|'.$request->ip() + )); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +Rate limiting reduces abuse; it does not replace authentication, authorization, or upstream denial-of-service protection. + +## Validate and Store Uploads Safely + +Validate expected content type, dimensions where relevant, and size. Laravel's `mimes` rule reads the file contents and guesses a Multipurpose Internet Mail Extensions (MIME) type corresponding to the listed extensions; it does not validate the user-assigned filename extension. The `extensions` rule checks that extension and should not be used by itself. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Use Laravel's storage methods to generate a filename, and store untrusted files outside a publicly executable location. Public files can require additional controls, such as image re-encoding, content-disposition headers, and explicit blocking of active formats. + +```php +$path = $request->file('avatar')->store('avatars'); +``` + +## Keep Secrets Out of Application Code + +Do not commit populated environment files or hard-code credentials. Read environment variables in configuration files, then use `config()` in application code so configuration caching works correctly. See the configuration rules for encrypted environment files and external secret stores. + +## Audit Dependencies + +Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly. + +```bash +composer audit +``` + +## Encrypt Sensitive Attributes When Appropriate + +Use an `encrypted` cast for sensitive values that must be recoverable, and use `$hidden` to omit them from array and JavaScript Object Notation (JSON) serialization. Hidden attributes remain accessible in PHP, and encryption does not replace access control. Encrypted values cannot be meaningfully queried and should use a `TEXT` or larger column because ciphertext length is variable. + +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/style.md b/.agents/skills/laravel-best-practices/rules/style.md new file mode 100644 index 0000000..3f3f14f --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,110 @@ +# Convention and Style Best Practices + +## Follow Project Naming Conventions + +Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile. + +| Element | Convention | Example | +| --- | --- | --- | +| Controller | Singular resource name | `ArticleController` | +| Model | Singular StudlyCase | `User` | +| Table | Plural snake_case | `article_comments` | +| Pivot table | Singular model names in alphabetical order, in snake_case | `article_user` | +| Column | snake_case | `meta_title` | +| Conventional foreign key | Singular model name plus `_id`, in snake_case | `article_id` | +| Resource URI | Plural resource | `articles/1` | +| Route name | Dotted segments; snake_case within a segment when needed | `users.show_active` | +| Method | camelCase | `getAll` | +| Variable | camelCase | `$articlesWithAuthor` | +| Collection | Descriptive and plural | `$activeUsers` | +| Object | Descriptive and singular | `$activeUser` | +| View | kebab-case | `show-filtered.blade.php` | +| Configuration file | snake_case | `google_calendar.php` | +| Enumeration | Singular StudlyCase | `UserType` | + +## Prefer Clear, Idiomatic Syntax + +Use Laravel helpers and query methods when they communicate intent more directly. Do not shorten code when the result is ambiguous or loses useful type information. + +| More verbose | Idiomatic alternative | +| --- | --- | +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()?->name` | `->value('name')` when only that value is needed | + +Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation. + +## Use Utilities When They Clarify Intent + +Laravel's `Str`, `Arr`, `Number`, and `Uri` utilities provide expressive operations and framework-consistent behavior. Prefer them when they are clearer or safer than an equivalent PHP operation, not as an unconditional replacement for every built-in function. + +```php +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename(User::class); +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Use `Arr` for dot notation and common transformations: + +```php +$name = Arr::get($array, 'user.name', 'default'); +$public = Arr::only($attributes, ['name', 'email']); +``` + +Use `Number` for localized display formatting rather than values that will be stored or calculated: + +```php +Number::format(1000000); +Number::currency(1500, 'USD'); +Number::fileSize(1024 * 1024); +``` + +Use `Uri` when constructing or transforming a uniform resource identifier (URI) benefits from a structured API: + +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Check the documentation for the Laravel version supported by the project before using newer utility classes or methods. + +## Keep Presentation Code Maintainable + +Prefer the project's asset pipeline, components, and existing conventions for substantial JavaScript and Cascading Style Sheets (CSS). Small page-specific scripts or styles can be reasonable in Blade layouts or stacks; avoid mixing large behavior and style blocks into templates. + +Pass server data with an encoding mechanism appropriate to its context. For example, Blade's `Js::from()` safely formats data for JavaScript: + +```blade + +``` + +Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping. + +## Write Comments That Explain Why + +Prefer clear names and small units of code over comments that merely restate an operation. Add concise comments for non-obvious constraints, tradeoffs, workarounds, regular expressions, or external behavior that the code cannot express by itself. Keep comments accurate when behavior changes. + +Unhelpful: + +```php +// Check whether the query has joins. +if (count((array) $builder->getQuery()->joins) > 0) { + // ... +} +``` + +Clearer: + +```php +if ($this->hasJoins()) { + // ... +} +``` diff --git a/.agents/skills/laravel-best-practices/rules/validation.md b/.agents/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 0000000..37ce6c5 --- /dev/null +++ b/.agents/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,89 @@ +# Validation and Forms Best Practices + +## Extract Validation When It Improves the Boundary + +Use a form request when validation or authorization is substantial, reused, or clearer outside the controller. Inline `$request->validate()` remains appropriate for a small, endpoint-specific rule set. + +```php +public function store(StorePostRequest $request): RedirectResponse +{ + $post = Post::create($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request's `authorize()` method can enforce access to the operation. Validation establishes the shape and values of input; it does not itself authorize the user. + +## Prefer Readable Rule Syntax + +Array syntax composes cleanly with rule objects and avoids delimiter issues. Prefer it in new code when it improves readability, while following a consistent local style. + +```php +'email' => ['required', 'email', Rule::unique('users')], +``` + +String syntax remains valid for simple rules: + +```php +'email' => 'required|email|unique:users', +``` + +## Use Only Intended Validated Data + +Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. Then select the fields intended for the operation when the validation rules also cover control fields or nested data. + +Unsafe: + +```php +Post::create($request->all()); +``` + +Preferred: + +```php +$post = Post::create($request->safe()->only(['title', 'body'])); +``` + +Validated data is not automatically safe for mass assignment. Keep model `$fillable` or `$guarded` rules aligned with the operation, and never add a sensitive attribute to validation merely to make mass assignment convenient. + +## Express Conditional Rules Clearly + +Use conditional rules such as `Rule::when()`, `required_if`, or `exclude_unless` when they make the condition explicit. Choose the simplest form that remains easy to test. + +```php +'company_name' => [ + 'string', + 'max:255', + Rule::when( + $this->input('account_type') === 'business', + ['required'], + ['nullable'], + ), +], +``` + +## Add Cross-Field Validation After Base Rules + +Use a form request's `after()` method for validation that depends on multiple fields or application state. Avoid expensive queries when prerequisite fields have already failed validation. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($validator->errors()->hasAny(['product_id', 'quantity'])) { + return; + } + + $stock = Product::find($this->integer('product_id'))?->stock; + + if ($stock !== null && $this->integer('quantity') > $stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` + +Validation against mutable state does not prevent a race between validation and persistence. Enforce inventory, uniqueness, and similar invariants with database constraints, atomic updates, or a database transaction as appropriate. diff --git a/.agents/skills/tailwindcss-development/SKILL.md b/.agents/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..7e3cd2a --- /dev/null +++ b/.agents/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,96 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.agents/skills/testing-best-practices/SKILL.md b/.agents/skills/testing-best-practices/SKILL.md new file mode 100644 index 0000000..17a781b --- /dev/null +++ b/.agents/skills/testing-best-practices/SKILL.md @@ -0,0 +1,57 @@ +--- +name: testing-best-practices +description: "Laravel test design and review. Use when selecting coverage, naming or structuring tests, choosing assertions or test data, isolating dependencies, testing HTTP or security boundaries, improving suite performance, or reviewing test value. Use framework guidance or search-docs for Pest and PHPUnit syntax." +license: MIT +metadata: + author: laravel +--- + +# Testing Best Practices + +This skill provides rules for designing Laravel tests. Each rule file explains what to do and why. Use `search-docs` for Laravel API syntax. Fetch `https://docs.phpunit.de/en/13.3/` for PHPUnit API syntax. +This project uses PHPUnit. Follow the corresponding guidance in each rule. + +## Consistency First + +Read nearby tests before you choose syntax and organization. + +A pattern repeated throughout the project is a convention, and project conventions take precedence over this skill. Follow them and give new tests the same structure. + +These rules govern the tests you write now. An existing test that follows a project convention is not defective merely because it conflicts with this skill. Do not delete or rewrite it. If the convention has drawbacks, explain them and let the user decide. + +## What to Test + +Read this section before you write a test. + +- Test observable behavior and application contracts. A test must pass after an implementation change if the behavior stays the same. +- Cover every changed decision and each applicable high-value failure mode. A decision is a branch, a validation, a calculation, or an authorization. +- Exercise declarations through behavior instead of repeating their text. +- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project. +- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test. +- Write a feature test first. Write a unit test only for logic that does not use the framework. +- Write a feature test for every behavior reachable through a request. Real-browser tests require `laravel/dusk` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test. +- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it. + +## How to Apply + +1. Read the code under test. Read the tests in the same directory. Identify every decision in the code. +2. Select every applicable branch in the rule index. Read every selected rule file. +3. Report each defect in the code before you write a test. Examples are a method with no body, a policy that no action calls, and a write action with no validation. Test the actual behavior. Report the defect to the user. +4. Write the tests. Run the smallest set of tests that covers the change. The tests must pass. +5. Check every applicable item in `rules/review.md` and every selected rule file. Resolve every mismatch before completion. + +## Rule Index + +Most changes need more than one rule file. + +| Subject | Rule File | +| --- | --- | +| Test framework features that may already do the work | [`rules/finding-features.md`](rules/finding-features.md) | +| File layout, test names, and groups | [`rules/naming.md`](rules/naming.md) | +| Arrange-act-assert and choosing the correct assertion | [`rules/assertions.md`](rules/assertions.md) | +| Endpoint coverage, authentication, authorization, tenant isolation, validation, and browser tests | [`rules/endpoint-tests.md`](rules/endpoint-tests.md) | +| Factories, test data ownership, and repeated input values | [`rules/test-data.md`](rules/test-data.md) | +| Fakes, mocks, outbound HTTP, time, randomness, and databases | [`rules/isolation.md`](rules/isolation.md) | +| Escaping, injection, cross-tenant access, and privilege checks | [`rules/security.md`](rules/security.md) | +| Environment and CI settings for a slow suite | [`rules/performance.md`](rules/performance.md) | +| Reviewing a test or suite | [`rules/review.md`](rules/review.md) | diff --git a/.agents/skills/testing-best-practices/rules/assertions.md b/.agents/skills/testing-best-practices/rules/assertions.md new file mode 100644 index 0000000..622f191 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/assertions.md @@ -0,0 +1,60 @@ +# Assertions + +## Arrange, Act, Assert + +Write each test in three parts: setup, one action, and assertions. Put one blank line between them so readers can identify each part without comments. + +Keep each test self-contained. Do not use values created by another test. + +## How to Find the Correct Assertion + +First identify the subject of the check, then find an assertion designed for it. A subject-specific assertion identifies the incorrect value when the test fails. + +1. Search Laravel's assertions for framework subjects such as responses, the database, sessions, models, queues, events, mail, and notifications. +2. Fetch `https://docs.phpunit.de/en/13.3/assertions.html` for the assertions of PHPUnit for a plain value, a type, a format, or a shape. +3. Build the check by hand only if no assertion exists for the subject. +4. Confirm the name in the documentation before you use it. Do not write an assertion that you did not confirm. + +Use the assertion in this table for each subject. + +| Subject | Assertion to use | +| --- | --- | +| A return value, the state of an object, or a transformation of a value | `assertSame()`, or the assertion for the type | +| An HTTP status, JSON, a session, or Inertia | a Laravel response assertion | +| The state in the database | a Laravel database assertion | +| The existence of a model | `assertModelExists($model)` rather than `assertDatabaseHas('users', ['id' => $user->id])` | + +Use `assertSame()` and not `assertEquals()`, because `assertSame()` also compares the type. + +Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered. + +## Named Response Assertions + +Use a named response assertion, such as `assertNotFound()`, rather than `assertStatus(404)`. A failure then identifies the broken contract. Laravel provides named assertions for commonly tested status codes. + +Group the assertions for one subject together. Start a new group when the subject changes. + +## Assert a Known Value + +Write the expected value in the test, or calculate the expected value by a different method. Do not calculate the expected value with the logic of the implementation, because the test then passes when that logic is wrong. + +```php +// The test calculates the value with the logic of the implementation... +$expected = now()->subHours(24)->floorSeconds(30)->toJson(); +$this->assertSame($expected, $from); + +// The test sets a fixed input and asserts a known value... +$this->travelTo('2025-01-01 00:00:00'); +$this->assertSame('2024-12-31T00:00:00.000000Z', $from); +``` + +## Assert the Complete Result + +A status code is not the complete result of a write operation. Assert each of the following if the operation changes it: + +- The response or the return value. +- The state in the database. +- The jobs and the events that the operation dispatches. +- The notifications and the mail that the operation sends. + +On the failure path, assert that the operation makes none of these changes. A test that asserts only `assertOk()` passes even when the application saves no record. diff --git a/.agents/skills/testing-best-practices/rules/endpoint-tests.md b/.agents/skills/testing-best-practices/rules/endpoint-tests.md new file mode 100644 index 0000000..16add3e --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/endpoint-tests.md @@ -0,0 +1,47 @@ +# Endpoint Tests + +## How to Write the Test + +Fetch `https://laravel.com/framework/docs/http-tests` for the request helpers, the authentication helpers, and the response assertions. Confirm the name before you use it, and do not guess an assertion. + +Choose an assertion based on the subject of the check: the status, a header, a redirect, the JSON body, the session, a validation error, or the view. Laravel provides a named assertion for each subject that identifies the incorrect value. + +## Endpoint Coverage + +Write a test for each applicable case: + +- The request has missing or invalid authentication. +- The request comes from a different tenant, team, or organization. +- The user has an insufficient role or permission. +- The request does not satisfy a route or scope constraint. +- The request fails the validation. +- The request is valid. Assert both the response and the persisted state. + +Assert the application's actual behavior rather than a generic status code. An API returns `401` for a missing or invalid token, while a browser endpoint redirects to the sign-in route. + +## Tenant Isolation + +Assert the status code returned for a cross-tenant request. Use `404` rather than `403` when one tenant must not learn that another tenant's record exists, because `403` confirms its existence. + +## Test Authorization at the Policy Level + +An HTTP test shows that the endpoint performs authorization. It cannot identify which mechanism refused the request because middleware, a policy, and a call to `abort()` can all return `403`. + +- Assert the complete matrix of the permissions against the policy or the gate. A failure then names the rule that is not correct. +- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization. +- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists. + +## Testing Validation + +- Write one test for each validation rule when each failure represents a separate contract. +- Write one test with an empty payload to assert several required fields together. +- Assert the text of the message that the user gets. A message that is present but wrong is a defect. +- Use a data provider with the `#[DataProvider]` attribute for input values that need the same setup and the same assertions. Use the `#[TestWith]` attribute for a small set of values. + +Send an input value that is not valid through the application, and assert the error. Do not assert that an array of rules contains a string, because that assertion tests the declaration and not the behavior. Use such an assertion only for a rule that no request can reach, and write the reason in the test. + +### Which Layer Owns Which Case + +The rule-class test owns the matrix of values that pass and fail. The endpoint test proves that the endpoint applies the rule and that the user receives the message. + +When both tests contain the matrix, move it to the rule-class test and retain one case in the endpoint test. Never remove the last case, because the rule-class test still passes if the request omits the rule. The same division applies to policies, scopes, and other classes called by a request. diff --git a/.agents/skills/testing-best-practices/rules/finding-features.md b/.agents/skills/testing-best-practices/rules/finding-features.md new file mode 100644 index 0000000..a523282 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/finding-features.md @@ -0,0 +1,33 @@ +# How to Find Test Framework Features + +PHPUnit and Laravel provide features for most testing needs. Find an existing feature before implementing the behavior by hand. + +- Give `search-docs` the capability you need rather than the name of a method you remember. It returns Laravel testing documentation for the installed version. +- Fetch `https://phpunit.de/documentation.html` for version-specific PHPUnit attributes, assertions, and command-line options. +- If a search returns no results, tell the user that the installed version does not provide the feature. Do not write an API that you have not confirmed. + +Search for a feature in this table before you write the code by hand. + +| Work that you need | Term to search for | +| --- | --- | +| Run one test method with many input values | data provider, `#[DataProvider]`, `#[TestWith]` | +| Run one test only after another test passes | `#[Depends]` | +| Select or skip a set of tests in one run | `#[Group]`, `--group`, `--exclude-group` | +| Skip a test on a version or on a missing extension | `#[RequiresPhp]`, `#[RequiresPhpExtension]` | +| Find a test that depends on the order of the run | `--order-by=random` | +| Reduce the time of a slow suite | ParaTest, `--cache-result` | +| Stop the run at the first failure while you debug | `--stop-on-failure`, `--filter` | + +## Built-in Laravel Assertion Methods + +Laravel provides assertions for each part of the framework. Fetch `https://laravel.com/framework/docs/testing` for the complete list, and search for an assertion before building a check by hand. Examples include `assertDatabaseHas()`, `assertModelExists()`, `assertSoftDeleted()`, response assertions such as `assertRedirectToRoute()` and `assertJsonPath()`, and fake assertions such as `Queue::assertPushed()` and `Notification::assertSentTo()`. + +A hand-built check fails with `false is not true`, which identifies nothing. A framework assertion names the incorrect table, value, or response, so the failure indicates what to fix. + +```php +// The failure says that false is not true. Instead of this... +$this->assertTrue(User::where('email', 'taylor@laravel.com')->exists()); + +// Use this... the failure names the table and the attributes that it did not find... +$this->assertDatabaseHas('users', ['email' => 'taylor@laravel.com']); +``` diff --git a/.agents/skills/testing-best-practices/rules/isolation.md b/.agents/skills/testing-best-practices/rules/isolation.md new file mode 100644 index 0000000..cbfd714 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/isolation.md @@ -0,0 +1,52 @@ +# Fakes, Mocks, and Determinism + +Tests that depend on actual time, randomness, sleeping, or network calls can fail for reasons unrelated to the code under test. Control all four. + +## How to Isolate a Dependency + +Fetch `https://laravel.com/framework/docs/mocking` for Laravel's fakes, facade doubles, and fake assertions. Confirm each name before using it. + +Identify the dependency, then choose the first applicable option. A framework fake preserves the real code path, while a mock replaces the dependency. + +1. Always use framework fakes for facades such as events, queues, mail, notifications, storage, the HTTP client, time, and sleep. +2. Use a developer-defined fake implementation of a service if the application provides one. +3. Use a mock for a container-resolved contract only when the real implementation leaves the process or is nondeterministic. +4. Use the real implementation for everything else, including the database. + +## Framework Fakes + +- Create each fake inside the test method that needs it. Do not create fakes in `setUp()`. +- Pass class names to `Event::fake()` and `Queue::fake()` when you know which classes the code dispatches. A fake without class names can hide an unexpected dispatch. +- Use a fake without class names only when the test asserts the complete result, including a call to `assertNothingPushed()`. +- Write one assertion for each fake. The assertion states that the code dispatches the item, or that the code does not dispatch the item. +- Assert the data of a job or of an event if that data is part of the behavior. +- Use `Exceptions::fake()` to assert that the application reports the correct exception. Do not use `withoutExceptionHandling()`, because it changes the response under test. + +Create prerequisite factory records before calling `Event::fake()`. Factories use model events, such as a `creating` hook that generates a UUID, and a fake without class names suppresses those events and can produce an invalid model. Call the fake first only when a factory event is under test, and pass that event's class name. + +## Mocking + +Use `shouldReceive()` before the action to declare an expectation. Use `shouldHaveReceived()` after the action for a spy. Use `Mockery::on()` or `withArgs()` if an equality check cannot state the expected argument, such as a check of one field of a value object. + +Use `$this->mock(Contract::class)` to put a mock in the container. Do not build a PHPUnit mock for a class that Mockery can double, because the project uses Mockery. + +## Outbound HTTP Testing + +Call `Http::preventStrayRequests()`. Any request without a matching fake then fails without reaching the network. + +Fake the exact endpoint used by each test. Do not call `Http::fake()` without an endpoint because it accepts unexpected requests and can hide defects. + +## Time and Randomness + +- Freeze the time or move the time in each test that depends on a date, a period, or a timestamp. +- Use the framework helpers `$this->freezeTime()`, `$this->travelTo()`, `$this->travel()`, and `$this->travelBack()`. Do not call `Carbon::setTestNow()`. +- Use `Str::createRandomStringsUsing()` to fix a generated string, if the test asserts an identifier or a slug. +- Use `Sleep::fake()` instead of a real sleep, and assert the sleeps that the code requests. +- Restore the time and the randomness after each test, if the suite does not restore them for every test. + +## Database + +- Run real queries against the real records in the test database. Do not mock the query builder, because the test then asserts the mock. +- Assert the exact keys of `toArray()` if the shape of the serialized model is a contract. The test then fails when the model exposes a new attribute. +- Test application behavior caused by the schema, such as deleting dependent records through a cascade. Do not test the database engine's cascade implementation. +- Use `LazilyRefreshDatabase` instead of `RefreshDatabase`. A test that does not use the database then does not run the migrations. diff --git a/.agents/skills/testing-best-practices/rules/naming.md b/.agents/skills/testing-best-practices/rules/naming.md new file mode 100644 index 0000000..104881d --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/naming.md @@ -0,0 +1,37 @@ +# Naming and Structure + +## File Layout + +- Name each test file `{ClassName}Test.php`. +- Place each test file at the same relative path as the class under test. The class `app/Actions/DeleteTeam.php` gets the test `tests/Unit/Actions/DeleteTeamTest.php`. +- Follow the project's convention for fixture files. If none exists, put fixtures in `tests/Fixtures/` and load them by path. +- Move large literal values out of the test body and into fixture files. + +## Test Class and Methods + +- Extend the base `TestCase` of the project in each test class. +- Give each test method the prefix `test_`, or add the `#[Test]` attribute to the method. Use the convention of the other files in the same directory. + +## Naming Tests + +The name of a test method is a specification. Separate the words with underscores. State the user-visible result and the condition that causes it. + +- Name the behavior, and not the method under test. The file name already gives the class. +- Give the exact status code in the name of a test for an API error. +- Do not write `given`, `when`, or `then` in the name. + +```php +public function test_unauthenticated_request_redirects_to_login(): void { ... } +public function test_returns_401_when_no_token_is_provided(): void { ... } +public function test_valid_payload_creates_record_and_returns_201(): void { ... } +``` + +Use a verb that describes a result, such as `returns`, `renders`, `creates`, `dispatches`, `rejects`, `forbids`, `falls back`, or `does not`. + +Do not write `test_store()`, `test_it_works()`, or `test_validation()`, because none of them gives a result. + +## Grouping + +Write one test class for each class under test. Write a separate test class if one file covers separate actions in a lifecycle, such as `StoreOrderControllerTest` and `UpdateOrderControllerTest`. + +Use the `#[Group]` attribute to mark the tests that a run must select or must skip. Do not use a group to give structure to a file, because a class gives the structure. diff --git a/.agents/skills/testing-best-practices/rules/performance.md b/.agents/skills/testing-best-practices/rules/performance.md new file mode 100644 index 0000000..61e0d72 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/performance.md @@ -0,0 +1,46 @@ +# Test Suite Performance + +These settings apply to the project and CI, not to individual tests. Read `rules/isolation.md` for choices within a test. + +Fetch `https://docs.phpunit.de/en/13.3/` for PHPUnit options that make test runs faster. +Verify each flag in the documentation before adding it to CI. + +Measure before changing a setting. Find the slow test first, and apply a project-wide setting only after identifying the costly work. + +## Test Environment + +- Set `BCRYPT_ROUNDS=4` in `.env.testing` or in `phpunit.xml`. The default value is 12, and the hash then takes most of the time of each test that signs a user in. +- Disable XDebug. Disable pcov also, unless the run needs the coverage. +- Disable packages that perform work on every request in the test environment. Examples are Pulse, Telescope, and Nightwatch. +- Use the `WithCachedConfig` and `WithCachedRoutes` traits, so the run does not parse the configuration and the routes for every test. +- Call `withoutVite()`, or `withoutMix()`, so the framework does not resolve a built asset. + +## Global Fakes + +Put these three calls in the `setUp()` of the base `TestCase` of the project: + +- `Http::preventStrayRequests()`, because one request that reaches the network can slow the suite. This catches requests made through Laravel's HTTP client. Check direct Guzzle and cURL usage separately. +- `Sleep::fake(syncWithCarbon: true)`, so a retry and a backoff do not sleep. +- `Exceptions::fake()`, so the suite does not report an exception to an external service. + +## How to Run the Suite in Parallel + +Run `php artisan test --parallel`, which uses ParaTest, to spread tests across the machine's CPU cores. Add `--processes=N` if the default count is unsuitable for the machine or CI. + +A parallel run gives each process a separate database. Tests must meet these conditions; a test that fails only in parallel breaks one of them: + +- The test creates each record that it reads. It does not read a record that another test creates. +- The test does not depend on the order of the run. +- The test does not share a file, a cache key, or a queue with another test. Give each process a separate name for such a resource. + +## How to Find a Slow Test + +Run `php artisan test --profile` to list the slowest tests. Start with the ten slowest tests, because the same cause often applies to the complete suite. + +If the cause of a slow test is unclear, add an event listener or temporary log entry to identify its work. + +## Common Errors + +- The run loads XDebug for a test that does not need it. +- `BCRYPT_ROUNDS` keeps the default value, because the project has no `.env.testing`. +- The code under test calls the real `sleep()`, and `Sleep::fake()` then does not help. diff --git a/.agents/skills/testing-best-practices/rules/review.md b/.agents/skills/testing-best-practices/rules/review.md new file mode 100644 index 0000000..92595f5 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/review.md @@ -0,0 +1,42 @@ +# Reviewing Tests + +Check every item in this file. A passing test may still provide no value. For each test, identify the defect it would catch. + +Report each finding. Do not delete or rewrite a test without the user's approval. When an issue appears throughout the suite as a convention, report the pattern once rather than every affected file. + +## Test Value + +- [ ] Each test covers observable behavior or an application contract, and passes after a change to the implementation that keeps the behavior. +- [ ] Each tested declaration is exercised through behavior, and no test asserts the behavior of the framework. A test of what this project configures, such as a relation with a constraint, a cast, or a scope, belongs to this project. +- [ ] Each test detects a distinct defect that no other test covers. A duplicate shrinks at the higher layer to the one case that proves the wiring. +- [ ] Every changed decision and each applicable high-value failure mode has coverage. + +## Names and Structure + +- [ ] Each file has the name `{ClassName}Test.php` and the relative path of the class under test. +- [ ] Each name states a result, the condition that causes it, and the status code for an API error. +- [ ] Each test class extends the base `TestCase` of the project, and each file uses either the prefix `test_` or the `#[Test]` attribute consistently. + +## Coverage + +- [ ] HTTP tests cover authentication, authorization, role, scope, and validation when applicable. +- [ ] A request for a record of a different tenant gets a status code that does not confirm that the record exists. +- [ ] The complete permission matrix belongs in policy tests, not controller tests. +- [ ] Each validation rule has one test that asserts the user-visible message. When a unit test owns a matrix, reduce duplicate higher-level coverage to one case rather than deleting it. +- [ ] Rendered user input and each dynamic part of a query have a security test. + +## Data and Determinism + +- [ ] Each test creates its mutable records directly or through a helper that it calls, and every created record arranges the behavior or supports an assertion. +- [ ] `setUp()` holds configuration only. +- [ ] Each factory state and each relationship gives the meaning of the data. +- [ ] Each call to `make()` is in a test that does not need the database. +- [ ] Time, randomness, sleep, and outbound HTTP are controlled. +- [ ] Each test passes alone, and passes in the complete suite in any order. + +## Assertions + +- [ ] Each expected value is a known value, and the test does not calculate the value with the logic of the implementation. +- [ ] Each test of a write operation asserts the response, the state in the database, and the side effects. +- [ ] Each fake has one assertion, and gives the class names unless the test asserts the complete result. +- [ ] Each group of assertions stays on one subject, and each comparison uses `assertSame()`. diff --git a/.agents/skills/testing-best-practices/rules/security.md b/.agents/skills/testing-best-practices/rules/security.md new file mode 100644 index 0000000..e9097d9 --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/security.md @@ -0,0 +1,27 @@ +# Security Tests + +Test each security boundary where user input affects authorization, rendered output, or query construction. A defect at such a boundary can be difficult to detect because the feature may continue to work. + +Write a test for each of these cases: + +- **Cross-tenant access.** Request a record of a different tenant, team, or organization. Read `rules/endpoint-tests.md` for why the response should possibly be `404` rather than `403`. +- **Each unprivileged role.** Use a data provider over the roles that the endpoint must refuse. +- **Escaping user-provided content.** Test escaping in HTML and mail. Include names and every free-text field a template renders. Assert that dangerous characters are escaped and the raw value is absent. Do not assert an exact entity for a quote, because Markdown and mail CSS inliners may decode it. +- **Injection into dynamic query components.** Examples include sort columns, filter fields, and sort directions. +- **An unexpected key** in a payload or configuration array. A merge that accepts every key can set an attribute the user must not control. + +```php +public function test_escapes_dangerous_content_in_the_notification(): void +{ + $organization = Organization::factory()->make([ + 'name' => "O'Reilly ", + ]); + + $content = (new QuotaApproaching($organization, 80))->toMail()->render(); + + $this->assertStringContainsString('", $content); +} +``` + +Laravel provides defenses against mass assignment, unauthorized access, and unescaped output. Test that the application applies the appropriate defense to each attribute, route, and template. diff --git a/.agents/skills/testing-best-practices/rules/test-data.md b/.agents/skills/testing-best-practices/rules/test-data.md new file mode 100644 index 0000000..153b45d --- /dev/null +++ b/.agents/skills/testing-best-practices/rules/test-data.md @@ -0,0 +1,68 @@ +# Factories and Test Data + +## Each Test Makes Its Own Data + +Create mutable records inside each test or through a private helper that the test calls. This keeps setup visible and lets each test select its factory state. + +Use `setUp()` only for configuration that applies to every test in the class. Do not create records in it, because its objects remain in memory until the suite ends. + +## Record Construction + +- Use `create()` if the test needs the record in the database. +- Use `make()` only if the test does not need the database. Examples include rendering a notification and testing a value object's behavior. +- Use a named factory state instead of a raw attribute. `User::factory()->unverified()->create()` gives the state meaning; `create(['email_verified_at' => null])` gives only its value. +- Use `for()` or the relationship helper of the project to declare the owner of a record. +- Use `recycle()` if several records must share one parent record. +- Use `sequence()` if several records need different attributes. + +```php +$organization = Organization::factory()->onPlan(BillingPlan::PRO)->create(); + +$environment = Environment::factory()->recycle($organization)->create(); + +$organizations = Organization::factory() + ->count(3) + ->sequence( + ['created_at' => now()->setSeconds(30)], + ['created_at' => now()->setSeconds(1)], + ) + ->create(); +``` + +Create only the records required to arrange the behavior or support an assertion. + +## Data Providers + +Use a data provider when the setup, test body, and assertions remain the same across input values. + +```php +public static function nonAdminRoles(): array +{ + return collect(Role::cases()) + ->reject(fn (Role $role): bool => $role === Role::ADMIN) + ->mapWithKeys(fn (Role $role): array => [$role->value => [$role]]) + ->all(); +} + +#[DataProvider('nonAdminRoles')] +public function test_forbids_roles_other_than_admin(Role $role): void +{ + $this->actingAs(User::factory()->hasOrganization($role)->create()) + ->post('/settings') + ->assertForbidden(); +} +``` + +Declare each data provider method as `public static`. + +Use parameterized tests for: + +- enum cases +- roles and plans +- boundary values +- input values that are invalid in the same way +- input and output value pairs + +Write separate tests if the cases need a different setup, a different behavior, or different assertions. One test function with a branch in the body is two tests in one function. + +Give each data-provider case a key that states the difference. A failure then identifies the case without requiring you to count positions. diff --git a/.claude/skills/infer-conventions/SKILL.md b/.claude/skills/infer-conventions/SKILL.md new file mode 100644 index 0000000..e7fcafc --- /dev/null +++ b/.claude/skills/infer-conventions/SKILL.md @@ -0,0 +1,104 @@ +--- +name: infer-conventions +description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand." +license: MIT +metadata: + author: laravel +--- + +# Infer Conventions + +Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it. + +## Ground Rules (read before you start) + +- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer. +- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record. +- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule. +- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering. +- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped. +- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar. +- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details. + +## Process + +Each step ends on a checkable completion criterion. Do not advance until it holds. + +Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output. + +### Step 0: Orient + +Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2. + +This app has no Livewire/Inertia/Flux packages installed. Treat the frontend group as likely API-only: confirm from `resources/views` before spending time there, and skip the Livewire/Inertia/Flux dimensions. + +Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents. + +### Step 1: Predefined sweep + +Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict: + +- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files. +- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled. +- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention. +- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most). +- Tooling-owned or Already-recorded. Skip per the ground rules. + +Done when: every applicable dimension carries exactly one of those verdicts. + +### Step 2: Open-ended pass + +First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude. + +Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal. + +Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none). + +### Step 3: Confirm + +Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style. + +Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo. + +Done when: every candidate is approved, rejected, or (conflicts) decided. + +### Step 4: Record + +Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand. + +Record this: + +> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models. + +Not this: + +> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models. + +Done when: every approved item has a successful tool response, and any failure is reported with its rule text. + +### Step 5: Summarize + +List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions. + +## Glob mapping + +Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path. + +Examples: + +- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one. +- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer. +- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses. +- Tests: `tests/**`. +- Migrations and database: `database/migrations/**`. +- Truly app-wide (rare, e.g. auth retrieval): `app/**`. + +`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there. + +## Edge cases + +- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4. +- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing. +- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything. +- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface. +- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths. diff --git a/.claude/skills/infer-conventions/references/checklist.md b/.claude/skills/infer-conventions/references/checklist.md new file mode 100644 index 0000000..80cef34 --- /dev/null +++ b/.claude/skills/infer-conventions/references/checklist.md @@ -0,0 +1,137 @@ +# Detection Checklist + +Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`). + +Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence. + +--- + +## A. Validation & HTTP input + +1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`. + - Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`. +2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal. + - Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`. +3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties. + - Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`. +4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods. + - Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`. + +## B. Controllers & routing + +5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method. + - Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes. +6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs. + - Hint: read a few controller methods; `ls app/Actions app/Services`. +7. Route handler style: closures in `routes/*.php` vs controller classes. + - Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`. +8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute. + - Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes. +9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`. + - Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`. +10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`. + - Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files. + +## C. Authorization + +11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`. + - Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`. +12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade. + - Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`. + +## D. Eloquent & models + +13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list. + - Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`. +14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain. + - Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`. +15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`. + - Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`. +16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings. + - Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models. +17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`). + - Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built. +18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes. + - Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`. +19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes. + - Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`. +20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture. + - Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`. + +## E. Architecture & organization + +21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked. + - Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find. +22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere. + - Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`. +23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location. + - Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps. +24. Decoupling: events + listeners vs direct service calls. + - Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`. +25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`). + - Hint: ratio of `config(` vs `Config::` (etc.) across `app/`. +26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules). + - Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders. +27. Enums: backed vs pure; case naming; where they live. + - Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`. + +## F. Frontend & views + +No Livewire/Inertia/Flux package is installed. This app may be API-only. Confirm from `resources/views` before sweeping, and treat the Livewire/Flux dimensions as not applicable. + +28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA. + - Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`. +29. Blade composition: class `` components vs anonymous components (`@props`) vs `@include` partials. + - Hint: `ls app/View/Components`; grep `constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`. + - Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`. +34. `down()` methods: real reverse logic vs omitted / one-way migrations. + - Hint: grep `function down` vs the migration count. +35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model. + - Hint: grep `->enum(` in migrations vs string columns cast to enums. +36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`. + - Hint: grep `DB::transaction`, `beginTransaction` in `app/`. +37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save. + - Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`. + +## H. Testing + +38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes. + - Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`. +39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`. + - Hint: grep those trait names in `tests/`. +40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories. + - Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide. +41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery. + - Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`. +42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`. + - Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`. + +## I. Responses & API resources + +43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly. + - Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers. +44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately. + - Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`. +45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority. + - Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them. +46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`. + - Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views. + +## J. Strings, collections & dates + +47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`. + - Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`. +48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`). + - Hint: grep `Str::of(` vs `Str::` vs native string funcs. +49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting. + - Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy. + +--- + +Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from. diff --git a/.claude/skills/laravel-best-practices/SKILL.md b/.claude/skills/laravel-best-practices/SKILL.md new file mode 100644 index 0000000..311ab84 --- /dev/null +++ b/.claude/skills/laravel-best-practices/SKILL.md @@ -0,0 +1,59 @@ +--- +name: laravel-best-practices +description: "Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns." +license: MIT +metadata: + author: laravel +--- + +# Laravel Best Practices + +Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`. + +## Consistency First + +Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern. + +Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides. + +## How to Apply + +1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out. +2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files. +3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job. +4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable. +5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them. +6. Re-read the diff against every mapped rule before finishing. + +## Rule Index + +Cross-cutting changes often need more than one rule file. + +| Concern | Read | +| --- | --- | +| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) | +| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) | +| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) | +| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) | +| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) | +| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) | +| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) | +| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) | +| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) | +| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) | +| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) | +| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) | +| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) | +| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) | +| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) | +| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) | +| Environment values and application configuration | [`rules/config.md`](rules/config.md) | +| Tests: coverage, factories, fakes, and assertions | the `testing-best-practices` skill | +| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) | +| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) | + +## Decision Rules + +- Prefer framework features and existing application abstractions over new helpers or dependencies. +- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable. +- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization. diff --git a/.claude/skills/laravel-best-practices/rules/advanced-queries.md b/.claude/skills/laravel-best-practices/rules/advanced-queries.md new file mode 100644 index 0000000..54dd783 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/advanced-queries.md @@ -0,0 +1,106 @@ +# Advanced Query Best Practices + +## Select Single Relationship Values with Subqueries + +When only one value from a has-many relationship is needed, consider a correlated subquery with `addSelect()` instead of loading the entire relationship. This selects the value as part of the main query without an additional relationship query. + +```php +public function scopeWithLastLoginAt($query): void +{ + $query->addSelect([ + 'last_login_at' => Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->withCasts(['last_login_at' => 'datetime']); +} +``` + +## Create Dynamic Relationships with a Subquery Foreign Key + +The same pattern can select a foreign key and expose the selected model through a `belongsTo` relationship. Eager loading that relationship still executes a separate query, but it avoids loading the full has-many collection. + +```php +public function lastLogin(): BelongsTo +{ + return $this->belongsTo(Login::class, 'last_login_id'); +} + +public function scopeWithLastLogin($query): void +{ + $query->addSelect([ + 'last_login_id' => Login::select('id') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1), + ])->with('lastLogin'); +} +``` + +## Combine Related Counts with Conditional Aggregates + +Combine several counts over the same filtered data set into one query by using conditional aggregates. Use `toBase()` when only scalar values are needed and model hydration provides no benefit. Confirm the expression syntax against the application's database engine. + +```php +$statuses = Feature::toBase() + ->selectRaw("count(case when status = 'Requested' then 1 end) as requested") + ->selectRaw("count(case when status = 'Planned' then 1 end) as planned") + ->selectRaw("count(case when status = 'Completed' then 1 end) as completed") + ->first(); +``` + +## Reuse Loaded Parent Models with `setRelation()` + +When a parent and its children are already loaded and code also accesses `$child->parent`, set the inverse relationship to the existing parent instance. This avoids an additional lazy-loading query for each child. + +```php +$feature->load('comments.user'); +$feature->comments->each->setRelation('feature', $feature); +``` + +## Compare `whereHas()` with an `IN` Subquery + +`whereHas()` typically produces an `EXISTS` subquery, while `whereIn()` can express the same filter with an `IN` subquery. Either form may be faster depending on the database engine, indexes, cardinality, and query plan. Measure both forms with representative data; neither subquery loads its result set into PHP memory. + +Option using `EXISTS`: + +```php +$query->whereHas('company', fn ($q) => $q->where('name', 'like', $term)); +``` + +Option using `IN`: + +```php +$query->whereIn('company_id', Company::where('name', 'like', $term)->select('id')); +``` + +## Measure Two Simple Queries Against One Complex Query + +Two targeted queries can outperform one complex correlated subquery or join when the first query is highly selective. They also add a database round trip, can transfer a large identifier list, and do not provide a single-query consistency snapshot. Decide from query plans and production-like measurements. + +## Design Composite Indexes for the Query + +For common multi-column sorts, consider a composite index whose column order supports the query's filters and ordering. Database engines may combine indexes or choose an explicit sort, so matching the `ORDER BY` list alone does not guarantee that an index will be used. Verify the query plan. + +```php +// Migration +$table->index(['last_name', 'first_name']); + +// Query that this index may support +User::query()->orderBy('last_name')->orderBy('first_name')->paginate(); +``` + +## Consider a Correlated Subquery for Has-Many Ordering + +When sorting by one value from a has-many relationship, a direct join can duplicate parent rows unless it first reduces the related table to one row per parent. A correlated subquery in `orderBy()` is often simpler, but its performance depends on the query plan and supporting indexes. + +```php +public function scopeOrderByLastLogin($query): void +{ + $query->orderByDesc(Login::select('created_at') + ->whereColumn('user_id', 'users.id') + ->latest() + ->take(1) + ); +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/architecture.md b/.claude/skills/laravel-best-practices/rules/architecture.md new file mode 100644 index 0000000..5e7af23 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/architecture.md @@ -0,0 +1,219 @@ +# Architecture Best Practices + +## Extract Focused Business Operations + +Extract a discrete business operation into an action class when doing so makes the operation easier to reuse or test. An action class has no special meaning to Laravel; follow the project's naming and invocation conventions. + +```php +class CreateOrderAction +{ + public function __construct(private InventoryService $inventory) {} + + public function handle(array $data): Order + { + $order = Order::create($data); + $this->inventory->reserve($order); + + return $order; + } +} +``` + +## Inject Required Dependencies + +Prefer constructor injection for dependencies required throughout an object's lifetime. Method injection is appropriate for dependencies needed by one controller action, listener, job handler, or other container-invoked method. Avoid `app()` and `resolve()` when normal injection can make a dependency explicit. + +Hidden dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request) + { + $service = app(OrderService::class); + + return $service->create($request->validated()); + } +} +``` + +Injected dependency: + +```php +class OrderController extends Controller +{ + public function store(StoreOrderRequest $request, OrderService $service) + { + return $service->create($request->validated()); + } +} +``` + +## Depend on Contracts at Boundaries + +Depend on contracts at system boundaries, such as payment gateways, notification channels, and external services, when testability or interchangeable implementations justify the abstraction. + +Concrete boundary dependency: + +```php +class OrderService +{ + public function __construct(private StripeGateway $gateway) {} +} +``` + +Contract boundary dependency: + +```php +interface PaymentGateway +{ + public function charge(int $amount, string $customerId): PaymentResult; +} + +class OrderService +{ + public function __construct(private PaymentGateway $gateway) {} +} +``` + +Bind in a service provider: + +```php +$this->app->bind(PaymentGateway::class, StripeGateway::class); +``` + +## Specify a Deterministic Sort Order + +Without an explicit `ORDER BY`, row order is undefined. Choose an order that matches the feature, and add a unique tie-breaker when stable pagination matters. + +Unspecified order: + +```php +$posts = Post::paginate(); +``` + +Newest first with a stable tie-breaker: + +```php +$posts = Post::query() + ->orderByDesc('created_at') + ->orderByDesc('id') + ->paginate(); +``` + +## Use Atomic Locks for Race Conditions + +Use a lock when concurrent execution must be serialized. `Cache::lock()` provides an atomic lock when the configured cache store supports locks. `lockForUpdate()` locks selected database rows and must run inside a database transaction. These mechanisms solve different coordination problems. + +```php +Cache::lock('order-processing-'.$order->id, 10)->block(5, function () use ($order) { + $order->process(); +}); + +// Or at query level, inside a transaction +DB::transaction(function () use ($id) { + $product = Product::where('id', $id)->lockForUpdate()->first(); + + // Read and update the product while the database lock is held. +}); +``` + +## Use `mb_*` String Functions + +When no Laravel helper exists, prefer multibyte-aware functions such as `mb_strlen()` and `mb_strtolower()` for UTF-8 text. For example, `strlen()` counts bytes, while `strtolower()` is not multibyte-aware. + +Incorrect: + +```php +strlen('José'); // 5 bytes, not 4 characters +strtolower('MÜNCHEN'); // Does not lowercase Ü +``` + +Correct: + +```php +mb_strlen('José'); // 4 characters +mb_strtolower('MÜNCHEN'); // 'münchen' + +// Prefer Laravel's Str helpers when available +Str::length('José'); // 4 +Str::lower('MÜNCHEN'); // 'münchen' +``` + +## Use `defer()` for Post-Response Work + +For lightweight work that does not need retries or crash durability, consider `defer()` instead of dispatching a job. During an HTTP request, the callback normally runs after the response has been sent but remains in the same PHP process. + +Queued and durable: + +```php +dispatch(new LogPageView($page)); +``` + +Deferred in the current process: + +```php +defer(fn () => PageView::create(['page_id' => $page->id, 'user_id' => auth()->id()])); +``` + +Use a queued job when the work needs retries, queue controls, or durability across process failures. + +## Use `Context` for Request-Scoped Data + +The `Context` facade makes contextual data available across the current execution lifecycle without manually passing arguments through every layer. + +```php +// In middleware +Context::add('tenant_id', $request->header('X-Tenant-ID')); + +// Later in the same execution lifecycle +$tenantId = Context::get('tenant_id'); +``` + +Visible context is added to log context, and both visible and hidden context are captured and restored for queued jobs. Use `Context::addHidden()` for data that should propagate to queued jobs without appearing in logs. Do not place secrets in context unless that propagation is intended. + +## Use `Concurrency::run()` for Parallel Execution + +Run independent operations concurrently through Laravel's configured concurrency driver. + +```php +use Illuminate\Support\Facades\Concurrency; + +[$users, $orders] = Concurrency::run([ + fn () => User::count(), + fn () => Order::where('status', 'pending')->count(), +]); +``` + +With a process-based driver, each closure runs in a separate PHP process that boots the application. Use concurrency when independent database queries, HTTP client calls, or computations benefit enough to offset process and serialization overhead. The `sync` driver executes closures sequentially and is useful primarily during testing. + +## Follow Framework Conventions + +Follow Laravel conventions unless the domain or an existing schema requires an override. + +Customized schema: + +```php +class Customer extends Model +{ + protected $table = 'Customer'; + protected $primaryKey = 'customer_id'; + + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class, 'role_customer', 'customer_id', 'role_id'); + } +} +``` + +Conventional schema: + +```php +class Customer extends Model +{ + public function roles(): BelongsToMany + { + return $this->belongsToMany(Role::class); + } +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/blade-views.md b/.claude/skills/laravel-best-practices/rules/blade-views.md new file mode 100644 index 0000000..4ea2c6c --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/blade-views.md @@ -0,0 +1,36 @@ +# Blade and View Best Practices + +## Use `$attributes->merge()` in Component Templates + +Use the component attribute bag so callers can add attributes. `merge()` combines default attributes with caller-provided values; class values receive special merging behavior. + +```blade +
merge(['class' => 'alert alert-'.$type]) }}> + {{ $message }} +
+``` + +## Use `@pushOnce` for Per-Component Scripts + +If a component renders repeatedly, `@push` adds its script on every render. Use a consistently named `@pushOnce` block to add that content once per rendered response. + +## Prefer Components for Explicit Interfaces + +Use a Blade component when a reusable interface benefits from explicit props, an attribute bag, or slots. An include remains suitable for a small partial that intentionally uses the current view data; pass an explicit data array when implicit variable sharing would obscure its dependencies. + +## Share Compatible View Data with a View Composer + +Use a view composer to centralize data needed whenever one or more named Blade views are rendered. Keep the composer compatible with every view it targets, and avoid broad wildcards when views require different data shapes. A view composer runs when Laravel renders the matching view; it does not supply data to JSON, streamed, or other non-view responses. + +## Return Blade Fragments for Partial Rendering + +A route can return either a full view or a named fragment for clients such as htmx or Turbo. + +```php +return view('dashboard', compact('users')) + ->fragmentIf($request->hasHeader('HX-Request'), 'user-list'); +``` + +## Share Parent Component Props with `@aware` + +Use `@aware` when a nested component needs a prop explicitly passed to an ancestor component. It does not expose an ancestor's default prop value unless that value was passed through the attribute bag. diff --git a/.claude/skills/laravel-best-practices/rules/caching.md b/.claude/skills/laravel-best-practices/rules/caching.md new file mode 100644 index 0000000..cd2ffd6 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/caching.md @@ -0,0 +1,100 @@ +# Caching Best Practices + +## Use `Cache::remember()` for Cache-Aside Reads + +`Cache::remember()` implements a cache-aside read without a separate truthiness check. It does not prevent concurrent requests from computing the same missing value; use an atomic lock when duplicate computation must be prevented. + +The manual version below incorrectly treats valid falsy values, such as `false` or `0`, as cache misses. + +Incorrect: + +```php +$val = Cache::get('stats'); +if (! $val) { + $val = $this->computeStats(); + Cache::put('stats', $val, 60); +} +``` + +Correct: + +```php +$val = Cache::remember('stats', 60, fn () => $this->computeStats()); +``` + +## Consider `Cache::flexible()` for Stale-While-Revalidate + +For frequently read keys, `Cache::flexible()` can serve stale data during a defined stale period and register a deferred refresh. During an HTTP request, that refresh normally runs after the response; it is not a durable background job. Once the stale period has elapsed, the request recomputes the value synchronously. + +Synchronous expiration: + +```php +Cache::remember('users', 300, fn () => User::all()); +``` + +Stale-while-revalidate tradeoff: + +```php +Cache::flexible('users', [300, 600], fn () => User::all()); +``` + +This value is fresh for five minutes and may be served stale until ten minutes after it was cached. + +## Use `Cache::memo()` to Avoid Redundant Hits Within an Execution + +If the same cache key is read repeatedly during one request or job, `memo()` decorates a cache store and retains resolved values in memory for that execution. + +```php +$settings = Cache::memo()->get('settings'); +``` + +Repeated reads through the same memoized store avoid additional store lookups. Writes through the memoized store update or invalidate its in-memory values as appropriate. + +## Use Cache Tags to Invalidate Related Groups + +Tags group related entries for invalidation without tracking each key. Cache tags are not supported by the `file`, `dynamodb`, or `database` drivers; confirm support before choosing a store. + +```php +Cache::tags(['user-1'])->flush(); +``` + +## Use `Cache::add()` for Atomic Conditional Writes + +`add()` atomically writes a value only when the key does not already exist. + +Incorrect: + +```php +if (! Cache::has('lock')) { + Cache::put('lock', true, 10); +} +``` + +Correct: + +```php +Cache::add('lock', true, 10); +``` + +Use `Cache::lock()` rather than an ordinary cache key when lock ownership and safe release are required. + +## Use `once()` for In-Process Memoization + +`once()` memoizes a callback's return value for the current request or job. Calls made from an object instance are scoped to that instance. Unlike `Cache::memo()`, `once()` does not read from an external cache store. + +```php +public function roles(): Collection +{ + return once(fn () => $this->loadRoles()); +} +``` + +Repeated calls return the memoized result without rerunning the callback. Use `once()` for repeated computation within one execution. Use `Cache::memo()` to memoize access to an underlying store that can also persist values across executions. + +## Configure Failover Cache Stores in Production + +The failover driver tries each configured store in order when a store operation throws an exception. It does not consult later stores for an ordinary cache miss, and data is not replicated between stores. + +```php +'failover' => ['driver' => 'failover', 'stores' => ['redis', 'database']], +``` diff --git a/.claude/skills/laravel-best-practices/rules/collections.md b/.claude/skills/laravel-best-practices/rules/collections.md new file mode 100644 index 0000000..211fec1 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/collections.md @@ -0,0 +1,72 @@ +# Collection Best Practices + +## Use Higher-Order Messages for Simple Operations + +Explicit closure: + +```php +$users->each(function (User $user) { + $user->markAsVip(); +}); +``` + +Concise equivalent: + +```php +$users->each->markAsVip(); +``` + +Higher-order messages are available for supported collection methods such as `each`, `map`, `filter`, and `sum`. Use an explicit closure when arguments or nontrivial logic would be clearer. + +## Choose Between `cursor()` and `lazy()` + +`cursor()` executes one query and hydrates models individually, but it cannot eager load relationships. The database driver's result buffering can still consume substantial memory for very large results. Use it for low-memory, attribute-only iteration when one long-running query is acceptable. + +`lazy()` executes multiple chunked queries and returns a flat `LazyCollection`. It supports eager loading relationships for each chunk and avoids holding one database cursor open for the entire iteration. + +With relationships: + +```php +User::with('roles')->lazy()->each(function (User $user) { + // The roles for this chunk have been eager loaded. +}); +``` + +Without relationships: + +```php +User::cursor()->each(function (User $user) { + // Process model attributes. +}); +``` + +## Use `lazyById()` When Updating Records While Iterating + +`lazy()` uses offset pagination, so updates to columns that affect the query can shift rows and cause records to be skipped or processed twice. `lazyById()` paginates by a monotonic key and is safer when updating other columns during iteration. Do not change the pagination key itself while iterating. + +## Use `toQuery()` for Bulk Operations on Collections + +Use `toQuery()` to build a query from the models in an Eloquent collection instead of manually constructing a `whereIn` clause. + +Manual query: + +```php +User::whereIn('id', $users->modelKeys())->update(['active' => false]); +``` + +Collection query: + +```php +$users->toQuery()->update(['active' => false]); +``` + +`toQuery()` requires a non-empty Eloquent collection whose models are of the same type. Like other bulk Eloquent updates, it does not dispatch per-model update events, so use it only when those events are not required. + +## Use `#[CollectedBy]` for Custom Collection Classes + +The `#[CollectedBy]` attribute declares the custom collection class without requiring a `newCollection()` override. + +```php +#[CollectedBy(UserCollection::class)] +class User extends Model {} +``` diff --git a/.claude/skills/laravel-best-practices/rules/config.md b/.claude/skills/laravel-best-practices/rules/config.md new file mode 100644 index 0000000..5a34de1 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/config.md @@ -0,0 +1,87 @@ +# Configuration Best Practices + +## Read Environment Variables in Configuration Files + +Call `env()` only from configuration files. After configuration is cached, Laravel does not load the application's `.env` file, so application code should read configuration values through `config()`. + +Incorrect: + +```php +$key = env('API_KEY'); +``` + +Correct: + +```php +// config/services.php +return [ + 'key' => env('API_KEY'), +]; + +// Application code +$key = config('services.key'); +``` + +## Protect Production Secrets + +Do not commit plaintext production secrets. Laravel can encrypt an environment file so its encrypted form can be stored safely, while deployment platforms can supply secrets through their native secret stores. + +Incorrect: + +```bash + +# A plaintext .env file committed to the repository + +STRIPE_SECRET=sk_live_abc123 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI +``` + +Encrypted environment file: + +```bash +php artisan env:encrypt --env=production --readable +php artisan env:decrypt --env=production +``` + +For hosted deployments, consider the platform's native secret store, such as AWS Secrets Manager or Vault, and inject secrets at runtime. + +## Use `App::environment()` for Environment Checks + +Incorrect: + +```php +if (env('APP_ENV') === 'production') { + // ... +} +``` + +Correct: + +```php +if (app()->isProduction()) { + // ... +} + +if (App::environment('production')) { + // ... +} +``` + +## Name Repeated Domain Values + +Use an enum or class constant when a domain value is repeated or represents a constrained set. A one-off string literal does not always need a named constant. + +```php +// Repeated literal +return $this->type === 'normal'; + +// Named domain value +return $this->type === self::TYPE_NORMAL; +``` + +If the application supports localization, put user-facing strings in language files and retrieve them with `__()`. Simple literals are reasonable for applications that intentionally do not support multiple languages. + +```php +// In a localized application +return back()->with('message', __('app.article_added')); +``` diff --git a/.claude/skills/laravel-best-practices/rules/db-performance.md b/.claude/skills/laravel-best-practices/rules/db-performance.md new file mode 100644 index 0000000..3f33977 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/db-performance.md @@ -0,0 +1,189 @@ +# Database Performance Best Practices + +## Eager Load Relationships Before Iterating + +When a relationship will be accessed for many models, eager load it with `with()` to avoid running one initial query plus one relationship query per model, commonly called an N+1 query pattern. Lazy loading is reasonable when the relationship may not be needed or only one model is involved. + +Lazy-loaded version: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Eager-loaded version: + +```php +$posts = Post::with('author')->get(); +foreach ($posts as $post) { + echo $post->author->name; +} +``` + +Constrain eager loads when large columns are unnecessary. Include the related model's primary key and every column Eloquent needs to match the relationship. In this example, `users.id` and `posts.user_id` match posts to users, while selecting `posts.id` preserves each related model's primary key: + +```php +$users = User::with(['posts' => function ($query) { + $query->select('id', 'user_id', 'title') + ->where('published', true) + ->latest() + ->limit(10); +}])->get(); +``` + +## Prevent Lazy Loading in Development + +Enable this in `AppServiceProvider::boot()` to catch N+1 issues during development. + +```php +public function boot(): void +{ + Model::preventLazyLoading(! app()->isProduction()); +} +``` + +By default, accessing an unloaded relationship then throws a `LazyLoadingViolationException`. Applications can customize violation handling with `handleLazyLoadingViolationUsing()`. + +## Select Only Needed Columns + +Select only the columns the operation needs when omitting large text, binary, or JSON columns provides a meaningful benefit. + +All columns: + +```php +$posts = Post::with('author')->get(); +``` + +Selected columns: + +```php +$posts = Post::select('id', 'title', 'user_id', 'created_at') + ->with(['author:id,name,avatar']) + ->get(); +``` + +When limiting selected columns, retain every key Eloquent needs for matching. A `belongsTo` relationship needs its foreign key on the parent query and the owner's key on the related query. A `hasMany` relationship needs the parent's local key and the related model's foreign key. + +## Process Large Data Sets Incrementally + +Use chunking or lazy iteration when loading an entire result set would exceed the application's practical memory budget. + +Loads the complete result set: + +```php +$users = User::all(); +foreach ($users as $user) { + $user->notify(new WeeklyDigest); +} +``` + +Processes bounded chunks: + +```php +User::where('subscribed', true)->chunk(200, function ($users) { + foreach ($users as $user) { + $user->notify(new WeeklyDigest); + } +}); +``` + +Use `chunkById()` when updates can change which rows match the query. Standard `chunk()` uses offset pagination, whose result positions can shift as rows change: + +```php +User::where('active', false)->chunkById(200, function ($users) { + $users->each->delete(); +}); +``` + +For read-only, attribute-only iteration, `cursor()` hydrates models individually from one query, although some database drivers still buffer raw results. Use `lazy()` when relationships must be eager loaded in chunks, and use `lazyById()` or `chunkById()` when updates can affect query membership. See the collection rules for detailed tradeoffs. + +## Add Indexes for Measured Query Patterns + +Design indexes around frequent, performance-sensitive query patterns. A column's presence in `WHERE`, `ORDER BY`, `JOIN`, or `GROUP BY` does not by itself justify an index; selectivity, write cost, existing indexes, and the database query plan all matter. + +Schema without an application-specific query index: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); +}); +``` + +Schema optimized for `WHERE status = ? ORDER BY created_at`: + +```php +Schema::create('orders', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained(); + $table->string('status'); + $table->timestamps(); + $table->index(['status', 'created_at']); +}); +``` + +Confirm composite index column order and effectiveness with production-like data and the database's query-plan tools. Also check whether the database already created an index to support a foreign key before adding another one. + +## Count Relationships Without Loading Them + +Use `withCount()` when only relationship counts are needed; loading and hydrating every related model wastes memory. + +Loads related models: + +```php +$posts = Post::all(); +foreach ($posts as $post) { + echo $post->comments->count(); +} +``` + +Selects relationship counts: + +```php +$posts = Post::withCount('comments')->get(); +foreach ($posts as $post) { + echo $post->comments_count; +} +``` + +Conditional counting: + +```php +$posts = Post::withCount([ + 'comments', + 'comments as approved_comments_count' => function ($query) { + $query->where('approved', true); + }, +])->get(); +``` + +## Keep Queries Out of Blade Templates + +Prepare data before rendering a Blade template, such as in a controller, query service, or view composer. This keeps query behavior visible and testable. + +Query in the template: + +```blade +@foreach (User::all() as $user) + {{ $user->profile->name }} +@endforeach +``` + +Data prepared before rendering: + +```php +// Controller +$users = User::with('profile')->get(); + +return view('users.index', compact('users')); +``` + +```blade +@foreach ($users as $user) + {{ $user->profile->name }} +@endforeach +``` diff --git a/.claude/skills/laravel-best-practices/rules/eloquent.md b/.claude/skills/laravel-best-practices/rules/eloquent.md new file mode 100644 index 0000000..5a89510 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/eloquent.md @@ -0,0 +1,158 @@ +# Eloquent Best Practices + +## Define Precise Relationship Types + +Define the relationship that matches the database association, and declare its concrete return type. + +```php +public function comments(): HasMany +{ + return $this->hasMany(Comment::class); +} + +public function author(): BelongsTo +{ + return $this->belongsTo(User::class, 'user_id'); +} +``` + +## Use Local Scopes for Reusable Queries + +Extract reusable query constraints into local scopes to avoid duplication. + +Duplicated constraints: + +```php +$active = User::where('verified', true)->whereNotNull('activated_at')->get(); +$articles = Article::whereHas('user', function ($q) { + $q->where('verified', true)->whereNotNull('activated_at'); +})->get(); +``` + +Reusable local scope: + +```php +#[Scope] +protected function active(Builder $query): Builder +{ + return $query->where('verified', true)->whereNotNull('activated_at'); +} + +// Usage +$active = User::active()->get(); +$articles = Article::whereHas('user', fn ($q) => $q->active())->get(); +``` + +## Apply Global Scopes Sparingly + +Global scopes silently modify every query on the model, making debugging difficult. Prefer local scopes and reserve global scopes for truly universal constraints like soft deletes or multi-tenancy. + +Global scope tradeoff: + +```php +class PublishedScope implements Scope +{ + public function apply(Builder $builder, Model $model): void + { + $builder->where('published', true); + } +} + +// Admin panels, reports, and jobs now omit drafts unless the scope is removed. +``` + +Explicit local scope: + +```php +#[Scope] +protected function published(Builder $query): Builder +{ + return $query->where('published', true); +} + +Post::published()->paginate(); // Explicit +Post::paginate(); // Admin sees all +``` + +## Define Attribute Casts + +Use the `casts()` method (or `$casts` property following project convention) for automatic type conversion. + +```php +protected function casts(): array +{ + return [ + 'is_active' => 'boolean', + 'metadata' => 'array', + 'total' => 'decimal:2', + ]; +} +``` + +## Cast Date and Time Attributes + +Cast a date or timestamp attribute when application code should treat it as a Carbon instance. Eloquent already casts the conventional `created_at` and `updated_at` timestamps. + +Manual parsing in the template: + +```blade +{{ Carbon::parse($order->ordered_at)->toDateString() }} +``` + +Model cast: + +```php +protected function casts(): array +{ + return [ + 'ordered_at' => 'datetime', + ]; +} +``` + +```blade +{{ $order->ordered_at->toDateString() }} +{{ $order->ordered_at->format('m-d') }} +``` + +## Use `whereBelongsTo()` for Relationship Queries + +`whereBelongsTo()` expresses the relationship constraint without manually specifying its foreign key. + +Foreign key constraint: + +```php +Post::where('user_id', $user->id)->get(); +``` + +Relationship-aware constraint: + +```php +Post::whereBelongsTo($user)->get(); +Post::whereBelongsTo($user, 'author')->get(); +``` + +## Keep Application Queries Model-Aware + +Prefer Eloquent models and relationships for model-backed application queries. They preserve casts, scopes, and model table configuration. The query builder and raw SQL legitimately require table names, so use them when their lower-level behavior is intentional. + +Lower-level alternatives: + +```php +DB::table('users')->where('active', true)->get(); + +$query->join('companies', 'companies.id', '=', 'users.company_id'); + +DB::select('SELECT * FROM orders WHERE status = ?', ['pending']); +``` + +Model-aware queries: + +```php +User::where('active', true)->get(); +Order::where('status', 'pending')->get(); +``` + +When a query builder operation should follow a model's configured table name, use `(new User)->getTable()`. For complex joins or raw SQL, explicit table names may be clearer; keep those references covered by tests when schema changes are possible. + +In migrations, use explicit table names rather than application models. Migrations are historical snapshots, while models and their scopes can change after a migration is deployed. diff --git a/.claude/skills/laravel-best-practices/rules/error-handling.md b/.claude/skills/laravel-best-practices/rules/error-handling.md new file mode 100644 index 0000000..afcdf4a --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/error-handling.md @@ -0,0 +1,77 @@ +# Error Handling Best Practices + +## Choose Where to Report and Render Exceptions + +Laravel supports exception-specific methods and centralized handler callbacks. Follow the pattern already established by the project. + +Exception methods keep behavior beside the exception definition: + +```php +class InvalidOrderException extends Exception +{ + public function report(): void + { + // Send the exception to a custom reporter. + } + + public function render(Request $request): Response + { + return response()->view('errors.invalid-order', status: 422); + } +} +``` + +Centralized callbacks in `bootstrap/app.php` keep the application's exception policy together: + +```php +->withExceptions(function (Exceptions $exceptions) { + $exceptions->report(function (InvalidOrderException $e) { + // Send the exception to a custom reporter. + }); + $exceptions->render(function (InvalidOrderException $e, Request $request) { + return response()->view('errors.invalid-order', status: 422); + }); +}) +``` + +An exception's `report()` method suppresses Laravel's default reporting unless it returns `false`. A report callback allows default reporting unless it returns `false` or is chained with `stop()`. Use `ShouldntReport` or `dontReport()` when the handler should not report an exception at all. By contrast, returning `false` from a `render()` method or render callback defers to Laravel's default rendering. + +## Mark Exceptions the Handler Should Not Report + +Implementing `ShouldntReport` prevents Laravel's exception handler from reporting that exception type and keeps the policy visible on the class. It does not prevent application code from logging the exception explicitly. + +```php +class PodcastProcessingException extends Exception implements ShouldntReport {} +``` + +## Throttle High-Volume Exception Reports + +A failing integration can flood logs or error tracking. Configure `throttle()` with a `Lottery` or `Limit` result to sample or rate-limit matching exception reports. Choose keys deliberately when separate exception classes, tenants, or integrations need independent limits. + +## Prevent Duplicate Reports of One Exception Instance + +Enable `dontReportDuplicates()` when the same exception object may pass through multiple `report($exception)` calls. It deduplicates by object identity, not by exception class or message. + +## Define JSON Rendering for API Routes + +Laravel normally uses request content negotiation to decide whether to render an exception as JSON. If the application's API contract requires JSON regardless of the `Accept` header, define that policy explicitly for the relevant routes. + +```php +$exceptions->shouldRenderJsonWhen(function (Request $request, Throwable $e) { + return $request->is('api/*') || $request->expectsJson(); +}); +``` + +## Add Context to Exception Classes + +Attach structured data to an exception through `context()`. Laravel merges that data into the exception's log context when the handler reports it. + +```php +class InvalidOrderException extends Exception +{ + public function context(): array + { + return ['order_id' => $this->orderId]; + } +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/events-notifications.md b/.claude/skills/laravel-best-practices/rules/events-notifications.md new file mode 100644 index 0000000..a8cce31 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/events-notifications.md @@ -0,0 +1,52 @@ +# Events and Notifications Best Practices + +## Rely on Event Discovery + +Laravel discovers listeners in the configured listener directories by inspecting type-hinted event arguments on `handle()` or `__invoke()` methods. Register listeners manually only when discovery is disabled, the listener is outside those directories, or explicit registration is clearer. + +## Cache Event Discovery During Production Deployment + +Cache discovered listeners during production deployment with `php artisan optimize` or `php artisan event:cache`. Rebuild the cache whenever listener definitions change. + +## Use `ShouldDispatchAfterCommit` Inside Transactions + +When an event is dispatched inside a database transaction, `ShouldDispatchAfterCommit` delays dispatch until all open database transactions commit. If a transaction rolls back, Laravel discards the event. This affects synchronous and queued listeners; it is not limited to queue timing. + +```php +class OrderShipped implements ShouldDispatchAfterCommit {} +``` + +## Queue Slow Notifications + +Queue notifications that call external services, such as email, text messaging, or Slack, when they do not need to complete before the response. Keep a notification synchronous when immediate completion or failure feedback is part of the operation. + +```php +class InvoicePaid extends Notification implements ShouldQueue +{ + use Queueable; +} +``` + +## Dispatch Queued Notifications After Commit + +A queued notification sent inside a database transaction can run before the transaction commits. Call `afterCommit()` on the queued notification, or enable the queue connection's `after_commit` option, when its delivery depends on committed data. This setting has no scheduling effect on a synchronous notification. + +```php +$user->notify((new InvoicePaid($invoice))->afterCommit()); +``` + +## Route Notification Channels to Dedicated Queues + +Different notification channels can have different latency and priority requirements. Implement `viaQueues()` when channels should use separate queues. + +## Use On-Demand Notifications for Non-User Recipients + +Avoid creating dummy models to send notifications to arbitrary addresses. + +```php +Notification::route('mail', 'admin@example.com')->notify(new SystemAlert()); +``` + +## Implement `HasLocalePreference` on Notifiable Models + +Implement `HasLocalePreference::preferredLocale()` on a notifiable model when notifications and mailables should use the recipient's locale. Laravel also preserves that locale for queued delivery. An explicit `locale()` call can still override the preference for an individual notification. diff --git a/.claude/skills/laravel-best-practices/rules/http-client.md b/.claude/skills/laravel-best-practices/rules/http-client.md new file mode 100644 index 0000000..cc8fdd6 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/http-client.md @@ -0,0 +1,157 @@ +# HTTP Client Best Practices + +## Set Explicit Timeouts + +Laravel's HTTP client has a 30-second response timeout by default. Choose response and connection timeouts that fit the service and the calling request or job. Remember that retries can multiply the total elapsed time. + +Less resilient: + +```php +$response = Http::get('https://api.example.com/users'); +``` + +Preferred: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users'); +``` + +Define shared settings in a macro or a dedicated client: + +```php +Http::macro('github', function () { + return Http::baseUrl('https://api.github.com') + ->connectTimeout(3) + ->timeout(10) + ->withToken(config('services.github.token')); +}); + +$response = Http::github()->get('/repos/laravel/framework'); +``` + +## Retry Only Safe Operations + +Retry transient connection failures, rate-limit responses, and server errors with an appropriate delay. Retry idempotent requests such as `GET` when the operation can safely run more than once. Retry a state-changing request only when the remote API supports an idempotency key or provides equivalent duplicate protection. + +Unsafe without an idempotency guarantee: + +```php +$response = Http::retry([100, 500, 1000]) + ->post('https://api.example.com/v1/charges', $data); +``` + +Safe for an idempotent request: + +```php +$response = Http::connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->get('https://api.example.com/data'); +``` + +For a supported state-changing API, send a stable idempotency key for every attempt: + +```php +$response = Http::withHeaders(['Idempotency-Key' => $paymentAttempt->uuid]) + ->connectTimeout(3) + ->timeout(10) + ->retry([100, 500, 1000], 0, function (Throwable $exception) { + return $exception instanceof ConnectionException + || ($exception instanceof RequestException + && ($exception->response->serverError() || $exception->response->status() === 429)); + }) + ->post('https://api.example.com/v1/charges', $data); +``` + +## Handle Errors Explicitly + +The HTTP client returns responses for `4xx` and `5xx` status codes instead of throwing by default. Inspect the expected statuses or call `throw()` before consuming a success payload. + +Unsafe when a success payload is expected: + +```php +$user = Http::get('https://api.example.com/users/1')->json(); +``` + +Preferred: + +```php +$user = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1') + ->throw() + ->json(); +``` + +Handle expected alternatives explicitly when graceful degradation is required: + +```php +$response = Http::connectTimeout(3) + ->timeout(5) + ->get('https://api.example.com/users/1'); + +if ($response->successful()) { + return $response->json(); +} + +if ($response->notFound()) { + return null; +} + +$response->throw(); +``` + +## Pool Independent Requests + +Use `Http::pool()` when several independent requests can run concurrently. Pooling changes execution time, not error handling; inspect or throw for each response as needed. + +```php +use Illuminate\Http\Client\Pool; + +$responses = Http::pool(fn (Pool $pool) => [ + $pool->as('users')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/users'), + $pool->as('posts')->connectTimeout(3)->timeout(5) + ->get('https://api.example.com/posts'), +]); + +$users = $responses['users']->throw()->json(); +$posts = $responses['posts']->throw()->json(); +``` + +## Fake HTTP Requests in Tests + +Use `Http::fake()` for external integrations, and use `Http::preventStrayRequests()` when an unexpected real request should fail the test. Also test timeouts, connection failures, and error responses that the application handles. + +```php +it('syncs a user from the API', function () { + Http::preventStrayRequests(); + + Http::fake([ + 'api.example.com/users/1' => Http::response([ + 'name' => 'John Doe', + 'email' => 'john@example.com', + ]), + ]); + + (new UserSyncService)->sync(1); + + Http::assertSent(fn (Request $request) => + $request->url() === 'https://api.example.com/users/1' + ); +}); +``` + +For example, fake a connection failure when testing the integration's failure path: + +```php +Http::fake([ + 'api.example.com/*' => Http::failedConnection(), +]); +``` diff --git a/.claude/skills/laravel-best-practices/rules/mail.md b/.claude/skills/laravel-best-practices/rules/mail.md new file mode 100644 index 0000000..f631043 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/mail.md @@ -0,0 +1,54 @@ +# Mail Best Practices + +## Queue Slow Mail Delivery + +Implement `ShouldQueue` on a mailable when delivery should normally happen in the background. Laravel queues that mailable even when the call site uses `Mail::send()`. + +```php +class OrderShipped extends Mailable implements ShouldQueue +{ + use Queueable, SerializesModels; +} +``` + +Keep mail synchronous when the caller must know immediately whether delivery was accepted, or when no queue worker is available. + +## Dispatch Queued Mail After Commit + +A queued mailable dispatched during a database transaction can be processed before the transaction commits. Call `afterCommit()` on the mailable, or enable the queue connection's `after_commit` option, when the mail depends on committed records. + +```php +Mail::to($user)->send( + (new OrderShipped($order))->afterCommit() +); +``` + +If the transaction rolls back, an after-commit mailable is not dispatched. This setting affects queued mail only; it does not defer synchronous delivery. + +## Assert the Delivery Mode + +Use `Mail::assertQueued()` for queued mailables and `Mail::assertSent()` for synchronously sent mailables. + +Incorrect for a mailable that implements `ShouldQueue`: + +```php +Mail::assertSent(OrderShipped::class); +``` + +Correct: + +```php +Mail::assertQueued(OrderShipped::class); +``` + +## Use Markdown Mailables When They Fit + +Markdown mailables render HTML and plain-text versions from Laravel's mail components and support publishable themes. They are useful for conventional transactional messages, but a custom HTML and text pair may be more appropriate for a specialized design. + +```bash +php artisan make:mail OrderShipped --markdown=mail.orders.shipped +``` + +## Separate Content and Delivery Tests + +Test rendered content by instantiating the mailable and using assertions such as `assertSeeInHtml()` and `assertSeeInText()`. Test delivery separately with `Mail::fake()` and `assertSent()` or `assertQueued()` so failures identify the affected behavior. diff --git a/.claude/skills/laravel-best-practices/rules/migrations.md b/.claude/skills/laravel-best-practices/rules/migrations.md new file mode 100644 index 0000000..3345402 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/migrations.md @@ -0,0 +1,67 @@ +# Migration Best Practices + +## Generate Migrations with Artisan + +Use `php artisan make:migration` to generate the timestamped filename and migration structure. + +```bash +php artisan make:migration create_posts_table +php artisan make:migration add_slug_to_posts_table +``` + +## Define Foreign-Key Constraints Deliberately + +Use `constrained()` when its naming conventions and default actions match the relationship. Specify the table or delete behavior when they do not. + +```php +$table->foreignId('user_id')->constrained()->cascadeOnDelete(); +$table->foreignId('author_id')->constrained('users'); +``` + +Do not add a duplicate single-column index without checking the database driver's treatment of foreign-key indexes and the indexes already created by the migration. + +## Treat Deployed Migrations as Immutable + +After a migration has run in a shared or production environment, create a new migration for subsequent changes. Editing the old file makes fresh installations differ from upgraded installations. + +For a local migration that has not been shared or deployed, editing and rerunning it may be simpler. + +## Design Indexes for Real Queries + +Add indexes based on query patterns, selectivity, write cost, and the database's ability to use composite indexes. A column appearing in `WHERE`, `ORDER BY`, or `JOIN` does not automatically need its own index. + +Declare each selected index in the schema migration that creates or changes the relevant table. Confirm important indexes with representative data and the database's query plan, and avoid redundant indexes whose leading columns duplicate an existing index without serving a distinct query. See the database performance and advanced query rules for index selection and column-order guidance. + +## Stage Changes That Affect Existing Rows + +Adding a required or unique column to a populated table often needs multiple deployment-safe steps. Add a nullable column, deploy code that can handle both states, backfill existing rows in bounded chunks, then add the required constraint or index after the data is valid. + +Do not assume this migration is safe on a populated table: + +```php +$table->string('slug')->unique(); +``` + +Large backfills are usually better implemented as an observable, restartable command or job than inside a schema migration. Small deterministic data changes may be reasonable in a migration when their locking, transaction, and deployment behavior is understood. + +## Mirror Defaults Only When Unsaved Models Need Them + +A database default is applied when a row is inserted, not when a model is instantiated. Mirror the value in the model's `$attributes` only when application code must observe that default before persistence, and keep both definitions synchronized. + +```php +// Migration +$table->string('status')->default('pending'); + +// Model +protected $attributes = [ + 'status' => 'pending', +]; +``` + +## Make Rollbacks Honest + +Implement `down()` when the change can be safely reversed. A rollback that drops populated columns or cannot restore transformed data is destructive even if it is syntactically reversible; document that limitation and prefer a forward-fix migration in production. + +## Keep Migrations Focused + +Keep each migration small enough to reason about, deploy, and reverse. Separate long-running backfills from schema changes when doing so reduces locks and supports phased deployment, but do not split related operations merely to enforce a blanket separation between data definition and data manipulation. diff --git a/.claude/skills/laravel-best-practices/rules/queue-jobs.md b/.claude/skills/laravel-best-practices/rules/queue-jobs.md new file mode 100644 index 0000000..011828a --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/queue-jobs.md @@ -0,0 +1,117 @@ +# Queue and Job Best Practices + +## Keep Reservation Time Longer Than Execution Time + +For queue drivers that use Laravel's `retry_after` setting, configure it to exceed the longest worker or job timeout by a safety margin. When a reservation expires, another worker can reserve the same job while the first process is still running. Keep the worker's `--timeout` several seconds shorter than `retry_after`. + +```php +// Job +public $timeout = 120; + +// config/queue.php for the connection +'retry_after' => 150, +``` + +Amazon Simple Queue Service uses its visibility timeout instead of Laravel's `retry_after`; configure that timeout at the queue level. Because workers can also stop after side effects but before acknowledging a job, make important jobs idempotent even with correct timeout settings. + +## Back Off Transient Failures + +Use progressively longer delays when a dependency needs time to recover. Do not retry permanent validation or business-rule failures. + +```php +class SyncWithStripe implements ShouldQueue +{ + public $tries = 4; + + public $backoff = [1, 5, 10]; +} +``` + +Rate-limiting and exception-throttling middleware can release jobs back to the queue. Released attempts may still count toward the maximum attempt limit, so configure `$tries` or `retryUntil()` to allow the intended retry window. + +## Use Unique Jobs for Dispatch Deduplication + +Implement `ShouldBeUnique` when only one queued instance of a logical job should exist. Uniqueness uses a cache lock and is not a substitute for idempotent processing or a database constraint. + +```php +class GenerateInvoice implements ShouldQueue, ShouldBeUnique +{ + public $uniqueFor = 3600; + + public function uniqueId(): string + { + return (string) $this->order->id; + } +} +``` + +All dispatching processes must use a shared cache that supports locks. Unique-job constraints do not apply to jobs within batches. + +Use `ShouldBeUniqueUntilProcessing` only when the lock should be released immediately before processing begins, allowing another instance to be dispatched while the first is running: + +```php +class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing +{ + // ... +} +``` + +## Handle Terminal Failure When Needed + +Implement `failed()` when the application must update state, alert an operator, or record domain-specific context after all attempts are exhausted. Logging every failure in each job may duplicate the queue system's failure reporting. + +Laravel invokes `failed()` on a new job instance, so mutations made to the job during `handle()` are not available there. + +```php +public function failed(?Throwable $exception): void +{ + $this->podcast->update(['status' => 'failed']); + + Log::error('Podcast processing failed', [ + 'podcast_id' => $this->podcast->id, + 'exception' => $exception, + ]); +} +``` + +## Rate Limit External Calls + +Use queue middleware such as `RateLimited` when jobs share a third-party API quota. Define the named limiter and choose release delays and attempt limits together. + +```php +public function middleware(): array +{ + return [new RateLimited('external-api')]; +} +``` + +## Batch Jobs for Group Coordination + +Use `Bus::batch()` to monitor a group of jobs and run callbacks when the batch completes or encounters failures. A batch is not a database transaction: completed jobs are not rolled back when another job fails. By default, one failed job cancels the batch; call `allowFailures()` only when partial failure is acceptable. + +```php +Bus::batch([ + new ImportCsvChunk($chunk1), + new ImportCsvChunk($chunk2), +]) + ->then(fn (Batch $batch) => Notification::send($user, new ImportComplete)) + ->catch(fn (Batch $batch, Throwable $exception) => Log::error('Import batch failed', [ + 'exception' => $exception, + ])) + ->dispatch(); +``` + +## Configure Time-Based Retry Limits Deliberately + +Use `retryUntil()` as the time-based alternative to a maximum attempt count. Laravel may attempt the job any number of times until this deadline, subject to other failure conditions such as maximum exceptions. The method takes precedence over attempt-based limits, so setting `$tries = 0` is not required. + +```php +public function retryUntil(): DateTimeInterface +{ + return now()->addHours(4); +} +``` + +## Use Horizon for Redis Queue Operations + +Laravel Horizon provides monitoring, balancing, metrics, and supervisor configuration for Redis queues. It does not support non-Redis queue drivers. diff --git a/.claude/skills/laravel-best-practices/rules/routing.md b/.claude/skills/laravel-best-practices/rules/routing.md new file mode 100644 index 0000000..1d19ef2 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/routing.md @@ -0,0 +1,106 @@ +# Routing and Controller Best Practices + +## Use Implicit Route Model Binding + +Let Laravel resolve models from route parameters when the default lookup and missing-model behavior fit the endpoint. + +Instead of manual lookup: + +```php +public function show(int $id): View +{ + $post = Post::findOrFail($id); + + return view('posts.show', ['post' => $post]); +} +``` + +Use route model binding: + +```php +public function show(Post $post): View +{ + return view('posts.show', ['post' => $post]); +} +``` + +## Scope Nested Bindings + +Use scoped bindings when a nested resource must belong to its parent. This constrains model resolution; it does not replace authorization. + +```php +Route::get('/users/{user}/posts/{post}', function (User $user, Post $post) { + // The resolved post belongs to the resolved user. +})->scopeBindings(); +``` + +## Use Resource Routes for Resourceful Actions + +Use `Route::resource()` or `Route::apiResource()` when the endpoint follows Laravel's resource-controller actions. Define explicit routes when the behavior does not fit that vocabulary. + +```php +Route::resource('posts', PostController::class); + +// Alternatively, for an API-only resource: +Route::apiResource('posts', ApiPostController::class); +``` + +`apiResource()` omits the HTML-oriented `create` and `edit` routes. It does not itself add an `/api` prefix; that prefix comes from the application's API route configuration. + +## Organize Controllers Around Resources + +As a general default, organize each controller around one resource and use Laravel's standard resource actions: `index`, `show`, `create`, `store`, `edit`, `update`, and `destroy`. This keeps routes predictable and prevents controllers from accumulating unrelated behavior. + +When a controller needs a custom action such as `publish`, `approve`, or `archive`, first consider whether that behavior represents a separate resource. A focused resource controller gives the behavior its own authorization, validation, and middleware boundary. + +Custom action on the primary controller: + +```php +Route::post('/podcasts/{podcast}/publish', [PodcastController::class, 'publish']); +``` + +The published podcast modeled as a resource: + +```php +Route::post('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'store']) + ->name('published-podcasts.store'); + +Route::delete('/published-podcasts/{podcast}', [PublishedPodcastController::class, 'destroy']) + ->name('published-podcasts.destroy'); +``` + +```php +class PublishedPodcastController extends Controller +{ + public function store(Podcast $podcast): RedirectResponse + { + $podcast->publish(); + + return back(); + } + + public function destroy(Podcast $podcast): RedirectResponse + { + $podcast->unpublish(); + + return back(); + } +} +``` + +Treat a custom verb as a design signal, not proof that another controller is required. Use query parameters for simple filtering, and keep an explicit action route when modeling the operation as a resource would obscure the domain or conflict with established project conventions. + +## Keep Controllers Focused on HTTP Concerns + +Controllers should coordinate HTTP input, authorization, validation, an application operation, and the response. Extract substantial or reusable business logic, but do not introduce an action or service merely to satisfy an arbitrary line limit. + +```php +public function store(StorePostRequest $request, CreatePostAction $create): RedirectResponse +{ + $post = $create->execute($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request can perform validation and authorization before the controller runs. Do not repeat its rules in the controller. Keep simple, endpoint-specific validation inline when extraction would not improve reuse or clarity; see the validation rules for detailed guidance. diff --git a/.claude/skills/laravel-best-practices/rules/scheduling.md b/.claude/skills/laravel-best-practices/rules/scheduling.md new file mode 100644 index 0000000..4c25134 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/scheduling.md @@ -0,0 +1,61 @@ +# Task Scheduling Best Practices + +## Prevent Unwanted Overlap + +Use `withoutOverlapping()` when a second run must not begin while the previous run holds the lock. This is appropriate for variable-duration tasks that are not safe to run concurrently. + +```php +Schedule::command('reports:generate') + ->everyFifteenMinutes() + ->withoutOverlapping(30); +``` + +The optional value is the lock expiration time in minutes, not the task timeout. Choose it carefully: the default is 24 hours, stale locks can be cleared with `php artisan schedule:clear-cache`, and an expiration that is too short can permit overlap while the first task still runs. The task itself should still tolerate retries and partial execution where practical. + +## Run a Task on One Server + +Use `onOneServer()` when only one scheduler node should run an eligible task. Scheduler nodes must use the same default cache store, and that store must support atomic locks. Supported stores include `database`, `memcached`, `dynamodb`, and `redis`. + +```php +Schedule::command('billing:charge')->daily()->onOneServer(); +``` + +Name scheduled closures before applying `onOneServer()`, especially when scheduling the same closure with different parameters, so each task has a distinct lock identity. + +## Run Eligible Commands in the Background + +Tasks due at the same time run sequentially by default. Use `runInBackground()` when an independent, long-running scheduled command should not delay later tasks. + +```php +Schedule::command('analytics:process')->hourly()->runInBackground(); +``` + +Laravel restricts `runInBackground()` to tasks scheduled with `command()` and `exec()`; it is not available for scheduled closures. Ensure background processes have appropriate logging and failure monitoring. + +## Restrict Tasks by Environment + +Use `environments()` when a task should run only in named application environments. Treat this as an operational safeguard, not an authorization control. + +```php +Schedule::command('billing:charge') + ->monthly() + ->environments(['production']); +``` + +## Group Shared Configuration + +Use schedule groups when several tasks genuinely share frequency or constraints. + +```php +Schedule::daily() + ->onOneServer() + ->timezone('America/New_York') + ->group(function () { + Schedule::command('emails:send --force'); + Schedule::command('emails:prune'); + }); +``` + +## Bound Work Inside the Task + +The scheduler does not provide a `takeUntilTimeout()` event method or terminate arbitrary tasks at a deadline. Bound work in the command or job itself by processing finite chunks, checking a deadline, or dispatching queue jobs with suitable timeouts. Use operating-system or process controls when hard termination is required. diff --git a/.claude/skills/laravel-best-practices/rules/security.md b/.claude/skills/laravel-best-practices/rules/security.md new file mode 100644 index 0000000..57b57f0 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/security.md @@ -0,0 +1,156 @@ +# Security Best Practices + +## Control Mass Assignment + +Define `$fillable` when a model is populated from request-derived arrays, or deliberately guard attributes by another consistent model convention. Laravel models guard all attributes by default; `$guarded = []` opts out of that protection. + +```php +class User extends Model +{ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; +} +``` + +Do not pass untrusted request data to a model with `$guarded = []`. Mass-assignment protection controls which attributes `create()`, `fill()`, and `update()` may set; it does not validate values or authorize the operation. + +## Authorize Protected Actions + +Use policies, gates, or form request authorization for actions that depend on the current user's permissions. Authentication alone does not establish permission, and validation is not authorization. + +```php +public function update(UpdatePostRequest $request, Post $post): RedirectResponse +{ + Gate::authorize('update', $post); + + $post->update($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +Authorization may instead live in the form request: + +```php +public function authorize(): bool +{ + return $this->user()?->can('update', $this->route('post')) ?? false; +} +``` + +Public actions intentionally available to everyone do not need a redundant authorization check. + +## Bind Query Parameters + +Use Eloquent, the query builder, or explicit bindings instead of interpolating untrusted values into Structured Query Language (SQL). Bindings protect values, not identifiers such as column names or sort directions; map user-selected identifiers to an allow-list. + +Incorrect: + +```php +DB::select("SELECT * FROM users WHERE name = '{$request->name}'"); +``` + +Correct: + +```php +User::where('name', $request->name)->get(); +User::whereRaw('LOWER(name) = ?', [$request->string('name')->lower()->toString()])->get(); +``` + +## Escape Output in Its Context + +Blade's `{{ }}` syntax HTML-escapes output. Use `{!! !!}` only for content that has been sanitized for the exact HTML context in which it is rendered. Escaping rules differ for HTML, URLs, JavaScript, and Cascading Style Sheets. + +Incorrect for untrusted content: + +```blade +{!! $user->bio !!} +``` + +Correct: + +```blade +{{ $user->bio }} +``` + +## Apply Cross-Site Request Forgery Protection + +Include `@csrf` in state-changing Blade forms handled by Laravel's `web` middleware. Routes intentionally excluded from cross-site request forgery (CSRF) verification, such as validated third-party webhooks, need their own authenticity check. + +```blade +
+ @csrf + +
+``` + +Inertia applications commonly use Axios, which returns the encrypted `XSRF-TOKEN` cookie in the `X-XSRF-TOKEN` header. Confirm equivalent configuration when using another HTTP client. Do not disable CSRF protection merely to fix a token mismatch. + +## Rate Limit Sensitive Endpoints + +Apply suitable rate limits to login attempts, password recovery, verification messages, and expensive or abuse-prone application programming interface (API) routes. Choose the limiter key deliberately; an Internet Protocol (IP) address alone can unfairly group users behind a shared network, while an account identifier alone can enable targeted denial of service. + +```php +RateLimiter::for('login', function (Request $request) { + return Limit::perMinute(5)->by(Str::transliterate( + Str::lower($request->string('email')).'|'.$request->ip() + )); +}); + +Route::post('/login', LoginController::class)->middleware('throttle:login'); +``` + +Rate limiting reduces abuse; it does not replace authentication, authorization, or upstream denial-of-service protection. + +## Validate and Store Uploads Safely + +Validate expected content type, dimensions where relevant, and size. Laravel's `mimes` rule reads the file contents and guesses a Multipurpose Internet Mail Extensions (MIME) type corresponding to the listed extensions; it does not validate the user-assigned filename extension. The `extensions` rule checks that extension and should not be used by itself. + +```php +public function rules(): array +{ + return [ + 'avatar' => ['required', 'image', 'mimes:jpg,jpeg,png,webp', 'max:2048'], + ]; +} +``` + +Use Laravel's storage methods to generate a filename, and store untrusted files outside a publicly executable location. Public files can require additional controls, such as image re-encoding, content-disposition headers, and explicit blocking of active formats. + +```php +$path = $request->file('avatar')->store('avatars'); +``` + +## Keep Secrets Out of Application Code + +Do not commit populated environment files or hard-code credentials. Read environment variables in configuration files, then use `config()` in application code so configuration caching works correctly. See the configuration rules for encrypted environment files and external secret stores. + +## Audit Dependencies + +Run `composer audit` regularly and in continuous integration. Review findings for exploitability and update or mitigate affected packages promptly. + +```bash +composer audit +``` + +## Encrypt Sensitive Attributes When Appropriate + +Use an `encrypted` cast for sensitive values that must be recoverable, and use `$hidden` to omit them from array and JavaScript Object Notation (JSON) serialization. Hidden attributes remain accessible in PHP, and encryption does not replace access control. Encrypted values cannot be meaningfully queried and should use a `TEXT` or larger column because ciphertext length is variable. + +```php +class Integration extends Model +{ + protected $hidden = ['api_key', 'api_secret']; + + protected function casts(): array + { + return [ + 'api_key' => 'encrypted', + 'api_secret' => 'encrypted', + ]; + } +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/style.md b/.claude/skills/laravel-best-practices/rules/style.md new file mode 100644 index 0000000..3f3f14f --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/style.md @@ -0,0 +1,110 @@ +# Convention and Style Best Practices + +## Follow Project Naming Conventions + +Prefer Laravel's conventions in new code, but preserve an established project convention unless a coordinated rename is worthwhile. + +| Element | Convention | Example | +| --- | --- | --- | +| Controller | Singular resource name | `ArticleController` | +| Model | Singular StudlyCase | `User` | +| Table | Plural snake_case | `article_comments` | +| Pivot table | Singular model names in alphabetical order, in snake_case | `article_user` | +| Column | snake_case | `meta_title` | +| Conventional foreign key | Singular model name plus `_id`, in snake_case | `article_id` | +| Resource URI | Plural resource | `articles/1` | +| Route name | Dotted segments; snake_case within a segment when needed | `users.show_active` | +| Method | camelCase | `getAll` | +| Variable | camelCase | `$articlesWithAuthor` | +| Collection | Descriptive and plural | `$activeUsers` | +| Object | Descriptive and singular | `$activeUser` | +| View | kebab-case | `show-filtered.blade.php` | +| Configuration file | snake_case | `google_calendar.php` | +| Enumeration | Singular StudlyCase | `UserType` | + +## Prefer Clear, Idiomatic Syntax + +Use Laravel helpers and query methods when they communicate intent more directly. Do not shorten code when the result is ambiguous or loses useful type information. + +| More verbose | Idiomatic alternative | +| --- | --- | +| `Session::get('cart')` | `session('cart')` | +| `$request->session()->get('cart')` | `session('cart')` | +| `return Redirect::back()` | `return back()` | +| `Carbon::now()` | `now()` | +| `->where('column', '=', 1)` | `->where('column', 1)` | +| `->orderBy('created_at', 'desc')` | `->latest()` | +| `->orderBy('created_at', 'asc')` | `->oldest()` | +| `->first()?->name` | `->value('name')` when only that value is needed | + +Use typed request accessors such as `$request->string()`, `$request->integer()`, and `$request->boolean()` when their coercion matches the operation. + +## Use Utilities When They Clarify Intent + +Laravel's `Str`, `Arr`, `Number`, and `Uri` utilities provide expressive operations and framework-consistent behavior. Prefer them when they are clearer or safer than an equivalent PHP operation, not as an unconditional replacement for every built-in function. + +```php +$slug = Str::slug($title); +$short = Str::limit($text, 100); +$class = class_basename(User::class); +$result = Str::of($input)->trim()->replace('_', '-')->lower(); +``` + +Use `Arr` for dot notation and common transformations: + +```php +$name = Arr::get($array, 'user.name', 'default'); +$public = Arr::only($attributes, ['name', 'email']); +``` + +Use `Number` for localized display formatting rather than values that will be stored or calculated: + +```php +Number::format(1000000); +Number::currency(1500, 'USD'); +Number::fileSize(1024 * 1024); +``` + +Use `Uri` when constructing or transforming a uniform resource identifier (URI) benefits from a structured API: + +```php +$uri = Uri::of('https://example.com/search') + ->withQuery(['q' => 'laravel', 'page' => 1]); +``` + +Check the documentation for the Laravel version supported by the project before using newer utility classes or methods. + +## Keep Presentation Code Maintainable + +Prefer the project's asset pipeline, components, and existing conventions for substantial JavaScript and Cascading Style Sheets (CSS). Small page-specific scripts or styles can be reasonable in Blade layouts or stacks; avoid mixing large behavior and style blocks into templates. + +Pass server data with an encoding mechanism appropriate to its context. For example, Blade's `Js::from()` safely formats data for JavaScript: + +```blade + +``` + +Data attributes are useful for small scalar values, but serializing a large model into an attribute can expose unnecessary fields and complicate escaping. + +## Write Comments That Explain Why + +Prefer clear names and small units of code over comments that merely restate an operation. Add concise comments for non-obvious constraints, tradeoffs, workarounds, regular expressions, or external behavior that the code cannot express by itself. Keep comments accurate when behavior changes. + +Unhelpful: + +```php +// Check whether the query has joins. +if (count((array) $builder->getQuery()->joins) > 0) { + // ... +} +``` + +Clearer: + +```php +if ($this->hasJoins()) { + // ... +} +``` diff --git a/.claude/skills/laravel-best-practices/rules/validation.md b/.claude/skills/laravel-best-practices/rules/validation.md new file mode 100644 index 0000000..37ce6c5 --- /dev/null +++ b/.claude/skills/laravel-best-practices/rules/validation.md @@ -0,0 +1,89 @@ +# Validation and Forms Best Practices + +## Extract Validation When It Improves the Boundary + +Use a form request when validation or authorization is substantial, reused, or clearer outside the controller. Inline `$request->validate()` remains appropriate for a small, endpoint-specific rule set. + +```php +public function store(StorePostRequest $request): RedirectResponse +{ + $post = Post::create($request->validated()); + + return redirect()->route('posts.show', $post); +} +``` + +A form request's `authorize()` method can enforce access to the operation. Validation establishes the shape and values of input; it does not itself authorize the user. + +## Prefer Readable Rule Syntax + +Array syntax composes cleanly with rule objects and avoids delimiter issues. Prefer it in new code when it improves readability, while following a consistent local style. + +```php +'email' => ['required', 'email', Rule::unique('users')], +``` + +String syntax remains valid for simple rules: + +```php +'email' => 'required|email|unique:users', +``` + +## Use Only Intended Validated Data + +Use `validated()` or `safe()` instead of `$request->all()` when passing request data onward. Then select the fields intended for the operation when the validation rules also cover control fields or nested data. + +Unsafe: + +```php +Post::create($request->all()); +``` + +Preferred: + +```php +$post = Post::create($request->safe()->only(['title', 'body'])); +``` + +Validated data is not automatically safe for mass assignment. Keep model `$fillable` or `$guarded` rules aligned with the operation, and never add a sensitive attribute to validation merely to make mass assignment convenient. + +## Express Conditional Rules Clearly + +Use conditional rules such as `Rule::when()`, `required_if`, or `exclude_unless` when they make the condition explicit. Choose the simplest form that remains easy to test. + +```php +'company_name' => [ + 'string', + 'max:255', + Rule::when( + $this->input('account_type') === 'business', + ['required'], + ['nullable'], + ), +], +``` + +## Add Cross-Field Validation After Base Rules + +Use a form request's `after()` method for validation that depends on multiple fields or application state. Avoid expensive queries when prerequisite fields have already failed validation. + +```php +public function after(): array +{ + return [ + function (Validator $validator) { + if ($validator->errors()->hasAny(['product_id', 'quantity'])) { + return; + } + + $stock = Product::find($this->integer('product_id'))?->stock; + + if ($stock !== null && $this->integer('quantity') > $stock) { + $validator->errors()->add('quantity', 'Not enough stock.'); + } + }, + ]; +} +``` + +Validation against mutable state does not prevent a race between validation and persistence. Enforce inventory, uniqueness, and similar invariants with database constraints, atomic updates, or a database transaction as appropriate. diff --git a/.claude/skills/tailwindcss-development/SKILL.md b/.claude/skills/tailwindcss-development/SKILL.md new file mode 100644 index 0000000..7e3cd2a --- /dev/null +++ b/.claude/skills/tailwindcss-development/SKILL.md @@ -0,0 +1,96 @@ +--- +name: tailwindcss-development +description: "Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS." +license: MIT +metadata: + author: laravel +--- + +# Tailwind CSS Development + +## Documentation + +Use `search-docs` for detailed Tailwind CSS v4 patterns and documentation. + +## Basic Usage + +- Use Tailwind CSS classes to style HTML. Check and follow existing Tailwind conventions in the project before introducing new patterns. +- Offer to extract repeated patterns into components that match the project's conventions (e.g., Blade, JSX, Vue). +- Consider class placement, order, priority, and defaults. Remove redundant classes, add classes to parent or child elements carefully to reduce repetition, and group elements logically. + +## Tailwind CSS v4 Specifics + +- Always use Tailwind CSS v4 and avoid deprecated utilities. +- `corePlugins` is not supported in Tailwind v4. + +### CSS-First Configuration + +In Tailwind v4, configuration is CSS-first using the `@theme` directive — no separate `tailwind.config.js` file is needed: + + +```css +@theme { + --color-brand: oklch(0.72 0.11 178); +} +``` + +### Import Syntax + +In Tailwind v4, import Tailwind with a regular CSS `@import` statement instead of the `@tailwind` directives used in v3: + + +```diff +- @tailwind base; +- @tailwind components; +- @tailwind utilities; ++ @import "tailwindcss"; +``` + +### Replaced Utilities + +Tailwind v4 removed deprecated utilities. Use the replacements shown below. Opacity values remain numeric. + +| Deprecated | Replacement | +|------------|-------------| +| bg-opacity-* | bg-black/* | +| text-opacity-* | text-black/* | +| border-opacity-* | border-black/* | +| divide-opacity-* | divide-black/* | +| ring-opacity-* | ring-black/* | +| placeholder-opacity-* | placeholder-black/* | +| flex-shrink-* | shrink-* | +| flex-grow-* | grow-* | +| overflow-ellipsis | text-ellipsis | +| decoration-slice | box-decoration-slice | +| decoration-clone | box-decoration-clone | + +## Spacing + +Use `gap` utilities instead of margins for spacing between siblings: + + +```html +
+
Item 1
+
Item 2
+
+``` + +## Dark Mode + +If existing pages and components support dark mode, new pages and components must support it the same way, typically using the `dark:` variant: + + +```html +
+ Content adapts to color scheme +
+``` + +## Common Pitfalls + +- Using deprecated v3 utilities (bg-opacity-*, flex-shrink-*, etc.) +- Using `@tailwind` directives instead of `@import "tailwindcss"` +- Trying to use `tailwind.config.js` instead of CSS `@theme` directive +- Using margins for spacing between siblings instead of gap utilities +- Forgetting to add dark mode variants when the project uses dark mode diff --git a/.claude/skills/testing-best-practices/SKILL.md b/.claude/skills/testing-best-practices/SKILL.md new file mode 100644 index 0000000..17a781b --- /dev/null +++ b/.claude/skills/testing-best-practices/SKILL.md @@ -0,0 +1,57 @@ +--- +name: testing-best-practices +description: "Laravel test design and review. Use when selecting coverage, naming or structuring tests, choosing assertions or test data, isolating dependencies, testing HTTP or security boundaries, improving suite performance, or reviewing test value. Use framework guidance or search-docs for Pest and PHPUnit syntax." +license: MIT +metadata: + author: laravel +--- + +# Testing Best Practices + +This skill provides rules for designing Laravel tests. Each rule file explains what to do and why. Use `search-docs` for Laravel API syntax. Fetch `https://docs.phpunit.de/en/13.3/` for PHPUnit API syntax. +This project uses PHPUnit. Follow the corresponding guidance in each rule. + +## Consistency First + +Read nearby tests before you choose syntax and organization. + +A pattern repeated throughout the project is a convention, and project conventions take precedence over this skill. Follow them and give new tests the same structure. + +These rules govern the tests you write now. An existing test that follows a project convention is not defective merely because it conflicts with this skill. Do not delete or rewrite it. If the convention has drawbacks, explain them and let the user decide. + +## What to Test + +Read this section before you write a test. + +- Test observable behavior and application contracts. A test must pass after an implementation change if the behavior stays the same. +- Cover every changed decision and each applicable high-value failure mode. A decision is a branch, a validation, a calculation, or an authorization. +- Exercise declarations through behavior instead of repeating their text. +- Leave framework behavior to framework tests. Testing project configuration is not testing the framework. A constrained relationship, cast, scope, or validation rule belongs to this project. +- Keep every test that can detect a distinct defect. When two tests detect the same defect, trim the higher-layer test to one case and report the duplication. Do not delete an existing test. +- Write a feature test first. Write a unit test only for logic that does not use the framework. +- Write a feature test for every behavior reachable through a request. Real-browser tests require `laravel/dusk` and a browser download, neither of which this project installs. Mention the package only if the user asks for a real-browser test. +- Use the test tools that the project installs. Add a new test dependency, plugin, or browser only after the user asks for it. + +## How to Apply + +1. Read the code under test. Read the tests in the same directory. Identify every decision in the code. +2. Select every applicable branch in the rule index. Read every selected rule file. +3. Report each defect in the code before you write a test. Examples are a method with no body, a policy that no action calls, and a write action with no validation. Test the actual behavior. Report the defect to the user. +4. Write the tests. Run the smallest set of tests that covers the change. The tests must pass. +5. Check every applicable item in `rules/review.md` and every selected rule file. Resolve every mismatch before completion. + +## Rule Index + +Most changes need more than one rule file. + +| Subject | Rule File | +| --- | --- | +| Test framework features that may already do the work | [`rules/finding-features.md`](rules/finding-features.md) | +| File layout, test names, and groups | [`rules/naming.md`](rules/naming.md) | +| Arrange-act-assert and choosing the correct assertion | [`rules/assertions.md`](rules/assertions.md) | +| Endpoint coverage, authentication, authorization, tenant isolation, validation, and browser tests | [`rules/endpoint-tests.md`](rules/endpoint-tests.md) | +| Factories, test data ownership, and repeated input values | [`rules/test-data.md`](rules/test-data.md) | +| Fakes, mocks, outbound HTTP, time, randomness, and databases | [`rules/isolation.md`](rules/isolation.md) | +| Escaping, injection, cross-tenant access, and privilege checks | [`rules/security.md`](rules/security.md) | +| Environment and CI settings for a slow suite | [`rules/performance.md`](rules/performance.md) | +| Reviewing a test or suite | [`rules/review.md`](rules/review.md) | diff --git a/.claude/skills/testing-best-practices/rules/assertions.md b/.claude/skills/testing-best-practices/rules/assertions.md new file mode 100644 index 0000000..622f191 --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/assertions.md @@ -0,0 +1,60 @@ +# Assertions + +## Arrange, Act, Assert + +Write each test in three parts: setup, one action, and assertions. Put one blank line between them so readers can identify each part without comments. + +Keep each test self-contained. Do not use values created by another test. + +## How to Find the Correct Assertion + +First identify the subject of the check, then find an assertion designed for it. A subject-specific assertion identifies the incorrect value when the test fails. + +1. Search Laravel's assertions for framework subjects such as responses, the database, sessions, models, queues, events, mail, and notifications. +2. Fetch `https://docs.phpunit.de/en/13.3/assertions.html` for the assertions of PHPUnit for a plain value, a type, a format, or a shape. +3. Build the check by hand only if no assertion exists for the subject. +4. Confirm the name in the documentation before you use it. Do not write an assertion that you did not confirm. + +Use the assertion in this table for each subject. + +| Subject | Assertion to use | +| --- | --- | +| A return value, the state of an object, or a transformation of a value | `assertSame()`, or the assertion for the type | +| An HTTP status, JSON, a session, or Inertia | a Laravel response assertion | +| The state in the database | a Laravel database assertion | +| The existence of a model | `assertModelExists($model)` rather than `assertDatabaseHas('users', ['id' => $user->id])` | + +Use `assertSame()` and not `assertEquals()`, because `assertSame()` also compares the type. + +Assert each fact once. Do not assert a 200 status before `assertSee`, because `assertSee` already shows that the page rendered. + +## Named Response Assertions + +Use a named response assertion, such as `assertNotFound()`, rather than `assertStatus(404)`. A failure then identifies the broken contract. Laravel provides named assertions for commonly tested status codes. + +Group the assertions for one subject together. Start a new group when the subject changes. + +## Assert a Known Value + +Write the expected value in the test, or calculate the expected value by a different method. Do not calculate the expected value with the logic of the implementation, because the test then passes when that logic is wrong. + +```php +// The test calculates the value with the logic of the implementation... +$expected = now()->subHours(24)->floorSeconds(30)->toJson(); +$this->assertSame($expected, $from); + +// The test sets a fixed input and asserts a known value... +$this->travelTo('2025-01-01 00:00:00'); +$this->assertSame('2024-12-31T00:00:00.000000Z', $from); +``` + +## Assert the Complete Result + +A status code is not the complete result of a write operation. Assert each of the following if the operation changes it: + +- The response or the return value. +- The state in the database. +- The jobs and the events that the operation dispatches. +- The notifications and the mail that the operation sends. + +On the failure path, assert that the operation makes none of these changes. A test that asserts only `assertOk()` passes even when the application saves no record. diff --git a/.claude/skills/testing-best-practices/rules/endpoint-tests.md b/.claude/skills/testing-best-practices/rules/endpoint-tests.md new file mode 100644 index 0000000..16add3e --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/endpoint-tests.md @@ -0,0 +1,47 @@ +# Endpoint Tests + +## How to Write the Test + +Fetch `https://laravel.com/framework/docs/http-tests` for the request helpers, the authentication helpers, and the response assertions. Confirm the name before you use it, and do not guess an assertion. + +Choose an assertion based on the subject of the check: the status, a header, a redirect, the JSON body, the session, a validation error, or the view. Laravel provides a named assertion for each subject that identifies the incorrect value. + +## Endpoint Coverage + +Write a test for each applicable case: + +- The request has missing or invalid authentication. +- The request comes from a different tenant, team, or organization. +- The user has an insufficient role or permission. +- The request does not satisfy a route or scope constraint. +- The request fails the validation. +- The request is valid. Assert both the response and the persisted state. + +Assert the application's actual behavior rather than a generic status code. An API returns `401` for a missing or invalid token, while a browser endpoint redirects to the sign-in route. + +## Tenant Isolation + +Assert the status code returned for a cross-tenant request. Use `404` rather than `403` when one tenant must not learn that another tenant's record exists, because `403` confirms its existence. + +## Test Authorization at the Policy Level + +An HTTP test shows that the endpoint performs authorization. It cannot identify which mechanism refused the request because middleware, a policy, and a call to `abort()` can all return `403`. + +- Assert the complete matrix of the permissions against the policy or the gate. A failure then names the rule that is not correct. +- Write one HTTP test for one refused role, which shows that the endpoint calls the authorization. +- Use the helper of the project that asserts the ability and the arguments of the gate, if such a helper exists. + +## Testing Validation + +- Write one test for each validation rule when each failure represents a separate contract. +- Write one test with an empty payload to assert several required fields together. +- Assert the text of the message that the user gets. A message that is present but wrong is a defect. +- Use a data provider with the `#[DataProvider]` attribute for input values that need the same setup and the same assertions. Use the `#[TestWith]` attribute for a small set of values. + +Send an input value that is not valid through the application, and assert the error. Do not assert that an array of rules contains a string, because that assertion tests the declaration and not the behavior. Use such an assertion only for a rule that no request can reach, and write the reason in the test. + +### Which Layer Owns Which Case + +The rule-class test owns the matrix of values that pass and fail. The endpoint test proves that the endpoint applies the rule and that the user receives the message. + +When both tests contain the matrix, move it to the rule-class test and retain one case in the endpoint test. Never remove the last case, because the rule-class test still passes if the request omits the rule. The same division applies to policies, scopes, and other classes called by a request. diff --git a/.claude/skills/testing-best-practices/rules/finding-features.md b/.claude/skills/testing-best-practices/rules/finding-features.md new file mode 100644 index 0000000..a523282 --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/finding-features.md @@ -0,0 +1,33 @@ +# How to Find Test Framework Features + +PHPUnit and Laravel provide features for most testing needs. Find an existing feature before implementing the behavior by hand. + +- Give `search-docs` the capability you need rather than the name of a method you remember. It returns Laravel testing documentation for the installed version. +- Fetch `https://phpunit.de/documentation.html` for version-specific PHPUnit attributes, assertions, and command-line options. +- If a search returns no results, tell the user that the installed version does not provide the feature. Do not write an API that you have not confirmed. + +Search for a feature in this table before you write the code by hand. + +| Work that you need | Term to search for | +| --- | --- | +| Run one test method with many input values | data provider, `#[DataProvider]`, `#[TestWith]` | +| Run one test only after another test passes | `#[Depends]` | +| Select or skip a set of tests in one run | `#[Group]`, `--group`, `--exclude-group` | +| Skip a test on a version or on a missing extension | `#[RequiresPhp]`, `#[RequiresPhpExtension]` | +| Find a test that depends on the order of the run | `--order-by=random` | +| Reduce the time of a slow suite | ParaTest, `--cache-result` | +| Stop the run at the first failure while you debug | `--stop-on-failure`, `--filter` | + +## Built-in Laravel Assertion Methods + +Laravel provides assertions for each part of the framework. Fetch `https://laravel.com/framework/docs/testing` for the complete list, and search for an assertion before building a check by hand. Examples include `assertDatabaseHas()`, `assertModelExists()`, `assertSoftDeleted()`, response assertions such as `assertRedirectToRoute()` and `assertJsonPath()`, and fake assertions such as `Queue::assertPushed()` and `Notification::assertSentTo()`. + +A hand-built check fails with `false is not true`, which identifies nothing. A framework assertion names the incorrect table, value, or response, so the failure indicates what to fix. + +```php +// The failure says that false is not true. Instead of this... +$this->assertTrue(User::where('email', 'taylor@laravel.com')->exists()); + +// Use this... the failure names the table and the attributes that it did not find... +$this->assertDatabaseHas('users', ['email' => 'taylor@laravel.com']); +``` diff --git a/.claude/skills/testing-best-practices/rules/isolation.md b/.claude/skills/testing-best-practices/rules/isolation.md new file mode 100644 index 0000000..cbfd714 --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/isolation.md @@ -0,0 +1,52 @@ +# Fakes, Mocks, and Determinism + +Tests that depend on actual time, randomness, sleeping, or network calls can fail for reasons unrelated to the code under test. Control all four. + +## How to Isolate a Dependency + +Fetch `https://laravel.com/framework/docs/mocking` for Laravel's fakes, facade doubles, and fake assertions. Confirm each name before using it. + +Identify the dependency, then choose the first applicable option. A framework fake preserves the real code path, while a mock replaces the dependency. + +1. Always use framework fakes for facades such as events, queues, mail, notifications, storage, the HTTP client, time, and sleep. +2. Use a developer-defined fake implementation of a service if the application provides one. +3. Use a mock for a container-resolved contract only when the real implementation leaves the process or is nondeterministic. +4. Use the real implementation for everything else, including the database. + +## Framework Fakes + +- Create each fake inside the test method that needs it. Do not create fakes in `setUp()`. +- Pass class names to `Event::fake()` and `Queue::fake()` when you know which classes the code dispatches. A fake without class names can hide an unexpected dispatch. +- Use a fake without class names only when the test asserts the complete result, including a call to `assertNothingPushed()`. +- Write one assertion for each fake. The assertion states that the code dispatches the item, or that the code does not dispatch the item. +- Assert the data of a job or of an event if that data is part of the behavior. +- Use `Exceptions::fake()` to assert that the application reports the correct exception. Do not use `withoutExceptionHandling()`, because it changes the response under test. + +Create prerequisite factory records before calling `Event::fake()`. Factories use model events, such as a `creating` hook that generates a UUID, and a fake without class names suppresses those events and can produce an invalid model. Call the fake first only when a factory event is under test, and pass that event's class name. + +## Mocking + +Use `shouldReceive()` before the action to declare an expectation. Use `shouldHaveReceived()` after the action for a spy. Use `Mockery::on()` or `withArgs()` if an equality check cannot state the expected argument, such as a check of one field of a value object. + +Use `$this->mock(Contract::class)` to put a mock in the container. Do not build a PHPUnit mock for a class that Mockery can double, because the project uses Mockery. + +## Outbound HTTP Testing + +Call `Http::preventStrayRequests()`. Any request without a matching fake then fails without reaching the network. + +Fake the exact endpoint used by each test. Do not call `Http::fake()` without an endpoint because it accepts unexpected requests and can hide defects. + +## Time and Randomness + +- Freeze the time or move the time in each test that depends on a date, a period, or a timestamp. +- Use the framework helpers `$this->freezeTime()`, `$this->travelTo()`, `$this->travel()`, and `$this->travelBack()`. Do not call `Carbon::setTestNow()`. +- Use `Str::createRandomStringsUsing()` to fix a generated string, if the test asserts an identifier or a slug. +- Use `Sleep::fake()` instead of a real sleep, and assert the sleeps that the code requests. +- Restore the time and the randomness after each test, if the suite does not restore them for every test. + +## Database + +- Run real queries against the real records in the test database. Do not mock the query builder, because the test then asserts the mock. +- Assert the exact keys of `toArray()` if the shape of the serialized model is a contract. The test then fails when the model exposes a new attribute. +- Test application behavior caused by the schema, such as deleting dependent records through a cascade. Do not test the database engine's cascade implementation. +- Use `LazilyRefreshDatabase` instead of `RefreshDatabase`. A test that does not use the database then does not run the migrations. diff --git a/.claude/skills/testing-best-practices/rules/naming.md b/.claude/skills/testing-best-practices/rules/naming.md new file mode 100644 index 0000000..104881d --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/naming.md @@ -0,0 +1,37 @@ +# Naming and Structure + +## File Layout + +- Name each test file `{ClassName}Test.php`. +- Place each test file at the same relative path as the class under test. The class `app/Actions/DeleteTeam.php` gets the test `tests/Unit/Actions/DeleteTeamTest.php`. +- Follow the project's convention for fixture files. If none exists, put fixtures in `tests/Fixtures/` and load them by path. +- Move large literal values out of the test body and into fixture files. + +## Test Class and Methods + +- Extend the base `TestCase` of the project in each test class. +- Give each test method the prefix `test_`, or add the `#[Test]` attribute to the method. Use the convention of the other files in the same directory. + +## Naming Tests + +The name of a test method is a specification. Separate the words with underscores. State the user-visible result and the condition that causes it. + +- Name the behavior, and not the method under test. The file name already gives the class. +- Give the exact status code in the name of a test for an API error. +- Do not write `given`, `when`, or `then` in the name. + +```php +public function test_unauthenticated_request_redirects_to_login(): void { ... } +public function test_returns_401_when_no_token_is_provided(): void { ... } +public function test_valid_payload_creates_record_and_returns_201(): void { ... } +``` + +Use a verb that describes a result, such as `returns`, `renders`, `creates`, `dispatches`, `rejects`, `forbids`, `falls back`, or `does not`. + +Do not write `test_store()`, `test_it_works()`, or `test_validation()`, because none of them gives a result. + +## Grouping + +Write one test class for each class under test. Write a separate test class if one file covers separate actions in a lifecycle, such as `StoreOrderControllerTest` and `UpdateOrderControllerTest`. + +Use the `#[Group]` attribute to mark the tests that a run must select or must skip. Do not use a group to give structure to a file, because a class gives the structure. diff --git a/.claude/skills/testing-best-practices/rules/performance.md b/.claude/skills/testing-best-practices/rules/performance.md new file mode 100644 index 0000000..61e0d72 --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/performance.md @@ -0,0 +1,46 @@ +# Test Suite Performance + +These settings apply to the project and CI, not to individual tests. Read `rules/isolation.md` for choices within a test. + +Fetch `https://docs.phpunit.de/en/13.3/` for PHPUnit options that make test runs faster. +Verify each flag in the documentation before adding it to CI. + +Measure before changing a setting. Find the slow test first, and apply a project-wide setting only after identifying the costly work. + +## Test Environment + +- Set `BCRYPT_ROUNDS=4` in `.env.testing` or in `phpunit.xml`. The default value is 12, and the hash then takes most of the time of each test that signs a user in. +- Disable XDebug. Disable pcov also, unless the run needs the coverage. +- Disable packages that perform work on every request in the test environment. Examples are Pulse, Telescope, and Nightwatch. +- Use the `WithCachedConfig` and `WithCachedRoutes` traits, so the run does not parse the configuration and the routes for every test. +- Call `withoutVite()`, or `withoutMix()`, so the framework does not resolve a built asset. + +## Global Fakes + +Put these three calls in the `setUp()` of the base `TestCase` of the project: + +- `Http::preventStrayRequests()`, because one request that reaches the network can slow the suite. This catches requests made through Laravel's HTTP client. Check direct Guzzle and cURL usage separately. +- `Sleep::fake(syncWithCarbon: true)`, so a retry and a backoff do not sleep. +- `Exceptions::fake()`, so the suite does not report an exception to an external service. + +## How to Run the Suite in Parallel + +Run `php artisan test --parallel`, which uses ParaTest, to spread tests across the machine's CPU cores. Add `--processes=N` if the default count is unsuitable for the machine or CI. + +A parallel run gives each process a separate database. Tests must meet these conditions; a test that fails only in parallel breaks one of them: + +- The test creates each record that it reads. It does not read a record that another test creates. +- The test does not depend on the order of the run. +- The test does not share a file, a cache key, or a queue with another test. Give each process a separate name for such a resource. + +## How to Find a Slow Test + +Run `php artisan test --profile` to list the slowest tests. Start with the ten slowest tests, because the same cause often applies to the complete suite. + +If the cause of a slow test is unclear, add an event listener or temporary log entry to identify its work. + +## Common Errors + +- The run loads XDebug for a test that does not need it. +- `BCRYPT_ROUNDS` keeps the default value, because the project has no `.env.testing`. +- The code under test calls the real `sleep()`, and `Sleep::fake()` then does not help. diff --git a/.claude/skills/testing-best-practices/rules/review.md b/.claude/skills/testing-best-practices/rules/review.md new file mode 100644 index 0000000..92595f5 --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/review.md @@ -0,0 +1,42 @@ +# Reviewing Tests + +Check every item in this file. A passing test may still provide no value. For each test, identify the defect it would catch. + +Report each finding. Do not delete or rewrite a test without the user's approval. When an issue appears throughout the suite as a convention, report the pattern once rather than every affected file. + +## Test Value + +- [ ] Each test covers observable behavior or an application contract, and passes after a change to the implementation that keeps the behavior. +- [ ] Each tested declaration is exercised through behavior, and no test asserts the behavior of the framework. A test of what this project configures, such as a relation with a constraint, a cast, or a scope, belongs to this project. +- [ ] Each test detects a distinct defect that no other test covers. A duplicate shrinks at the higher layer to the one case that proves the wiring. +- [ ] Every changed decision and each applicable high-value failure mode has coverage. + +## Names and Structure + +- [ ] Each file has the name `{ClassName}Test.php` and the relative path of the class under test. +- [ ] Each name states a result, the condition that causes it, and the status code for an API error. +- [ ] Each test class extends the base `TestCase` of the project, and each file uses either the prefix `test_` or the `#[Test]` attribute consistently. + +## Coverage + +- [ ] HTTP tests cover authentication, authorization, role, scope, and validation when applicable. +- [ ] A request for a record of a different tenant gets a status code that does not confirm that the record exists. +- [ ] The complete permission matrix belongs in policy tests, not controller tests. +- [ ] Each validation rule has one test that asserts the user-visible message. When a unit test owns a matrix, reduce duplicate higher-level coverage to one case rather than deleting it. +- [ ] Rendered user input and each dynamic part of a query have a security test. + +## Data and Determinism + +- [ ] Each test creates its mutable records directly or through a helper that it calls, and every created record arranges the behavior or supports an assertion. +- [ ] `setUp()` holds configuration only. +- [ ] Each factory state and each relationship gives the meaning of the data. +- [ ] Each call to `make()` is in a test that does not need the database. +- [ ] Time, randomness, sleep, and outbound HTTP are controlled. +- [ ] Each test passes alone, and passes in the complete suite in any order. + +## Assertions + +- [ ] Each expected value is a known value, and the test does not calculate the value with the logic of the implementation. +- [ ] Each test of a write operation asserts the response, the state in the database, and the side effects. +- [ ] Each fake has one assertion, and gives the class names unless the test asserts the complete result. +- [ ] Each group of assertions stays on one subject, and each comparison uses `assertSame()`. diff --git a/.claude/skills/testing-best-practices/rules/security.md b/.claude/skills/testing-best-practices/rules/security.md new file mode 100644 index 0000000..e9097d9 --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/security.md @@ -0,0 +1,27 @@ +# Security Tests + +Test each security boundary where user input affects authorization, rendered output, or query construction. A defect at such a boundary can be difficult to detect because the feature may continue to work. + +Write a test for each of these cases: + +- **Cross-tenant access.** Request a record of a different tenant, team, or organization. Read `rules/endpoint-tests.md` for why the response should possibly be `404` rather than `403`. +- **Each unprivileged role.** Use a data provider over the roles that the endpoint must refuse. +- **Escaping user-provided content.** Test escaping in HTML and mail. Include names and every free-text field a template renders. Assert that dangerous characters are escaped and the raw value is absent. Do not assert an exact entity for a quote, because Markdown and mail CSS inliners may decode it. +- **Injection into dynamic query components.** Examples include sort columns, filter fields, and sort directions. +- **An unexpected key** in a payload or configuration array. A merge that accepts every key can set an attribute the user must not control. + +```php +public function test_escapes_dangerous_content_in_the_notification(): void +{ + $organization = Organization::factory()->make([ + 'name' => "O'Reilly ", + ]); + + $content = (new QuotaApproaching($organization, 80))->toMail()->render(); + + $this->assertStringContainsString('", $content); +} +``` + +Laravel provides defenses against mass assignment, unauthorized access, and unescaped output. Test that the application applies the appropriate defense to each attribute, route, and template. diff --git a/.claude/skills/testing-best-practices/rules/test-data.md b/.claude/skills/testing-best-practices/rules/test-data.md new file mode 100644 index 0000000..153b45d --- /dev/null +++ b/.claude/skills/testing-best-practices/rules/test-data.md @@ -0,0 +1,68 @@ +# Factories and Test Data + +## Each Test Makes Its Own Data + +Create mutable records inside each test or through a private helper that the test calls. This keeps setup visible and lets each test select its factory state. + +Use `setUp()` only for configuration that applies to every test in the class. Do not create records in it, because its objects remain in memory until the suite ends. + +## Record Construction + +- Use `create()` if the test needs the record in the database. +- Use `make()` only if the test does not need the database. Examples include rendering a notification and testing a value object's behavior. +- Use a named factory state instead of a raw attribute. `User::factory()->unverified()->create()` gives the state meaning; `create(['email_verified_at' => null])` gives only its value. +- Use `for()` or the relationship helper of the project to declare the owner of a record. +- Use `recycle()` if several records must share one parent record. +- Use `sequence()` if several records need different attributes. + +```php +$organization = Organization::factory()->onPlan(BillingPlan::PRO)->create(); + +$environment = Environment::factory()->recycle($organization)->create(); + +$organizations = Organization::factory() + ->count(3) + ->sequence( + ['created_at' => now()->setSeconds(30)], + ['created_at' => now()->setSeconds(1)], + ) + ->create(); +``` + +Create only the records required to arrange the behavior or support an assertion. + +## Data Providers + +Use a data provider when the setup, test body, and assertions remain the same across input values. + +```php +public static function nonAdminRoles(): array +{ + return collect(Role::cases()) + ->reject(fn (Role $role): bool => $role === Role::ADMIN) + ->mapWithKeys(fn (Role $role): array => [$role->value => [$role]]) + ->all(); +} + +#[DataProvider('nonAdminRoles')] +public function test_forbids_roles_other_than_admin(Role $role): void +{ + $this->actingAs(User::factory()->hasOrganization($role)->create()) + ->post('/settings') + ->assertForbidden(); +} +``` + +Declare each data provider method as `public static`. + +Use parameterized tests for: + +- enum cases +- roles and plans +- boundary values +- input values that are invalid in the same way +- input and output value pairs + +Write separate tests if the cases need a different setup, a different behavior, or different assertions. One test function with a branch in the body is two tests in one function. + +Give each data-provider case a key that states the difference. A failure then identifies the case without requiring you to count positions. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9408516 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,20 @@ +node_modules +.devenv/ +vendor/ +storage/logs/* +storage/framework/cache/data/* +database/database.sqlite +public/build +public/hot +.env +.env.* +!.env.example +.github/ +.git/ +.dockerignore +.gitattributes +.gitignore +.gitpod.yml +Makefile +docker-compose.yml +*.md \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6df8428 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 + +[{compose,docker-compose}.{yml,yaml}] +indent_size = 4 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..40bb13b --- /dev/null +++ b/.env.example @@ -0,0 +1,65 @@ +APP_NAME=Laravel +APP_ENV=local +APP_KEY= +APP_DEBUG=true +APP_URL=http://localhost:8000 + +APP_LOCALE=en +APP_FALLBACK_LOCALE=en +APP_FAKER_LOCALE=en_US + +APP_MAINTENANCE_DRIVER=file +# APP_MAINTENANCE_STORE=database + +# PHP_CLI_SERVER_WORKERS=4 + +BCRYPT_ROUNDS=12 + +LOG_CHANNEL=stack +LOG_STACK=single +LOG_DEPRECATIONS_CHANNEL=null +LOG_LEVEL=debug + +DB_CONNECTION=sqlite +# DB_HOST=127.0.0.1 +# DB_PORT=3306 +# DB_DATABASE=laravel +# DB_USERNAME=root +# DB_PASSWORD= + +SESSION_DRIVER=database +SESSION_LIFETIME=120 +SESSION_ENCRYPT=false +SESSION_PATH=/ +SESSION_DOMAIN=null + +BROADCAST_CONNECTION=log +FILESYSTEM_DISK=local +QUEUE_CONNECTION=database + +CACHE_STORE=database +# CACHE_PREFIX= + +MEMCACHED_HOST=127.0.0.1 + +REDIS_CLIENT=phpredis +REDIS_HOST=127.0.0.1 +REDIS_PASSWORD=null +REDIS_PORT=6379 + +MAIL_MAILER=log +MAIL_SCHEME=null +MAIL_HOST=127.0.0.1 +MAIL_PORT=2525 +MAIL_USERNAME=null +MAIL_PASSWORD=null +MAIL_FROM_ADDRESS="hello@example.com" +MAIL_FROM_NAME="${APP_NAME}" + +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +AWS_DEFAULT_REGION=us-east-1 +AWS_BUCKET= +AWS_USE_PATH_STYLE_ENDPOINT=false + +VITE_APP_NAME="${APP_NAME}" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fcb21d3 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +* text=auto eol=lf + +*.blade.php diff=html +*.css diff=css +*.html diff=html +*.md diff=markdown +*.php diff=php + +/.github export-ignore +CHANGELOG.md export-ignore +.styleci.yml export-ignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1cdfae7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +*.log +.DS_Store +.env +.env.backup +.env.production +.phpactor.json +.phpunit.result.cache +/.codex +/.cursor/ +/.idea +/.nova +/.phpunit.cache +/.vscode +/.zed +/auth.json +/node_modules +/public/build +/public/fonts-manifest.dev.json +/public/hot +/public/storage +/storage/*.key +/storage/pail +/vendor +_ide_helper.php +Homestead.json +Homestead.yaml +Thumbs.db +.devenv* +.envrc diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..8c6715a --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "laravel-boost": { + "command": "php", + "args": [ + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..495a6af --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +ignore-scripts=true +audit=true diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..327890b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,167 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version. + +Before relying on a package's API, confirm its installed version: +- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show ` for a single package. +- JS packages: check `package.json` for the installed versions. + +## Skills Activation + +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Use `search-docs` before changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Project Rules + +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. +- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== tests rules === + +# Test Enforcement + +- Test every code change by adding or updating a test. +- Run the affected tests and ensure they pass. +- Test the changed behavior and its important failure modes, but do not add tests beyond them. +- Read the `testing-best-practices` skill before writing tests. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== phpunit/core rules === + +# PHPUnit + +- This project uses PHPUnit. Create tests with `php artisan make:test --phpunit {name}`. +- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`. +- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review. + +## Running Tests + +- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`. +- Rerun a test after each change to it. +- Run `vendor/bin/phpunit` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments. + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..327890b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,167 @@ + +=== foundation rules === + +# Laravel Boost Guidelines + +The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications. + +## Foundational Context + +This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version. + +Before relying on a package's API, confirm its installed version: +- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show ` for a single package. +- JS packages: check `package.json` for the installed versions. + +## Skills Activation + +This project has domain-specific skills available in `**/skills/**`. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck. + +## Conventions + +- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming. +- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`. +- Check for existing components to reuse before writing a new one. + +## Verification Scripts + +- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important. + +## Application Structure & Architecture + +- Stick to existing directory structure; don't create new base folders without approval. +- Do not change the application's dependencies without approval. + +## Frontend Bundling + +- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them. + +## Documentation Files + +- You must only create documentation files if explicitly requested by the user. + +## Replies + +- Be concise in your explanations - focus on what's important rather than explaining obvious details. + +=== boost rules === + +# Laravel Boost + +## Tools + +- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads. +- Use `database-query` to run read-only queries against the database instead of writing raw SQL in tinker. +- Use `database-schema` to inspect table structure before writing migrations or models. +- Use `get-absolute-url` to resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. +- Use `browser-logs` to read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries. + +## Searching Documentation (IMPORTANT) + +- Use `search-docs` before changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again. +- Pass a `packages` array to scope results when you know which packages are relevant. +- Use multiple broad, topic-based queries: `['rate limiting', 'routing rate limiting', 'routing']`. Expect the most relevant results first. +- Do not add package names to queries because package info is already shared. Use `test resource table`, not `filament 4 test resource table`. + +### Search Syntax + +1. Use words for auto-stemmed AND logic: `rate limit` matches both "rate" AND "limit". +2. Use `"quoted phrases"` for exact position matching: `"infinite scroll"` requires adjacent words in order. +3. Combine words and phrases for mixed queries: `middleware "rate limit"`. +4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`. + +## Project Rules + +- This project contains committed, area-grouped rules in `.ai/rules` when that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If `.ai/rules` does not exist, continue without it. +- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo. + +## Artisan + +- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters. +- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`. +- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory. + +## Tinker + +- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code. +- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'` + - Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'` + +=== php rules === + +# PHP + +- Always use curly braces for control structures, even for single-line bodies. +- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private. +- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool` +- Use TitleCase for Enum keys: `FavoritePerson`, `BestLake`, `Monthly`. +- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic. +- Use array shape type definitions in PHPDoc blocks. + +=== deployments rules === + +# Deployment + +- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications. + +=== tests rules === + +# Test Enforcement + +- Test every code change by adding or updating a test. +- Run the affected tests and ensure they pass. +- Test the changed behavior and its important failure modes, but do not add tests beyond them. +- Read the `testing-best-practices` skill before writing tests. + +=== laravel/core rules === + +# Do Things the Laravel Way + +- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`. +- If you're creating a generic PHP class, use `php artisan make:class`. +- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior. + +### Model Creation + +- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options. + +## APIs & Eloquent Resources + +- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention. + +## URL Generation + +- When generating links to other pages, prefer named routes and the `route()` function. + +## Testing + +- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model. +- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`. +- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests. + +## Vite Error + +- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`. + +=== pint/core rules === + +# Laravel Pint Code Formatter + +- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style. +- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues. + +=== phpunit/core rules === + +# PHPUnit + +- This project uses PHPUnit. Create tests with `php artisan make:test --phpunit {name}`. +- Do not include the test suite directory in `{name}`. Use `SomeFeatureTest`, not `Feature/SomeFeatureTest`. +- Read the `testing-best-practices` skill for guidance on coverage, naming, structure, dependency isolation, and review. + +## Running Tests + +- Run the narrowest set of tests that covers the change. Pass a file path or `--filter=testName` to `php artisan test --compact`. +- Rerun a test after each change to it. +- Run `vendor/bin/phpunit` to call the test runner directly. It accepts the same file path and `--filter=testName` arguments. + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a8b241e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# ---------- Stage 1: build the client (resources/js -> public/build) ---------- +FROM node:22-alpine AS client + +WORKDIR /build + +COPY package.json package-lock.json ./ +RUN npm ci --no-audit --no-fund + +COPY vite.config.js ./ +COPY resources ./resources + +RUN npm run build + +# ---------- Stage 2: Laravel runtime (php-fpm / reverb / simulate) ---------- +FROM php:8.5-fpm-alpine AS app + +WORKDIR /var/www/html + +RUN apk add --no-cache linux-headers libzip-dev oniguruma-dev sqlite-dev && \ + docker-php-ext-install -j"$(nproc)" mbstring sockets pcntl bcmath pdo_sqlite && \ + rm -rf /var/cache/apk/* + +# opcache defaults for single-container deployments +RUN { \ + echo 'opcache.enable=1'; \ + echo 'opcache.validate_timestamps=0'; \ + echo 'opcache.memory_consumption=64'; \ + echo 'opcache.interned_strings_buffer=16'; \ + echo 'opcache.max_accelerated_files=10000'; \ + } > /usr/local/etc/php/conf.d/zz-opcache.ini + +COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + +COPY composer.json composer.lock ./ +RUN composer install \ + --no-dev \ + --no-interaction \ + --prefer-dist \ + --no-progress \ + --no-scripts \ + --no-autoloader + +COPY --from=client /build/public/build ./public/build +COPY . . + +RUN rm -f bootstrap/cache/services.php bootstrap/cache/packages.php bootstrap/cache/config.php && \ + composer dump-autoload --optimize --no-scripts --no-interaction && \ + chown -R www-data:www-data storage bootstrap/cache && \ + chmod -R ug+rw storage bootstrap/cache + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +STOPSIGNAL SIGTERM +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["php-fpm", "-F"] + +# ---------- Stage 3: nginx serving public/ + proxying /app to Reverb ---------- +FROM nginx:alpine AS web + +COPY nginx/default.conf /etc/nginx/conf.d/default.conf +COPY --from=app /var/www/html/public /var/www/html/public \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ad1377 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +

Laravel Logo

+ +

+Build Status +Total Downloads +Latest Stable Version +License +

+ +## About Laravel + +Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: + +- [Simple, fast routing engine](https://laravel.com/docs/routing). +- [Powerful dependency injection container](https://laravel.com/docs/container). +- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. +- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). +- Database agnostic [schema migrations](https://laravel.com/docs/migrations). +- [Robust background job processing](https://laravel.com/docs/queues). +- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). + +Laravel is accessible, powerful, and provides tools required for large, robust applications. + +## Learning Laravel + +Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. + +In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. + +You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. + +## Agentic Development + +Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: + +```bash +composer require laravel/boost --dev + +php artisan boost:install +``` + +Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. + +## Contributing + +Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). + +## Code of Conduct + +In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). + +## Security Vulnerabilities + +If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. + +## License + +The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/app/Console/Commands/GameSimulate.php b/app/Console/Commands/GameSimulate.php new file mode 100644 index 0000000..173372b --- /dev/null +++ b/app/Console/Commands/GameSimulate.php @@ -0,0 +1,130 @@ +registerSignals(); + + $last = hrtime(true) / 1e9; + while ($this->running) { + $now = hrtime(true) / 1e9; + $delta = min(0.25, max(0.0, $now - $last)); + $last = $now; + $this->accumulator += $delta; + + $ticks = 0; + while ($this->accumulator >= self::TICK_DT && $ticks < self::MAX_TICKS_PER_LOOP) { + $this->drainQueues($simulator); + $simulator->tick(self::TICK_DT); + $this->accumulator -= self::TICK_DT; + $ticks++; + } + + if (++$this->loop % 600 === 0) { + $this->heartbeat($simulator); + } + + usleep(1000); + } + + $this->info('Simulation worker stopped.'); + + return self::SUCCESS; + } + + private function registerSignals(): void + { + if (!function_exists('pcntl_signal')) { + $this->warn('pcntl unavailable; graceful shutdown disabled.'); + + return; + } + pcntl_async_signals(true); + pcntl_signal(SIGINT, function (): void { + $this->running = false; + }); + pcntl_signal(SIGTERM, function (): void { + $this->running = false; + }); + } + + private function drainQueues(GameSimulator $simulator): void + { + $roomIds = $simulator->roomIds(); + foreach (Redis::keys(sprintf(GameSimulator::CONTROL_QUEUE, '*')) as $key) { + if (preg_match('#^game:ctrl:(.+)$#', (string) $key, $matches) === 1) { + $roomIds[] = $matches[1]; + } + } +$roomIds = array_values(array_unique($roomIds)); + + foreach ($roomIds as $roomId) { + while (($control = Redis::lpop(sprintf(GameSimulator::CONTROL_QUEUE, $roomId))) !== false && $control !== null) { + $frame = $simulator->decode($control); + if ($frame !== null) { + if ($this->getOutput()->isVerbose()) { + $this->line(sprintf(' ctrl %s <- %s', $roomId, (string) ($frame['type'] ?? '?'))); + } + $simulator->applyControl($roomId, $frame); + } + } + if (!in_array($roomId, $simulator->roomIds(), true)) { + continue; + } + while (($input = Redis::lpop(sprintf(GameSimulator::INPUT_QUEUE, $roomId))) !== false && $input !== null) { + $frame = $simulator->decode($input); + if ($frame !== null) { + if ($this->getOutput()->isVerbose()) { + $this->line(sprintf(' in %s <- %s#%s', $roomId, (string) ($frame['id'] ?? '?'), implode('+', (array) ($frame['keys'] ?? [])))); + } + $simulator->applyInput($roomId, $frame); + } + } + } + } + + private function heartbeat(GameSimulator $simulator): void + { + if (!$this->getOutput()->isVerbose()) { + return; + } + $rooms = []; + foreach ($simulator->roomIds() as $roomId) { + $room = $simulator->room($roomId); + $rooms[] = sprintf('%s:%s(%d,%d)', $roomId, $room?->state ?? '?', count($room?->players ?? []), count($room?->entities ?? [])); + } + $keys = Redis::keys('game:*') ?: []; + $this->line(sprintf('hb frame=%d loop=%d rooms=[%s] queueKeys=%d', $simulator->frame(), $this->loop, implode(',', $rooms), count($keys))); + } +} \ No newline at end of file diff --git a/app/Events/GameRoomError.php b/app/Events/GameRoomError.php new file mode 100644 index 0000000..ad9630d --- /dev/null +++ b/app/Events/GameRoomError.php @@ -0,0 +1,45 @@ +room); + } + + public function broadcastAs(): string + { + return 'room-error'; + } + + /** @return array{code: string, room: string} */ + public function broadcastWith(): array + { + return [ + 'code' => $this->code, + 'room' => $this->room, + ]; + } +} diff --git a/app/Events/GameSnapshot.php b/app/Events/GameSnapshot.php new file mode 100644 index 0000000..7367946 --- /dev/null +++ b/app/Events/GameSnapshot.php @@ -0,0 +1,55 @@ + $players + * @param array $entities + * @param array $events + */ + public function __construct( + public Room $room, + public array $players, + public array $entities, + public array $events, + ) { + } + + public function broadcastOn(): Channel + { + return new Channel('game.' . $this->room->id); + } + + public function broadcastAs(): string + { + return 'snapshot'; + } + + /** @return array */ + public function broadcastWith(): array + { + return [ + 'state' => $this->room->state, + 'elapsed' => round($this->room->elapsed, 2), + 'seq' => $this->room->seq, + 'players' => $this->players, + 'entities' => $this->entities, + 'events' => $this->events, + ]; + } +} \ No newline at end of file diff --git a/app/Game/Entities/Boss.php b/app/Game/Entities/Boss.php new file mode 100644 index 0000000..5e556fd --- /dev/null +++ b/app/Game/Entities/Boss.php @@ -0,0 +1,84 @@ +target = $initialTarget; + } + $this->spawnInterval = $config['spawnInterval'] ?? 5.0; + $this->spawnCount = $config['spawnCount'] ?? 1; + $this->spawnTimer = 3.0; + $this->spawnZombie = $config['spawnZombie'] ?? null; + } + + public function kind(): string + { + return 'boss'; + } + + public function update(float $dt): void + { + parent::update($dt); + + $this->spawnTimer -= $dt; + if ($this->spawnTimer > 0.0 || $this->spawnZombie === null) { + return; + } + $this->spawnTimer = $this->spawnInterval; + + $minions = 0; + foreach ($this->container as $obj) { + if ($obj instanceof Zombie && ! ($obj instanceof self)) { + $minions++; + } + } + if ($minions >= 15) { + return; + } + for ($i = 0; $i < $this->spawnCount; $i++) { + $angle = ($i / $this->spawnCount) * M_PI * 2.0 + $this->rand01() * 0.6; + ($this->spawnZombie)( + $this->pos->x + cos($angle) * 70.0, + $this->pos->y + sin($angle) * 70.0, + $this->container, + $this->room, + $this->target, + ); + } + } + + public function toSnapshot(): array + { + $data = parent::toSnapshot(); + $data['type'] = 'boss'; + + return $data; + } +} diff --git a/app/Game/Entities/Bullet.php b/app/Game/Entities/Bullet.php new file mode 100644 index 0000000..eba6126 --- /dev/null +++ b/app/Game/Entities/Bullet.php @@ -0,0 +1,82 @@ + */ + public array $color; + + public Player $owner; + + public function __construct( + float $x, + float $y, + Vector2 $direction, + EntityList $container, + Room $room, + Player $owner, + float $speed = 800.0, + float $size = 8.0, + int $damage = 1, + ) { + parent::__construct($x, $y); + $this->container = $container; + $this->room = $room; + $this->owner = $owner; + $this->velocity = $direction->mulS($speed); + $this->size = $size; + $this->damage = $damage; + $this->color = [1.0, 0.2, 0.2, 1.0]; + $this->width = $size; + $this->height = $size; + } + + public function kind(): string + { + return 'bullet'; + } + + public function update(float $dt): void + { + parent::update($dt); + + if ( + $this->pos->x < -$this->size + || $this->pos->x > $this->room->width + $this->size + || $this->pos->y < -$this->size + || $this->pos->y > $this->room->height + $this->size + ) { + $this->container->remove($this); + } + } + + public function toSnapshot(): array + { + return [ + 'eid' => $this->eid, + 'd' => $this->kind(), + 'x' => round($this->pos->x, 1), + 'y' => round($this->pos->y, 1), + 'vx' => round($this->velocity->x, 1), + 'vy' => round($this->velocity->y, 1), + 's' => $this->size, + ]; + } +} diff --git a/app/Game/Entities/Leaper.php b/app/Game/Entities/Leaper.php new file mode 100644 index 0000000..1e6d2c5 --- /dev/null +++ b/app/Game/Entities/Leaper.php @@ -0,0 +1,103 @@ +target = $initialTarget; + } + $this->dashRange = $config['dashRange'] ?? 300.0; + $this->windupTime = $config['windupTime'] ?? 0.35; + $this->dashTime = $config['dashTime'] ?? 0.28; + $this->dashSpeed = $config['dashSpeed'] ?? 680.0; + $this->dashCooldown = $config['dashCooldown'] ?? 1.8; + $this->dashDirection = new Vector2(1.0, 0.0); + } + + public function kind(): string + { + return 'leaper'; + } + + public function update(float $dt): void + { + $this->time += $dt; + if ($this->updateEmerge($dt)) { + return; + } + $this->dashCooldownTimer = max(0.0, $this->dashCooldownTimer - $dt); + + $target = $this->target; + $delta = $target !== null ? $target->pos->subV($this->pos) : Vector2::zero(); + $distance = $delta->length(); + + $move = Vector2::zero(); + if ($this->windupTimer > 0.0) { + $this->windupTimer -= $dt; + if ($this->windupTimer <= 0.0) { + $this->dashTimer = $this->dashTime; + $this->dashDirection = $distance > 1.0 ? $delta->divS($distance) : new Vector2(1.0, 0.0); + } + } elseif ($this->dashTimer > 0.0) { + $this->dashTimer -= $dt; + $move = $this->dashDirection->mulS($this->dashSpeed); + } elseif ($distance > 1.0) { + if ($distance < $this->dashRange && $this->dashCooldownTimer <= 0.0) { + $this->windupTimer = $this->windupTime; + } else { + $move = $delta->divS($distance)->mulS($this->speed); + } + } + + $this->velocity = $move->addV($this->hitKnockback); + $this->move($dt); + + $this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping)); + $this->flash = max(0.0, $this->flash - $dt); + } + + public function toSnapshot(): array + { + $data = parent::toSnapshot(); + $data['dash'] = $this->dashTimer > 0.0; + $data['wind'] = $this->windupTimer > 0.0; + + return $data; + } +} diff --git a/app/Game/Entities/MegaTank.php b/app/Game/Entities/MegaTank.php new file mode 100644 index 0000000..98a9b8b --- /dev/null +++ b/app/Game/Entities/MegaTank.php @@ -0,0 +1,49 @@ +size + 28.0; + $spawnOnDeath( + $mega->pos->x + cos($angle) * $offset, + $mega->pos->y + sin($angle) * $offset, + $mega->container, + $mega->room, + $mega->target, + ); + } + }); + if ($initialTarget !== null) { + $this->target = $initialTarget; + } + } + + public function kind(): string + { + return 'megatank'; + } +} diff --git a/app/Game/Entities/MegaWitch.php b/app/Game/Entities/MegaWitch.php new file mode 100644 index 0000000..627ccaf --- /dev/null +++ b/app/Game/Entities/MegaWitch.php @@ -0,0 +1,142 @@ + */ + private array $pendingNova = []; + + public function __construct( + float $x, + float $y, + EntityList $container, + Room $room, + ?Player $initialTarget, + array $config = [], + ?callable $onDeath = null, + ) { + $spawnCount = $config['spawnCount'] ?? 3; + $spawnOnDeath = $config['spawnOnDeath'] ?? null; + parent::__construct($x, $y, $container, $room, $initialTarget, $config, $onDeath ?? function (Zombie $mega) use ($spawnCount, $spawnOnDeath): void { + if ($spawnOnDeath === null) { + return; + } + for ($i = 0; $i < $spawnCount; $i++) { + $angle = ($i / $spawnCount) * M_PI * 2.0; + $offset = $mega->size + 30.0; + $spawnOnDeath( + $mega->pos->x + cos($angle) * $offset, + $mega->pos->y + sin($angle) * $offset, + $mega->container, + $mega->room, + $mega->target, + ); + } + }); + if ($initialTarget !== null) { + $this->target = $initialTarget; + } + $this->novaInterval = $config['novaInterval'] ?? 5.0; + $this->novaCount = $config['novaCount'] ?? 16; + $this->novaProjectileSpeed = $config['novaProjectileSpeed'] ?? 250.0; + $this->novaTimer = $this->novaInterval * 0.5; + $this->novaRotation = $config['novaRotation'] ?? $this->rand01() * M_PI * 2.0; + } + + public function kind(): string + { + return 'megawitch'; + } + + public function update(float $dt): void + { + parent::update($dt); + + $target = $this->target; + if ($target !== null && $target->dead) { + return; + } + + foreach (array_keys($this->pendingNova) as $i) { + $this->pendingNova[$i][0] -= $dt; + if ($this->pendingNova[$i][0] <= 0.0) { + $closure = $this->pendingNova[$i][1]; + unset($this->pendingNova[$i]); + $closure(); + } + } + + $this->novaTimer -= $dt; + if ($this->novaTimer <= 0.0) { + $this->novaTimer = $this->novaInterval * (0.85 + $this->rand01() * 0.3); + $this->nova(); + } + } + + private function nova(): void + { + $lost = 1.0 - max(0.0, $this->health / max(1, $this->maxHealth)); + $count = max(6, (int) round($this->novaCount * (0.75 + 0.25 * $lost))); + $speed = $this->novaProjectileSpeed * (1.0 + $lost * 0.45); + $size = max(8.0, $this->size * 0.3); + $color = [1.0, 0.5, 0.3, 1.0]; + $this->novaRotation += 0.36; + + $fireRing = function (float $offset) use ($count, $speed, $size, $color): void { + for ($i = 0; $i < $count; $i++) { + $angle = ($i / $count) * M_PI * 2.0 + $this->novaRotation + $offset; + $direction = new Vector2(cos($angle), sin($angle)); + $this->container->append(new WitchProjectile( + $this->pos->x, + $this->pos->y, + $direction, + $this->container, + $this->room, + $speed, + $size, + $color, + )); + } + }; + + $fireRing(0.0); + $this->pendingNova[] = [0.22, function () use ($fireRing, $count): void { + $fireRing(M_PI / $count); + }]; + if ($lost > 0.34) { + $this->pendingNova[] = [0.44, function () use ($fireRing): void { + $fireRing(0.0); + }]; + } + } + + public function toSnapshot(): array + { + $data = parent::toSnapshot(); + $data['charge'] = 1.0 - max(0.0, $this->novaTimer) / $this->novaInterval; + + if ($data['hp'] < $data['mhp'] && $data['charge'] > 0.72) { + $data['nova'] = true; + } + + return $data; + } +} diff --git a/app/Game/Entities/Pickup.php b/app/Game/Entities/Pickup.php new file mode 100644 index 0000000..27d15d1 --- /dev/null +++ b/app/Game/Entities/Pickup.php @@ -0,0 +1,109 @@ + 'Rapid Fire', + 'damage' => 'Damage +1', + 'multishot' => 'Multishot', + 'speed' => 'Bullet Speed', + ]; + + private EntityList $container; + + private Room $room; + + public string $type; + + public float $life; + + private float $time = 0.0; + + public float $collectRadius; + + public float $size; + + /** @var array */ + public array $color; + + public function __construct( + float $x, + float $y, + string $type, + EntityList $container, + Room $room, + float $life = 10.0, + float $collectRadius = 46.0, + ) { + parent::__construct($x, $y); + $this->container = $container; + $this->room = $room; + $this->type = $type; + $this->life = $life; + $this->collectRadius = $collectRadius; + $this->time = 0.0; + $this->size = 16.0; + $this->width = $this->size; + $this->height = $this->size; + $this->color = self::colorFor($type); + } + + public function kind(): string + { + return 'pickup'; + } + + public static function colorFor(string $type): array + { + return match ($type) { + 'rapid' => [1.0, 0.8, 0.2, 1.0], + 'damage' => [1.0, 0.3, 0.3, 1.0], + 'multishot' => [0.2, 0.8, 1.0, 1.0], + 'speed' => [0.6, 0.9, 1.0, 1.0], + default => [1.0, 1.0, 1.0, 1.0], + }; + } + + public function collectableBy(Player $player): bool + { + if ($player instanceof Player && ! $player->dead) { + return $player->maxUpgradeLevel > ($player->upgrades[$this->type] ?? 0); + } + + return false; + } + + public function update(float $dt): void + { + $this->time += $dt; + if ($this->time >= $this->life) { + $this->container->remove($this); + } + } + + public function remaining(): float + { + return max(0.0, $this->life - $this->time); + } + + public function toSnapshot(): array + { + return [ + 'eid' => $this->eid, + 'd' => $this->kind(), + 'x' => round($this->pos->x, 1), + 'y' => round($this->pos->y, 1), + 't' => $this->type, + 'life' => round($this->remaining(), 2), + ]; + } +} diff --git a/app/Game/Entities/Witch.php b/app/Game/Entities/Witch.php new file mode 100644 index 0000000..667c536 --- /dev/null +++ b/app/Game/Entities/Witch.php @@ -0,0 +1,125 @@ + */ + public array $projectileColor; + + protected float $fireTimer = 0.0; + + public function __construct( + float $x, + float $y, + EntityList $container, + Room $room, + ?Player $initialTarget, + array $config = [], + ?callable $onDeath = null, + ) { + parent::__construct($x, $y, $container, $room, $config, $onDeath); + if ($initialTarget !== null) { + $this->target = $initialTarget; + } + $this->preferredDistance = $config['preferredDistance'] ?? 220.0; + $this->fireInterval = $config['fireInterval'] ?? 2.2; + $this->projectileSpeed = $config['projectileSpeed'] ?? 220.0; + $this->projectileCount = $config['projectileCount'] ?? 1; + $this->projectileSpread = $config['projectileSpread'] ?? 0.08; + $this->projectileColor = $config['projectileColor'] ?? [0.7, 0.2, 1.0, 1.0]; + } + + public function kind(): string + { + return 'witch'; + } + + public function update(float $dt): void + { + $this->time += $dt; + + if ($this->updateEmerge($dt)) { + return; + } + + $this->fireTimer -= $dt; + + $target = $this->target; + if ($target === null) { + return; + } + + $delta = $target->pos->subV($this->pos); + $distance = $delta->length(); + + $move = Vector2::zero(); + if ($distance > 1.0) { + $dir = $delta->divS($distance); + if ($distance < $this->preferredDistance) { + $move = $dir->mulS(-$this->speed); + } elseif ($distance > $this->preferredDistance + 40.0) { + $move = $dir->mulS($this->speed); + } + } + + $this->velocity = $move->addV($this->hitKnockback); + $this->move($dt); + + $this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping)); + $this->flash = max(0.0, $this->flash - $dt); + + if ($this->fireTimer <= 0.0 && ! $target->dead && $distance < 480.0) { + $this->fireTimer = $this->fireInterval * (0.8 + $this->rand01() * 0.4); + $this->fire(); + } + } + + protected function onEmergeComplete(): void + { + $this->fireTimer = $this->fireInterval * (0.4 + $this->rand01() * 0.4); + } + + public function fire(): void + { + $target = $this->target; + if ($target === null) { + return; + } + $aim = $target->pos->subV($this->pos); + $aimAngle = $aim->lengthSquared() > 1.0 ? atan2($aim->y, $aim->x) : $this->time; + + $offset = (($this->projectileCount - 1) / 2.0) * $this->projectileSpread; + for ($i = 0; $i < $this->projectileCount; $i++) { + $angle = $aimAngle - $offset + $i * $this->projectileSpread; + $direction = new Vector2(cos($angle), sin($angle)); + $this->container->append(new WitchProjectile( + $this->pos->x, + $this->pos->y, + $direction, + $this->container, + $this->room, + $this->projectileSpeed, + $this->size * 0.4, + $this->projectileColor, + )); + } + } +} diff --git a/app/Game/Entities/WitchProjectile.php b/app/Game/Entities/WitchProjectile.php new file mode 100644 index 0000000..8284953 --- /dev/null +++ b/app/Game/Entities/WitchProjectile.php @@ -0,0 +1,77 @@ + */ + public array $color; + + private float $time = 0.0; + + public function __construct( + float $x, + float $y, + Vector2 $direction, + EntityList $container, + Room $room, + float $speed = 220.0, + float $size = 12.0, + array $color = [0.7, 0.2, 1.0, 1.0], + ) { + parent::__construct($x, $y); + $this->container = $container; + $this->room = $room; + $this->velocity = $direction->mulS($speed); + $this->size = $size; + $this->color = $color; + $this->width = $size; + $this->height = $size; + } + + public function kind(): string + { + return 'projectile'; + } + + public function update(float $dt): void + { + parent::update($dt); + $this->time += $dt; + + if ( + $this->pos->x < -$this->size + || $this->pos->x > $this->room->width + $this->size + || $this->pos->y < -$this->size + || $this->pos->y > $this->room->height + $this->size + ) { + $this->container->remove($this); + } + } + + public function toSnapshot(): array + { + return [ + 'eid' => $this->eid, + 'd' => $this->kind(), + 'x' => round($this->pos->x, 1), + 'y' => round($this->pos->y, 1), + 'vx' => round($this->velocity->x, 1), + 'vy' => round($this->velocity->y, 1), + 's' => $this->size, + ]; + } +} diff --git a/app/Game/Entities/Zombie.php b/app/Game/Entities/Zombie.php new file mode 100644 index 0000000..7a1b756 --- /dev/null +++ b/app/Game/Entities/Zombie.php @@ -0,0 +1,173 @@ + */ + public array $baseColor; + + public int $health; + + public int $maxHealth; + + public float $knockback; + + public float $knockbackDamping; + + public int $scoreValue; + + /** @var null|callable(Zombie):void */ + public $onDeath; + + protected Vector2 $hitKnockback; + + protected float $flash = 0.0; + + protected float $time = 0.0; + + public float $broken; + + protected float $emergeTotal; + + protected float $emerge; + + public function __construct( + float $x, + float $y, + EntityList $container, + Room $room, + array $config = [], + ?callable $onDeath = null, + ) { + parent::__construct($x, $y); + $this->container = $container; + $this->room = $room; + $this->speed = $config['speed'] ?? 110.0; + $this->size = $config['size'] ?? 24.0; + $this->baseColor = $config['color'] ?? [0.15, 0.65, 0.2, 1.0]; + $this->health = $config['health'] ?? 1; + $this->maxHealth = $this->health; + $this->knockback = $config['knockback'] ?? 260.0; + $this->knockbackDamping = $config['knockbackDamping'] ?? 10.0; + $this->scoreValue = $config['scoreValue'] ?? 1; + $emergeTime = $config['emergeTime'] ?? 0.0; + $this->emergeTotal = $emergeTime; + $this->emerge = $emergeTime; + $this->onDeath = $onDeath; + $this->broken = rand(0, 100000) / 100000.0 * M_PI * 2.0; + $this->hitKnockback = Vector2::zero(); + $this->width = $this->size; + $this->height = $this->size; + } + + public function kind(): string + { + return 'zombie'; + } + + public function die(): void + { + if ($this->onDeath !== null) { + ($this->onDeath)($this); + } + $this->removeAllReferences(); + $this->container->remove($this); + } + + protected function removeAllReferences(): void {} + + public function hit(Vector2 $impulse, int $damage = 1): bool + { + $this->health -= $damage; + if ($this->health < 0) { + $this->health = 0; + } + $this->hitKnockback = $impulse->mulS($this->knockback); + $this->flash = 0.08; + + return $this->health <= 0; + } + + public function update(float $dt): void + { + $this->time += $dt; + if ($this->updateEmerge($dt)) { + return; + } + if ($this->target === null) { + return; + } + $delta = $this->target->pos->subV($this->pos); + $distance = $delta->length(); + $chaseVelocity = Vector2::zero(); + if ($distance > 1.0) { + $chaseVelocity = $delta->divS($distance)->mulS($this->speed); + } + + $this->velocity = $chaseVelocity->addV($this->hitKnockback); + parent::update($dt); + + $this->hitKnockback = $this->hitKnockback->mulS(max(0.0, 1 - $dt * $this->knockbackDamping)); + $this->flash = max(0.0, $this->flash - $dt); + } + + public function updateEmerge(float $dt): bool + { + if ($this->emerge <= 0.0) { + return false; + } + $this->emerge -= $dt; + if ($this->emerge <= 0.0) { + $this->emerge = 0.0; + $this->onEmergeComplete(); + } + + return true; + } + + protected function onEmergeComplete(): void {} + + protected function rand01(): float + { + return random_int(0, 1000000) / 1000000.0; + } + + public function toSnapshot(): array + { + $snapshot = [ + 'eid' => $this->eid, + 'd' => $this->kind(), + 'x' => round($this->pos->x, 1), + 'y' => round($this->pos->y, 1), + 's' => $this->size, + 'hp' => $this->health, + 'mhp' => $this->maxHealth, + 'c' => array_slice($this->baseColor, 0, 3), + ]; + if ($this->emergeTotal > 0.0) { + $snapshot['et'] = round($this->emergeTotal, 2); + $snapshot['er'] = round($this->emerge, 2); + } + + return $snapshot; + } +} diff --git a/app/Game/EntityList.php b/app/Game/EntityList.php new file mode 100644 index 0000000..f6a4d9b --- /dev/null +++ b/app/Game/EntityList.php @@ -0,0 +1,41 @@ +getArrayCopy()) as $key) { + if ($this[$key] === $gameObject) { + $this->offsetUnset($key); + break; + } + } + } + + public function findByKind(string $kind): array + { + $found = []; + foreach ($this as $entity) { + if ($entity->kind() === $kind) { + $found[] = $entity; + } + } + + return $found; + } + + /** Snapshot copy so entities added mid-iteration are not touched. */ + public function copy(): array + { + $copy = []; + foreach ($this as $entity) { + $copy[] = $entity; + } + + return $copy; + } +} diff --git a/app/Game/GameEngine.php b/app/Game/GameEngine.php new file mode 100644 index 0000000..d2571d8 --- /dev/null +++ b/app/Game/GameEngine.php @@ -0,0 +1,319 @@ +room->state !== Room::STATE_PLAYING) { + return; + } + + $this->room->elapsed += $dt; + $this->room->bossCooldown = max(0.0, $this->room->bossCooldown - $dt); + + $this->assignTargets(); + $this->updateEntities($dt); + $this->updateSpawns($dt); + $this->updatePickupTidy($dt); + $this->resolveBulletHits(); + $this->resolveProjectileHits(); + $this->resolvePlayerContact(); + $this->resolvePickups(); + } + + private function assignTargets(): void + { + $alive = $this->room->alivePlayers(); + if ($alive === []) { + return; + } + foreach ($this->room->entities as $entity) { + if (! $entity instanceof Zombie) { + continue; + } + $entity->target = $this->nearestPlayer($entity, $alive); + } + } + + /** @param array $alive */ + private function nearestPlayer(Zombie $zombie, array $alive): ?Player + { + $best = null; + $bestDist = INF; + foreach ($alive as $player) { + $dist = $player->pos->subV($zombie->pos)->lengthSquared(); + if ($dist < $bestDist) { + $bestDist = $dist; + $best = $player; + } + } + + return $best; + } + + private function updateEntities(float $dt): void + { + // Players first so bullets spawn before resolution this frame. + foreach ($this->room->players as $player) { + if (! $player->dead) { + $player->update($dt); + } + } + + foreach ($this->room->entities->copy() as $entity) { + if ($entity instanceof Player) { + continue; + } + $entity->update($dt); + } + } + + private function updateSpawns(float $dt): void + { + $this->room->spawnTimer += $dt; + $interval = $this->spawner->spawnInterval($this->room->elapsed); + if ($this->room->spawnTimer >= $interval) { + $this->room->spawnTimer = 0.0; + $zombieCount = 0; + foreach ($this->room->entities as $entity) { + if ($entity instanceof Zombie) { + $zombieCount++; + } + } + if ($zombieCount < self::MAX_ZOMBIES) { + $this->spawner->spawnZombie($this->room); + } + } + } + + private function updatePickupTidy(float $dt): void + { + foreach ($this->room->entities->copy() as $pickup) { + if (! $pickup instanceof Pickup) { + continue; + } + $anyNeeds = false; + foreach ($this->room->alivePlayers() as $player) { + if (($player->upgrades[$pickup->type] ?? 0) < $player->maxUpgradeLevel) { + $anyNeeds = true; + break; + } + } + if (! $anyNeeds) { + $pickup->removeFrom($this->room->entities); + } + } + + $this->room->pickupTimer -= $dt; + if ($this->room->pickupTimer <= 0.0) { + $this->room->pickupTimer = self::PICKUP_INTERVAL; + if ($this->room->alivePlayers() !== []) { + $this->spawner->spawnPickup($this->room); + } + } + } + + private function resolveBulletHits(): void + { + foreach ($this->room->entities->copy() as $bullet) { + if (! $bullet instanceof Bullet) { + continue; + } + foreach ($this->room->entities->copy() as $zombie) { + if (! $zombie instanceof Zombie || ! $bullet->intersects($zombie)) { + continue; + } + $impulse = $zombie->pos->subV($bullet->pos); + $impulse = $impulse->lengthSquared() > 1.0 + ? $impulse->divS($impulse->length()) + : new Vector2(1.0, 0.0); + $bullet->removeFrom($this->room->entities); + + $this->room->pushEvent('bullethit', [ + 'x' => $bullet->pos->x, + 'y' => $bullet->pos->y, + 'color' => array_slice($zombie->baseColor, 0, 3), + ]); + + if (! $zombie->hit($impulse, $bullet->damage)) { + break; + } + $zombie->die(); + + $owner = $bullet->owner; + if (! $owner->dead) { + $owner->addScore($zombie->scoreValue); + } + + $killColor = array_slice($zombie->baseColor, 0, 3); + $this->room->pushEvent('kill', [ + 'x' => $zombie->pos->x, + 'y' => $zombie->pos->y, + 'color' => $killColor, + 'boss' => $zombie instanceof Boss, + 'big' => $zombie->maxHealth > 1, + ]); + + if ($zombie instanceof Boss) { + $this->room->nextBossScore *= 2.0; + $this->room->bossCooldown = 60.0; + $this->room->pushEvent('bossdefeated', [ + 'x' => $zombie->pos->x, + 'y' => $zombie->pos->y, + ]); + } elseif ( + $owner->score >= $this->room->nextBossScore + && $this->room->bossCooldown <= 0.0 + && ! $this->room->hasBoss() + ) { + $this->spawner->spawnBoss($this->room); + } + break; + } + } + } + + private function resolveProjectileHits(): void + { + foreach ($this->room->entities->copy() as $projectile) { + if (! $projectile instanceof WitchProjectile) { + continue; + } + foreach ($this->room->alivePlayers() as $player) { + if (! $projectile->intersects($player)) { + continue; + } + $projectile->removeFrom($this->room->entities); + $player->takeDamage(); + $this->room->pushEvent('playerdamage', [ + 'x' => $projectile->pos->x, + 'y' => $projectile->pos->y, + 'id' => $player->id, + ]); + if ($player->dead) { + $this->room->pushEvent('playerdeath', [ + 'x' => $player->pos->x, + 'y' => $player->pos->y, + 'id' => $player->id, + ]); + $player->removeFrom($this->room->entities); + $this->checkGameOver(); + } + } + } + } + + private function resolvePlayerContact(): void + { + foreach ($this->room->entities->copy() as $zombie) { + if (! $zombie instanceof Zombie) { + continue; + } + foreach ($this->room->alivePlayers() as $player) { + if (! $zombie->intersects($player)) { + continue; + } + if (! $zombie instanceof Boss && ! $zombie instanceof Witch) { + $zombie->removeFrom($this->room->entities); + $this->room->pushEvent('bloodpool', [ + 'x' => $zombie->pos->x, + 'y' => $zombie->pos->y, + 's' => $zombie->size, + ]); + } + $player->takeDamage(); + $this->room->pushEvent('playerdamage', [ + 'x' => $player->pos->x, + 'y' => $player->pos->y, + 'id' => $player->id, + ]); + if ($player->dead) { + $this->room->pushEvent('playerdeath', [ + 'x' => $player->pos->x, + 'y' => $player->pos->y, + 'id' => $player->id, + ]); + $player->removeFrom($this->room->entities); + $this->checkGameOver(); + } + } + } + } + + private function resolvePickups(): void + { + foreach ($this->room->entities->copy() as $pickup) { + if (! $pickup instanceof Pickup) { + continue; + } + foreach ($this->room->alivePlayers() as $player) { + if (! $this->pickupIntersects($pickup, $player)) { + continue; + } + $maxed = ($player->upgrades[$pickup->type] ?? 0) >= $player->maxUpgradeLevel; + if ($maxed) { + $this->room->pushEvent('pickupmax', [ + 'x' => $player->pos->x, + 'y' => $player->pos->y, + 'type' => $pickup->type, + 'id' => $player->id, + ]); + + continue; + } + $pickup->removeFrom($this->room->entities); + $player->applyUpgrade($pickup->type); + $this->room->pushEvent('pickup', [ + 'x' => $pickup->pos->x, + 'y' => $pickup->pos->y, + 'type' => $pickup->type, + 'id' => $player->id, + ]); + break; + } + } + } + + private function pickupIntersects(Pickup $pickup, Player $player): bool + { + $dx = $pickup->pos->x - $player->pos->x; + $dy = $pickup->pos->y - $player->pos->y; + + return $dx * $dx + $dy * $dy <= $pickup->collectRadius * $pickup->collectRadius + || $pickup->intersects($player); + } + + private function checkGameOver(): void + { + if ($this->room->alivePlayers() !== []) { + return; + } + $this->room->state = Room::STATE_OVER; + foreach ($this->room->entities->copy() as $entity) { + if ($entity instanceof Zombie) { + $entity->removeFrom($this->room->entities); + } + } + $this->room->pushEvent('gameover'); + } +} diff --git a/app/Game/GameObject.php b/app/Game/GameObject.php new file mode 100644 index 0000000..e15d79f --- /dev/null +++ b/app/Game/GameObject.php @@ -0,0 +1,56 @@ +eid = ++self::$nextId; + $this->pos = new Vector2($x, $y); + $this->velocity = Vector2::zero(); + $this->width = 0.0; + $this->height = 0.0; + } + + public function update(float $dt): void + { + $this->move($dt); + } + + /** Integrate position from velocity; used directly by subclasses that override update(). */ + public function move(float $dt): void + { + $this->pos = new Vector2( + $this->pos->x + $this->velocity->x * $dt, + $this->pos->y + $this->velocity->y * $dt, + ); + } + + public function intersects(GameObject $other): bool + { + return abs($this->pos->x - $other->pos->x) * 2.0 < $this->width + $other->width + && abs($this->pos->y - $other->pos->y) * 2.0 < $this->height + $other->height; + } + + public function removeFrom(EntityList $container): void + { + $container->remove($this); + } + + abstract public function kind(): string; +} diff --git a/app/Game/GameSimulator.php b/app/Game/GameSimulator.php new file mode 100644 index 0000000..0f9eb1d --- /dev/null +++ b/app/Game/GameSimulator.php @@ -0,0 +1,308 @@ +}> */ + private array $world = []; + + private int $frame = 0; + + /** @var callable(Room, array, array, array): void */ + private $broadcaster; + + /** @var callable(string, string): void */ + private $errorReporter; + + public function __construct(?callable $broadcaster = null, ?callable $errorReporter = null) + { + $this->broadcaster = $broadcaster ?? function (Room $room, array $players, array $entities, array $events): void { + GameSnapshot::dispatch($room, $players, $entities, $events); + }; + $this->errorReporter = $errorReporter ?? static function (string $roomId, string $code): void { + GameRoomError::dispatch($roomId, $code); + }; + } + + /** @return array room id to Room id (for introspection/tests) */ + public function roomIds(): array + { + return array_keys($this->world); + } + + public function frame(): int + { + return $this->frame; + } + + public function room(string $id): ?Room + { + return $this->world[$id]['room'] ?? null; + } + + public function applyControl(string $roomId, array $frame): void + { + $type = $frame['type'] ?? null; + + if ($type === 'create') { + $this->create($roomId, (string) ($frame['id'] ?? ''), (string) ($frame['name'] ?? 'Player')); + } elseif ($type === 'join') { + $this->join($roomId, (string) ($frame['id'] ?? ''), (string) ($frame['name'] ?? 'Player')); + } elseif ($type === 'leave') { + $this->leave($roomId, (string) ($frame['id'] ?? '')); + } elseif ($type === 'start') { + $entry = $this->world[$roomId] ?? null; + if ($entry !== null) { + $room = $entry['room']; + if ( + $room->state === Room::STATE_LOBBY + && $room->players !== [] + && (string) ($frame['id'] ?? '') === $room->hostId + ) { + $room->state = Room::STATE_PLAYING; + $room->pickupTimer = 8.0; + $room->spawnTimer = 0.0; + } + } + } elseif ($type === 'reset') { + $this->reset($roomId, (string) ($frame['id'] ?? '')); + } + } + + /** Host-only: create a brand-new room with this client as host. */ + private function create(string $roomId, string $playerId, string $name): void + { + if ($playerId === '') { + return; + } + + if (! isset($this->world[$roomId])) { + $this->createRoom($roomId, $playerId, $name); + + return; + } + + $room = $this->world[$roomId]['room']; + if ($room->hostId !== $playerId) { + $this->reject($roomId, 'room_exists'); + + return; + } + + $this->addPlayer($roomId, $playerId, $name); + } + + /** Guests may only join rooms that already exist and are open. */ + private function join(string $roomId, string $playerId, string $name): void + { + if ($playerId === '') { + return; + } + + if (! isset($this->world[$roomId])) { + $this->reject($roomId, 'room_not_found'); + + return; + } + + $room = $this->world[$roomId]['room']; + if ($room->state !== Room::STATE_LOBBY) { + $this->broadcastRoom($room); + $this->reject($roomId, 'room_not_open'); + + return; + } + if (isset($room->players[$playerId])) { + return; + } + if (count($room->players) >= $room->maxPlayers) { + $this->broadcastRoom($room); + $this->reject($roomId, 'room_full'); + + return; + } + + $this->addPlayer($roomId, $playerId, $name); + } + + private function createRoom(string $roomId, string $playerId, string $name): void + { + $room = new Room($roomId); + $room->state = Room::STATE_LOBBY; + $room->hostId = $playerId; + $spawner = new Spawner; + $this->world[$roomId] = [ + 'room' => $room, + 'engine' => new GameEngine($room, $spawner), + 'spawner' => $spawner, + 'inputs' => [], + ]; + + $this->addPlayer($roomId, $playerId, $name); + } + + private function addPlayer(string $roomId, string $playerId, string $name): void + { + $entry = $this->world[$roomId]; + $room = $entry['room']; + + if (isset($room->players[$playerId])) { + return; + } + if (count($room->players) >= $room->maxPlayers || $room->state !== Room::STATE_LOBBY) { + $this->broadcastRoom($room); + + return; + } + + $input = new InputState; + $player = new Player($playerId, $name, $input, $room->entities, $room); + $room->addPlayer($player); + $entry['inputs'][$playerId] = $input; + $this->world[$roomId] = $entry; + + $this->broadcastRoom($room); + } + + private function reject(string $roomId, string $code): void + { + ($this->errorReporter)($roomId, $code); + } + + private function leave(string $roomId, string $playerId): void + { + if (! isset($this->world[$roomId])) { + return; + } + $entry = $this->world[$roomId]; + $room = $entry['room']; + $player = $room->players[$playerId] ?? null; + if ($player !== null) { + $room->removePlayer($player); + if ($room->hostId === $playerId && $room->players !== []) { + $room->hostId = (string) array_key_first($room->players); + } + } + unset($entry['inputs'][$playerId]); + $this->world[$roomId] = $entry; + + $this->broadcastRoom($room); + + if ($room->players === []) { + unset($this->world[$roomId]); + } + } + + /** Host-only: send a finished room back to the lobby for another round. */ + private function reset(string $roomId, string $playerId): void + { + $room = $this->world[$roomId]['room'] ?? null; + if ($room === null || $playerId === '' || $room->hostId !== $playerId) { + return; + } + if ($room->state !== Room::STATE_OVER) { + return; + } + + $room->reset(); + $this->broadcastRoom($room); + } + + public function applyInput(string $roomId, array $frame): void + { + $entry = $this->world[$roomId] ?? null; + if ($entry === null) { + return; + } + $input = $entry['inputs'][(string) ($frame['id'] ?? '')] ?? null; + if ($input === null) { + return; + } + + $keys = is_array($frame['keys'] ?? null) ? $frame['keys'] : []; + $input->keys = array_values(array_filter($keys, 'is_string')); + $input->fire = (bool) ($frame['fire'] ?? false); + $input->autoAim = (bool) ($frame['autoAim'] ?? false); + + $aim = $frame['aim'] ?? null; + if (is_array($aim)) { + $input->aim = new Vector2( + (float) ($aim['x'] ?? 0.0), + (float) ($aim['y'] ?? 0.0), + ); + } else { + $input->aim = new Vector2(1.0, 0.0); + } + } + + public function tick(float $dt): void + { + $this->frame++; + foreach (array_keys($this->world) as $roomId) { + $entry = $this->world[$roomId]; + $room = $entry['room']; + if ($room->state !== Room::STATE_PLAYING) { + continue; + } + $entry['engine']->tick($dt); + + if ($room->state === Room::STATE_OVER) { + $room->seq++; + $this->broadcastRoom($room); + } elseif ($this->frame % self::SNAPSHOT_EVERY === 0) { + $room->seq++; + $this->broadcastRoom($room); + } + } + } + + public function broadcastRoom(Room $room): void + { + if ($room->players === []) { + return; + } + + $players = []; + foreach ($room->players as $player) { + $players[] = $player->toSnapshot(); + } + + $entities = []; + foreach ($room->entities as $entity) { + if ($entity instanceof Player) { + continue; + } + if ($entity instanceof GameObject) { + $entities[] = $entity->toSnapshot(); + } + } + + ($this->broadcaster)($room, $players, $entities, $room->flushEvents()); + } + + /** @return array|null */ + public function decode(string $payload): ?array + { + $frame = json_decode($payload, true); + + return is_array($frame) ? $frame : null; + } +} diff --git a/app/Game/InputState.php b/app/Game/InputState.php new file mode 100644 index 0000000..9ee3fdb --- /dev/null +++ b/app/Game/InputState.php @@ -0,0 +1,38 @@ + Currently-held keys (lowercase: w/a/s/d/space/shift). */ + public array $keys; + + /** Normalized aim direction in world space, sent by the client. */ + public Vector2 $aim; + + public bool $fire; + + public bool $autoAim; + + public function __construct( + array $keys = [], + ?Vector2 $aim = null, + bool $fire = false, + bool $autoAim = false, + ) { + $this->keys = $keys; + $this->aim = $aim ?? new Vector2(1.0, 0.0); + $this->fire = $fire; + $this->autoAim = $autoAim; + } + + public function reset(?Vector2 $aim = null): void + { + $this->keys = []; + $this->aim = $aim ?? new Vector2(1.0, 0.0); + $this->fire = false; + $this->autoAim = false; + } +} diff --git a/app/Game/Player.php b/app/Game/Player.php new file mode 100644 index 0000000..7352253 --- /dev/null +++ b/app/Game/Player.php @@ -0,0 +1,310 @@ + */ + public array $upgrades = ['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0]; + + public float $size; + + public string $name; + + public string $id; + + public int $health; + + public int $maxHealth; + + public int $score = 0; + + public bool $dead = false; + + public bool $autoAim = false; + + private float $cooldown = 0.0; + + private Vector2 $aimDirection; + + private Vector2 $recoil; + + private float $damageCooldown = 0.0; + + private float $time = 0.0; + + public function __construct( + string $playerId, + string $name, + InputState $input, + EntityList $container, + Room $room, + array $config = [], + ) { + parent::__construct( + $config['x'] ?? $room->width / 2.0, + $config['y'] ?? $room->height / 2.0, + ); + $this->input = $input; + $this->container = $container; + $this->room = $room; + $this->id = $playerId; + $this->name = $name; + $this->moveSpeed = $config['moveSpeed'] ?? 420.0; + $this->fireRate = $config['fireRate'] ?? 0.2; + $this->recoilSpeed = $config['recoilSpeed'] ?? 100.0; + $this->recoilDamping = $config['recoilDamping'] ?? 15.0; + $this->bulletSpeed = $config['bulletSpeed'] ?? 800.0; + $this->bulletSize = $config['bulletSize'] ?? 8.0; + $this->bulletCount = $config['bulletCount'] ?? 1; + $this->bulletSpread = $config['bulletSpread'] ?? 0.09; + $this->damage = $config['damage'] ?? 1; + $this->baseFireRate = $this->fireRate; + $this->baseDamage = $this->damage; + $this->baseBulletCount = $this->bulletCount; + $this->baseBulletSpeed = $this->bulletSpeed; + $this->maxUpgradeLevel = $config['maxUpgradeLevel'] ?? 3; + $this->health = $config['health'] ?? 3; + $this->maxHealth = $this->health; + $this->size = $config['size'] ?? 24.0; + $this->width = $this->size; + $this->height = $this->size; + $this->aimDirection = new Vector2(1.0, 0.0); + $this->recoil = Vector2::zero(); + } + + public function kind(): string + { + return 'player'; + } + + public function update(float $dt): void + { + $this->time += $dt; + + $movement = new Vector2(0.0, 0.0); + $keys = $this->input->keys; + if (in_array('w', $keys, true) || in_array('arrowup', $keys, true)) { + $movement = new Vector2($movement->x, $movement->y - 1.0); + } + if (in_array('s', $keys, true) || in_array('arrowdown', $keys, true)) { + $movement = new Vector2($movement->x, $movement->y + 1.0); + } + if (in_array('a', $keys, true) || in_array('arrowleft', $keys, true)) { + $movement = new Vector2($movement->x - 1.0, $movement->y); + } + if (in_array('d', $keys, true) || in_array('arrowright', $keys, true)) { + $movement = new Vector2($movement->x + 1.0, $movement->y); + } + + $moveVelocity = Vector2::zero(); + if ($movement->lengthSquared() > 0.0) { + $moveVelocity = $movement->normalize()->mulS($this->moveSpeed); + } + + $this->velocity = $this->recoil->lengthSquared() > 1.0 + ? $moveVelocity->addV($this->recoil) + : $moveVelocity; + parent::update($dt); + + $maxX = max($this->width / 2.0, 0.0); + $maxY = max($this->height / 2.0, 0.0); + $this->pos = new Vector2( + min(max($this->pos->x, $maxX), $this->room->width - $maxX), + min(max($this->pos->y, $maxY), $this->room->height - $maxY), + ); + + $aim = $this->input->aim; + $autoAimActive = $this->input->autoAim || $this->autoAim; + $hasTarget = false; + if ($autoAimActive) { + $nearest = $this->nearestZombie(); + if ($nearest !== null) { + $hasTarget = true; + $toEnemy = $nearest->pos->subV($this->pos); + if ($toEnemy->lengthSquared() > 1.0) { + $aim = $toEnemy; + } + } + } + if ($aim->lengthSquared() > 1.0) { + $this->aimDirection = $aim->divS($aim->length()); + } elseif ($aim->lengthSquared() > 0.01 && $this->input->aim !== null && ! $autoAimActive) { + // Raw normalized direction from the client when not auto-aiming. + $this->aimDirection = $aim->normalize(); + } + + $this->recoil = $this->recoil->mulS(max(0.0, 1 - $dt * $this->recoilDamping)); + $this->damageCooldown = max(0.0, $this->damageCooldown - $dt); + + $this->cooldown -= $dt; + if ($this->cooldown <= 0.0 && ($this->input->fire || ($autoAimActive && $hasTarget))) { + $this->cooldown = $this->fireRate; + $this->shoot(); + } + } + + public function nearestZombie(): ?Zombie + { + $best = null; + $bestDistSq = INF; + foreach ($this->container as $obj) { + if (! $obj instanceof Zombie) { + continue; + } + $delta = $obj->pos->subV($this->pos); + $distSq = $delta->lengthSquared(); + if ($distSq < $bestDistSq) { + $bestDistSq = $distSq; + $best = $obj; + } + } + + return $best; + } + + public function shoot(): void + { + $aimAngle = atan2($this->aimDirection->y, $this->aimDirection->x); + $this->recoil = $this->aimDirection->mulS(-$this->recoilSpeed); + + $offset = (($this->bulletCount - 1) / 2.0) * $this->bulletSpread; + for ($i = 0; $i < $this->bulletCount; $i++) { + $angle = $aimAngle - $offset + $i * $this->bulletSpread; + $direction = new Vector2(cos($angle), sin($angle)); + $bullet = new Bullet( + $this->pos->x, + $this->pos->y, + $direction, + $this->container, + $this->room, + $this, + $this->bulletSpeed, + $this->bulletSize, + $this->damage, + ); + $this->container->append($bullet); + } + } + + public function applyUpgrade(string $type): bool + { + if (! array_key_exists($type, $this->upgrades)) { + return false; + } + if ($this->upgrades[$type] >= $this->maxUpgradeLevel) { + return false; + } + $this->upgrades[$type] += 1; + switch ($type) { + case 'rapid': + $this->fireRate = max(0.06, $this->fireRate * 0.8); + break; + case 'damage': + $this->damage += 1; + break; + case 'multishot': + $this->bulletCount = min(7, $this->bulletCount + 1); + break; + case 'speed': + $this->bulletSpeed = min(1800.0, $this->bulletSpeed * 1.15); + break; + } + + return true; + } + + public function toSnapshot(): array + { + return [ + 'id' => $this->id, + 'name' => $this->name, + 'x' => round($this->pos->x, 1), + 'y' => round($this->pos->y, 1), + 'aimX' => round($this->aimDirection->x, 3), + 'aimY' => round($this->aimDirection->y, 3), + 'hp' => $this->health, + 'mhp' => $this->maxHealth, + 'score' => $this->score, + 'dead' => $this->dead, + 'host' => $this->room->hostId === $this->id, + 'up' => $this->upgrades, + ]; + } + + /** Reset per-round state back to lobby defaults (keeps id/name/host). */ + public function resetForRound(): void + { + $this->pos = new Vector2($this->room->width / 2.0, $this->room->height / 2.0); + $this->velocity = Vector2::zero(); + $this->recoil = Vector2::zero(); + $this->aimDirection = new Vector2(1.0, 0.0); + $this->health = $this->maxHealth; + $this->dead = false; + $this->score = 0; + $this->upgrades = ['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0]; + $this->fireRate = $this->baseFireRate; + $this->bulletSpeed = $this->baseBulletSpeed; + $this->bulletCount = $this->baseBulletCount; + $this->damage = $this->baseDamage; + $this->cooldown = 0.0; + $this->damageCooldown = 0.0; + $this->time = 0.0; + } + + public function takeDamage(): void + { + if ($this->damageCooldown > 0.0) { + return; + } + $this->damageCooldown = 0.5; + $this->health -= 1; + if ($this->health <= 0 && ! $this->dead) { + $this->dead = true; + } + } + + public function addScore(int $points): void + { + $this->score += $points; + } +} diff --git a/app/Game/Room.php b/app/Game/Room.php new file mode 100644 index 0000000..e767db0 --- /dev/null +++ b/app/Game/Room.php @@ -0,0 +1,136 @@ + playerId => Player */ + public array $players = []; + + public string $state = self::STATE_LOBBY; + + /** Player id of the room's host (the first player to join). */ + public string $hostId = ''; + + public float $elapsed = 0.0; + + public float $nextBossScore = 50.0; + + public int $bossCount = 0; + + public float $bossCooldown = 0.0; + + public float $spawnTimer = 0.0; + + public float $pickupTimer = 8.0; + + /** @var array> Queue of one-shot event descriptors for clients. */ + public array $events = []; + + public int $seq = 0; + + public function __construct( + public readonly string $id, + public readonly int $maxPlayers = 8, + ) { + $this->entities = new EntityList; + } + + public function addPlayer(Player $player): void + { + $this->players[$player->id] = $player; + $this->entities->append($player); + } + + public function removePlayer(Player $player): void + { + unset($this->players[$player->id]); + $this->entities->remove($player); + } + + /** Reset round state so the room can be played again (host-initiated). */ + public function reset(): void + { + $this->state = self::STATE_LOBBY; + $this->elapsed = 0.0; + $this->nextBossScore = 50.0; + $this->bossCount = 0; + $this->bossCooldown = 0.0; + $this->spawnTimer = 0.0; + $this->pickupTimer = 8.0; + $this->events = []; + $this->seq++; + + foreach ($this->entities->copy() as $entity) { + if ($entity instanceof Player) { + continue; + } + $this->entities->remove($entity); + } + + foreach ($this->players as $player) { + $player->resetForRound(); + } + } + + /** @return array */ + public function alivePlayers(): array + { + $alive = []; + foreach ($this->players as $player) { + if (! $player->dead) { + $alive[] = $player; + } + } + + return $alive; + } + + public function highestScore(): int + { + $best = 0; + foreach ($this->players as $player) { + $best = max($best, $player->score); + } + + return $best; + } + + public function hasBoss(): bool + { + foreach ($this->entities as $entity) { + if ($entity instanceof Entities\Boss) { + return true; + } + } + + return false; + } + + public function pushEvent(string $kind, array $data = []): void + { + $this->events[] = ['k' => $kind, 'x' => $data['x'] ?? 0.0, 'y' => $data['y'] ?? 0.0, 'd' => $data]; + } + + public function flushEvents(): array + { + $events = $this->events; + $this->events = []; + + return $events; + } +} diff --git a/app/Game/Spawner.php b/app/Game/Spawner.php new file mode 100644 index 0000000..1e9d92d --- /dev/null +++ b/app/Game/Spawner.php @@ -0,0 +1,331 @@ +elapsed; + $width = $room->width; + $height = $room->height; + $speedMultiplier = $this->speedMultiplier($elapsed); + + $isMegaTank = $this->chance($this->megaTankChance($elapsed)); + $isMegaWitch = ! $isMegaTank && $this->chance($this->megaWitchChance($elapsed)); + $isWitch = ! $isMegaTank && ! $isMegaWitch && $this->chance($this->witchChance($elapsed)); + $isRunner = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && $this->chance($this->runnerChance($elapsed)); + $isLeaper = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && ! $isRunner && $this->chance($this->leaperChance($elapsed)); + $isTank = ! $isMegaTank && ! $isMegaWitch && ! $isWitch && ! $isRunner && ! $isLeaper && $this->chance($this->tankChance($elapsed)); + + $size = $isMegaWitch ? 52.0 : ($isWitch ? 30.0 : ($isRunner ? 14.0 : ($isLeaper ? 28.0 : ($isTank ? 36.0 : 24.0)))); + + [$x, $y] = $this->edgePosition($room, $size); + + if ($isMegaTank) { + $room->entities->append(new MegaTank($x, $y, $room->entities, $room, $this->initialTarget($room), [ + 'speed' => 28.0 + $this->rand() * 12.0, + 'size' => 64.0, + 'health' => 18, + 'knockback' => 90.0, + 'scoreValue' => 15, + 'spawnCount' => 3, + 'color' => [0.55, 0.28, 0.12, 1.0], + 'spawnOnDeath' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void { + $container->append(new Zombie($sx, $sy, $container, $sroom, [ + 'speed' => (70.0 + $this->rand() * 60.0) * $this->speedMultiplier($sroom->elapsed), + 'size' => 24.0, + 'health' => 1, + 'knockback' => 260.0, + 'scoreValue' => 1, + 'color' => [0.15, 0.65, 0.2, 1.0], + 'emergeTime' => 0.8, + ], fn (Zombie $z) => $z->target = $starget)); + $sroom->pushEvent('portal', ['x' => $sx, 'y' => $sy, 'color' => [0.15, 0.65, 0.2, 1.0], 'scale' => 0.8]); + }, + ], null)); + $room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.55, 0.28, 0.12, 1.0], 'scale' => 2.2]); + + return; + } + + if ($isMegaWitch) { + $megaPower = min(1.0, max(0.0, ($elapsed - 45.0) / 90.0)); + $this->spawnMegaWitch($room, $x, $y, $speedMultiplier, $megaPower); + + return; + } + + if ($isWitch) { + $this->spawnWitch($room, $x, $y, $speedMultiplier); + + return; + } + + if ($isRunner) { + $room->entities->append(new Zombie($x, $y, $room->entities, $room, [ + 'speed' => (180.0 + $this->rand() * 60.0) * $speedMultiplier, + 'size' => $size, + 'health' => 1, + 'knockback' => 380.0, + 'scoreValue' => 2, + 'color' => [0.85, 0.1, 0.2, 1.0], + ], fn (Zombie $z) => $z->target = $this->initialTarget($room))); + $room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.85, 0.1, 0.2, 1.0], 'scale' => 0.9]); + + return; + } + + if ($isLeaper) { + $this->spawnLeaper($room, $x, $y, $speedMultiplier); + + return; + } + + $zombieColor = $isTank ? [0.3, 0.35, 0.15, 1.0] : [0.15, 0.65, 0.2, 1.0]; + $room->entities->append(new Zombie($x, $y, $room->entities, $room, [ + 'speed' => ($isTank ? 45.0 : 80.0 + $this->rand() * 60.0) * $speedMultiplier, + 'size' => $size, + 'health' => $isTank ? 4 : 1, + 'knockback' => $isTank ? 160.0 : 260.0, + 'scoreValue' => $isTank ? 3 : 1, + 'color' => $zombieColor, + ], fn (Zombie $z) => $z->target = $this->initialTarget($room))); + $room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => $zombieColor, 'scale' => $isTank ? 1.5 : 1.0]); + } + + public function spawnWitch(Room $room, float $x, float $y, float $speedMultiplier = 1.0, int $emergeTime = 0): void + { + $room->entities->append(new Witch($x, $y, $room->entities, $room, $this->initialTarget($room), [ + 'speed' => (70.0 + $this->rand() * 40.0) * $speedMultiplier, + 'size' => 30.0, + 'health' => 5, + 'knockback' => 180.0, + 'scoreValue' => 4, + 'color' => [0.55, 0.2, 0.8, 1.0], + 'preferredDistance' => 220.0, + 'fireInterval' => 2.2, + 'projectileSpeed' => 220.0, + 'emergeTime' => $emergeTime, + ], fn (Zombie $z) => $z->target = $this->initialTarget($room))); + $room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.55, 0.2, 0.8, 1.0], 'scale' => 1.2]); + } + + public function spawnMegaWitch(Room $room, float $x, float $y, float $speedMultiplier = 1.0, float $power = 1.0): void + { + $room->entities->append(new MegaWitch($x, $y, $room->entities, $room, $this->initialTarget($room), [ + 'speed' => 55.0 * $speedMultiplier, + 'size' => 52.0, + 'health' => (int) round(8.0 + 14.0 * $power), + 'knockback' => 130.0, + 'scoreValue' => 12, + 'color' => [0.8, 0.15, 0.7, 1.0], + 'preferredDistance' => 240.0, + 'fireInterval' => 2.0, + 'projectileSpeed' => 240.0, + 'projectileCount' => 3, + 'projectileSpread' => 0.35, + 'projectileColor' => [1.0, 0.3, 0.3, 1.0], + 'novaInterval' => 6.0 - $power, + 'novaCount' => (int) round(10.0 + 6.0 * $power), + 'novaProjectileSpeed' => 210.0 + 40.0 * $power, + 'spawnCount' => 3, + 'spawnOnDeath' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void { + $container->append(new Witch($sx, $sy, $container, $sroom, $starget, [ + 'speed' => 70.0 + $this->rand() * 40.0, + 'size' => 30.0, + 'health' => 5, + 'knockback' => 180.0, + 'scoreValue' => 4, + 'color' => [0.55, 0.2, 0.8, 1.0], + 'preferredDistance' => 220.0, + 'fireInterval' => 2.2, + 'projectileSpeed' => 220.0, + 'emergeTime' => 1.1, + ], fn (Zombie $z) => $z->target = $starget)); + $sroom->pushEvent('portal', ['x' => $sx, 'y' => $sy, 'color' => [0.55, 0.2, 0.8, 1.0], 'scale' => 1.2]); + }, + ], null)); + $room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.8, 0.15, 0.7, 1.0], 'scale' => 2.2]); + } + + public function spawnLeaper(Room $room, float $x, float $y, float $speedMultiplier = 1.0, int $emergeTime = 0): void + { + $room->entities->append(new Leaper($x, $y, $room->entities, $room, $this->initialTarget($room), [ + 'speed' => (80.0 + $this->rand() * 40.0) * $speedMultiplier, + 'size' => 28.0, + 'health' => 3, + 'knockback' => 240.0, + 'scoreValue' => 6, + 'color' => [0.15, 0.25, 0.75, 1.0], + 'dashRange' => 300.0, + 'windupTime' => 0.35, + 'dashTime' => 0.28, + 'dashSpeed' => 680.0, + 'dashCooldown' => 1.8, + 'emergeTime' => $emergeTime, + ], fn (Zombie $z) => $z->target = $this->initialTarget($room))); + $room->pushEvent('portal', ['x' => $x, 'y' => $y, 'color' => [0.15, 0.25, 0.75, 1.0], 'scale' => 1.2]); + } + + public function spawnBoss(Room $room): void + { + $width = $room->width; + $witchCount = min(4, 2 ** $room->bossCount); + $room->bossCount += 1; + $room->entities->append(new Boss($width / 2.0, -80.0, $room->entities, $room, $this->initialTarget($room), [ + 'speed' => 36.0 + $this->rand() * 10.0, + 'size' => 90.0, + 'health' => 80, + 'knockback' => 40.0, + 'scoreValue' => 50, + 'color' => [0.45, 0.05, 0.1, 1.0], + 'spawnInterval' => 5.0, + 'spawnCount' => $witchCount, + 'spawnZombie' => function (float $sx, float $sy, EntityList $container, Room $sroom, ?Player $starget): void { + if ($this->chance(0.5)) { + $container->append(new Witch($sx, $sy, $container, $sroom, $starget, [ + 'speed' => (70.0 + $this->rand() * 40.0) * $this->speedMultiplier($sroom->elapsed), + 'size' => 30.0, + 'health' => 5, + 'knockback' => 180.0, + 'scoreValue' => 4, + 'color' => [0.55, 0.2, 0.8, 1.0], + 'preferredDistance' => 220.0, + 'fireInterval' => 2.2, + 'projectileSpeed' => 220.0, + 'emergeTime' => 0.9, + ], fn (Zombie $z) => $z->target = $starget)); + } else { + $container->append(new Leaper($sx, $sy, $container, $sroom, $starget, [ + 'speed' => (80.0 + $this->rand() * 40.0) * $this->speedMultiplier($sroom->elapsed), + 'size' => 28.0, + 'health' => 3, + 'knockback' => 240.0, + 'scoreValue' => 6, + 'color' => [0.15, 0.25, 0.75, 1.0], + 'dashRange' => 300.0, + 'windupTime' => 0.35, + 'dashTime' => 0.28, + 'dashSpeed' => 680.0, + 'dashCooldown' => 1.8, + 'emergeTime' => 0.9, + ], fn (Zombie $z) => $z->target = $starget)); + } + }, + ], fn (Zombie $z) => $z->target = $this->initialTarget($room))); + $room->pushEvent('portal', ['x' => $width / 2.0, 'y' => -80.0, 'color' => [0.45, 0.05, 0.1, 1.0], 'scale' => 3.5]); + $room->pushEvent('boss', [ + 'x' => $width / 2.0, + 'y' => $room->height / 2.0, + 'title' => 'BOSS APPROACHES', + ]); + } + + public function spawnPickup(Room $room): void + { + $available = []; + foreach (array_keys(Pickup::TYPES) as $type) { + $needed = false; + foreach ($room->alivePlayers() as $player) { + if (($player->upgrades[$type] ?? 0) < $player->maxUpgradeLevel) { + $needed = true; + break; + } + } + if ($needed) { + $available[] = $type; + } + } + if ($available === []) { + return; + } + $margin = 60.0; + $x = $margin + $this->rand() * ($room->width - $margin * 2.0); + $y = $margin + $this->rand() * ($room->height - $margin * 2.0); + $type = $available[(int) floor($this->rand() * count($available))]; + $room->entities->append(new Pickup($x, $y, $type, $room->entities, $room)); + } + + /** @return array{0:float, 1:float} */ + private function edgePosition(Room $room, float $size): array + { + $width = $room->width; + $height = $room->height; + $edge = (int) floor($this->rand() * 4.0); + + return match ($edge) { + 0 => [$this->rand() * $width, -$size], + 1 => [$this->rand() * $width, $height + $size], + 2 => [-$size, $this->rand() * $height], + default => [$width + $size, $this->rand() * $height], + }; + } + + private function initialTarget(Room $room): ?Player + { + $alive = $room->alivePlayers(); + + return $alive === [] ? null : $alive[array_rand($alive)]; + } + + private function chance(float $probability): bool + { + return $this->rand() < $probability; + } + + private function rand(): float + { + return random_int(0, 1000000) / 1000000.0; + } +} diff --git a/app/Game/Vector2.php b/app/Game/Vector2.php new file mode 100644 index 0000000..4cdec0e --- /dev/null +++ b/app/Game/Vector2.php @@ -0,0 +1,91 @@ +x * $scalar, $this->y * $scalar); + } + + public function mulV(self $v): self + { + return new self($this->x * $v->x, $this->y * $v->y); + } + + public function divS(float $scalar): self + { + return new self($this->x / $scalar, $this->y / $scalar); + } + + public function addS(float $scalar): self + { + return new self($this->x + $scalar, $this->y + $scalar); + } + + public function addV(self $v): self + { + return new self($this->x + $v->x, $this->y + $v->y); + } + + public function subS(float $scalar): self + { + return new self($this->x - $scalar, $this->y - $scalar); + } + + public function subV(self $v): self + { + return new self($this->x - $v->x, $this->y - $v->y); + } + + public function dot(self $v): float + { + return $this->x * $v->x + $this->y * $v->y; + } + + public function abs(): self + { + return new self(abs($this->x), abs($this->y)); + } + + public function length(): float + { + return sqrt($this->dot($this)); + } + + public function lengthSquared(): float + { + return $this->dot($this); + } + + public function normalize(): self + { + $length = $this->length(); + if ($length < 1.0e-9) { + return new self(0.0, 0.0); + } + + return new self($this->x / $length, $this->y / $length); + } + + public function rotate(float $angle): self + { + $cos = cos($angle); + $sin = sin($angle); + + return new self($this->x * $cos - $this->y * $sin, $this->x * $sin + $this->y * $cos); + } +} diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php new file mode 100644 index 0000000..8677cd5 --- /dev/null +++ b/app/Http/Controllers/Controller.php @@ -0,0 +1,8 @@ +input('id', ''); + if ($playerId === '') { + return response()->json(['error' => 'missing id'], 422); + } + + $keys = $request->input('keys'); + $keys = is_array($keys) + ? array_values(array_filter($keys, 'is_string')) + : []; + + $aim = $request->input('aim'); + $aim = is_array($aim) + ? ['x' => (float) ($aim['x'] ?? 0.0), 'y' => (float) ($aim['y'] ?? 0.0)] + : ['x' => 1.0, 'y' => 0.0]; + + $frame = [ + 'id' => $playerId, + 'keys' => $keys, + 'aim' => $aim, + 'fire' => (bool) $request->input('fire', false), + 'autoAim' => (bool) $request->input('autoAim', false), + ]; + + Redis::rpush(sprintf('game:in:%s', $room), json_encode($frame, JSON_THROW_ON_ERROR)); + + return response()->json(['ok' => true]); + } +} \ No newline at end of file diff --git a/app/Listeners/PushClientEventToInputQueue.php b/app/Listeners/PushClientEventToInputQueue.php new file mode 100644 index 0000000..28b6b61 --- /dev/null +++ b/app/Listeners/PushClientEventToInputQueue.php @@ -0,0 +1,86 @@ +message, true); + if (! is_array($payload)) { + return; + } + + $clientEvent = $payload['event'] ?? null; + if (! is_string($clientEvent) || ! str_starts_with($clientEvent, 'client-')) { + return; + } + + $channel = $payload['channel'] ?? null; + if (! is_string($channel)) { + return; + } + $roomId = $this->roomFromChannel($channel); + if ($roomId === null) { + return; + } + + $data = $payload['data'] ?? []; + if (! is_array($data)) { + return; + } + + $type = substr($clientEvent, strlen('client-')); + + if ($type === 'input') { + $this->pushInput($roomId, $data); + + return; + } + + if (in_array($type, self::CONTROL_TYPES, true)) { + $frame = array_merge($data, ['type' => $type]); + Redis::rpush(sprintf(self::CONTROL_QUEUE, $roomId), json_encode($frame, JSON_THROW_ON_ERROR)); + } + } + + private function pushInput(string $roomId, array $data): void + { + if (! isset($data['id'])) { + return; + } + + $frame = [ + 'id' => (string) $data['id'], + 'keys' => is_array($data['keys'] ?? null) ? ($data['keys']) : [], + 'aim' => is_array($data['aim'] ?? null) + ? ['x' => (float) ($data['aim']['x'] ?? 0.0), 'y' => (float) ($data['aim']['y'] ?? 0.0)] + : ['x' => 1.0, 'y' => 0.0], + 'fire' => (bool) ($data['fire'] ?? false), + 'autoAim' => (bool) ($data['autoAim'] ?? false), + ]; + + Redis::rpush(sprintf(self::INPUT_QUEUE, $roomId), json_encode($frame, JSON_THROW_ON_ERROR)); + } + + private function roomFromChannel(string $channel): ?string + { + // game.{room}.input + if (preg_match('#^game\.(.+?)\.input$#', $channel, $matches) !== 1) { + return null; + } + + return $matches[1]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php new file mode 100644 index 0000000..f6ba1d2 --- /dev/null +++ b/app/Models/User.php @@ -0,0 +1,32 @@ + */ + use HasFactory, Notifiable; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php new file mode 100644 index 0000000..452e6b6 --- /dev/null +++ b/app/Providers/AppServiceProvider.php @@ -0,0 +1,24 @@ +handleCommand(new ArgvInput); + +exit($status); diff --git a/boost.json b/boost.json new file mode 100644 index 0000000..a8520d1 --- /dev/null +++ b/boost.json @@ -0,0 +1,17 @@ +{ + "agents": [ + "claude_code", + "opencode" + ], + "cloud": false, + "guidelines": true, + "mcp": true, + "nightwatch": false, + "sail": false, + "skills": [ + "infer-conventions", + "laravel-best-practices", + "testing-best-practices", + "tailwindcss-development" + ] +} diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..fbc92b8 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,23 @@ +withRouting( + web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + commands: __DIR__.'/../routes/console.php', + channels: __DIR__.'/../routes/channels.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + // + }) + ->withExceptions(function (Exceptions $exceptions): void { + $exceptions->shouldRenderJsonWhen( + fn (Request $request) => $request->is('api/*') || $request->expectsJson(), + ); + })->create(); diff --git a/bootstrap/cache/.gitignore b/bootstrap/cache/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/bootstrap/cache/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/bootstrap/providers.php b/bootstrap/providers.php new file mode 100644 index 0000000..fc94ae6 --- /dev/null +++ b/bootstrap/providers.php @@ -0,0 +1,7 @@ +=5.0.0" + }, + "require-dev": { + "doctrine/dbal": "^4.0.0", + "nesbot/carbon": "^2.71.0 || ^3.0.0", + "phpunit/phpunit": "^10.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\Doctrine\\": "src/Carbon/Doctrine/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "KyleKatarn", + "email": "kylekatarnls@gmail.com" + } + ], + "description": "Types to use Carbon in Doctrine", + "keywords": [ + "carbon", + "date", + "datetime", + "doctrine", + "time" + ], + "support": { + "issues": "https://github.com/CarbonPHP/carbon-doctrine-types/issues", + "source": "https://github.com/CarbonPHP/carbon-doctrine-types/tree/3.2.1" + }, + "funding": [ + { + "url": "https://github.com/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon", + "type": "open_collective" + }, + { + "url": "https://tidelift.com/funding/github/packagist/nesbot/carbon", + "type": "tidelift" + } + ], + "time": "2026-09-06T14:11:38+00:00" + }, + { + "name": "clue/redis-protocol", + "version": "v0.3.2", + "source": { + "type": "git", + "url": "https://github.com/clue/redis-protocol.git", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/redis-protocol/zipball/6f565332f5531b7722d1e9c445314b91862f6d6c", + "reference": "6f565332f5531b7722d1e9c445314b91862f6d6c", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\Redis\\Protocol\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@lueck.tv" + } + ], + "description": "A streaming Redis protocol (RESP) parser and serializer written in pure PHP.", + "homepage": "https://github.com/clue/redis-protocol", + "keywords": [ + "parser", + "protocol", + "redis", + "resp", + "serializer", + "streaming" + ], + "support": { + "issues": "https://github.com/clue/redis-protocol/issues", + "source": "https://github.com/clue/redis-protocol/tree/v0.3.2" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2024-08-07T11:06:28+00:00" + }, + { + "name": "clue/redis-react", + "version": "v2.8.0", + "source": { + "type": "git", + "url": "https://github.com/clue/reactphp-redis.git", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/clue/reactphp-redis/zipball/84569198dfd5564977d2ae6a32de4beb5a24bdca", + "reference": "84569198dfd5564977d2ae6a32de4beb5a24bdca", + "shasum": "" + }, + "require": { + "clue/redis-protocol": "^0.3.2", + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.0 || ^1.1", + "react/promise-timer": "^1.11", + "react/socket": "^1.16" + }, + "require-dev": { + "clue/block-react": "^1.5", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "Clue\\React\\Redis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering" + } + ], + "description": "Async Redis client implementation, built on top of ReactPHP.", + "homepage": "https://github.com/clue/reactphp-redis", + "keywords": [ + "async", + "client", + "database", + "reactphp", + "redis" + ], + "support": { + "issues": "https://github.com/clue/reactphp-redis/issues", + "source": "https://github.com/clue/reactphp-redis/tree/v2.8.0" + }, + "funding": [ + { + "url": "https://clue.engineering/support", + "type": "custom" + }, + { + "url": "https://github.com/clue", + "type": "github" + } + ], + "time": "2025-01-03T16:18:33+00:00" + }, + { + "name": "dflydev/dot-access-data", + "version": "v3.0.3", + "source": { + "type": "git", + "url": "https://github.com/dflydev/dflydev-dot-access-data.git", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^0.12.42", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.3", + "scrutinizer/ocular": "1.6.0", + "squizlabs/php_codesniffer": "^3.5", + "vimeo/psalm": "^4.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Dflydev\\DotAccessData\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dragonfly Development Inc.", + "email": "info@dflydev.com", + "homepage": "http://dflydev.com" + }, + { + "name": "Beau Simensen", + "email": "beau@dflydev.com", + "homepage": "http://beausimensen.com" + }, + { + "name": "Carlos Frutos", + "email": "carlos@kiwing.it", + "homepage": "https://github.com/cfrutos" + }, + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com" + } + ], + "description": "Given a deep data structure, access data by dot notation.", + "homepage": "https://github.com/dflydev/dflydev-dot-access-data", + "keywords": [ + "access", + "data", + "dot", + "notation" + ], + "support": { + "issues": "https://github.com/dflydev/dflydev-dot-access-data/issues", + "source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3" + }, + "time": "2024-07-08T12:26:09+00:00" + }, + { + "name": "doctrine/inflector", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/inflector.git", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/inflector/zipball/6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "reference": "6d6c96277ea252fc1304627204c3d5e6e15faa3b", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "require-dev": { + "doctrine/coding-standard": "^12.0 || ^13.0", + "phpstan/phpstan": "^1.12 || ^2.0", + "phpstan/phpstan-phpunit": "^1.4 || ^2.0", + "phpstan/phpstan-strict-rules": "^1.6 || ^2.0", + "phpunit/phpunit": "^8.5 || ^12.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Inflector\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + }, + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Inflector is a small library that can perform string manipulations with regard to upper/lowercase and singular/plural forms of words.", + "homepage": "https://www.doctrine-project.org/projects/inflector.html", + "keywords": [ + "inflection", + "inflector", + "lowercase", + "manipulation", + "php", + "plural", + "singular", + "strings", + "uppercase", + "words" + ], + "support": { + "issues": "https://github.com/doctrine/inflector/issues", + "source": "https://github.com/doctrine/inflector/tree/2.1.0" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Finflector", + "type": "tidelift" + } + ], + "time": "2025-08-10T19:31:58+00:00" + }, + { + "name": "doctrine/lexer", + "version": "3.0.1", + "source": { + "type": "git", + "url": "https://github.com/doctrine/lexer.git", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/lexer/zipball/31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "reference": "31ad66abc0fc9e1a1f2d9bc6a42668d2fbbcd6dd", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "doctrine/coding-standard": "^12", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5", + "psalm/plugin-phpunit": "^0.18.3", + "vimeo/psalm": "^5.21" + }, + "type": "library", + "autoload": { + "psr-4": { + "Doctrine\\Common\\Lexer\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Guilherme Blanco", + "email": "guilhermeblanco@gmail.com" + }, + { + "name": "Roman Borschel", + "email": "roman@code-factory.org" + }, + { + "name": "Johannes Schmitt", + "email": "schmittjoh@gmail.com" + } + ], + "description": "PHP Doctrine Lexer parser library that can be used in Top-Down, Recursive Descent Parsers.", + "homepage": "https://www.doctrine-project.org/projects/lexer.html", + "keywords": [ + "annotations", + "docblock", + "lexer", + "parser", + "php" + ], + "support": { + "issues": "https://github.com/doctrine/lexer/issues", + "source": "https://github.com/doctrine/lexer/tree/3.0.1" + }, + "funding": [ + { + "url": "https://www.doctrine-project.org/sponsorship.html", + "type": "custom" + }, + { + "url": "https://www.patreon.com/phpdoctrine", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/doctrine%2Flexer", + "type": "tidelift" + } + ], + "time": "2024-02-05T11:56:58+00:00" + }, + { + "name": "dragonmantank/cron-expression", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/dragonmantank/cron-expression.git", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dragonmantank/cron-expression/zipball/d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "reference": "d61a8a9604ec1f8c3d150d09db6ce98b32675013", + "shasum": "" + }, + "require": { + "php": "^8.2|^8.3|^8.4|^8.5" + }, + "replace": { + "mtdowling/cron-expression": "^1.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.32|^2.1.31", + "phpunit/phpunit": "^8.5.48|^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Cron\\": "src/Cron/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Tankersley", + "email": "chris@ctankersley.com", + "homepage": "https://github.com/dragonmantank" + } + ], + "description": "CRON for PHP: Calculate the next or previous run date and determine if a CRON expression is due", + "keywords": [ + "cron", + "schedule" + ], + "support": { + "issues": "https://github.com/dragonmantank/cron-expression/issues", + "source": "https://github.com/dragonmantank/cron-expression/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/dragonmantank", + "type": "github" + } + ], + "time": "2025-10-31T18:51:33+00:00" + }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, + { + "name": "evenement/evenement", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/igorw/evenement.git", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", + "shasum": "" + }, + "require": { + "php": ">=7.0" + }, + "require-dev": { + "phpunit/phpunit": "^9 || ^6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Evenement\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + } + ], + "description": "Événement is a very simple event dispatching library for PHP", + "keywords": [ + "event-dispatcher", + "event-emitter" + ], + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fruitcake/php-cors", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/fruitcake/php-cors.git", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fruitcake/php-cors/zipball/38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "reference": "38aaa6c3fd4c157ffe2a4d10aa8b9b16ba8de379", + "shasum": "" + }, + "require": { + "php": "^8.1", + "symfony/http-foundation": "^5.4|^6.4|^7.3|^8" + }, + "require-dev": { + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^4" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Fruitcake\\Cors\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fruitcake", + "homepage": "https://fruitcake.nl" + }, + { + "name": "Barryvdh", + "email": "barryvdh@gmail.com" + } + ], + "description": "Cross-origin resource sharing library for the Symfony HttpFoundation", + "homepage": "https://github.com/fruitcake/php-cors", + "keywords": [ + "cors", + "laravel", + "symfony" + ], + "support": { + "issues": "https://github.com/fruitcake/php-cors/issues", + "source": "https://github.com/fruitcake/php-cors/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://fruitcake.nl", + "type": "custom" + }, + { + "url": "https://github.com/barryvdh", + "type": "github" + } + ], + "time": "2025-12-03T09:33:47+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/GrahamCampbell/Result-Type.git", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/adccca3324eece92ca35463648c12b9e6293c05b", + "reference": "adccca3324eece92ca35463648c12b9e6293c05b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.52 || ^9.6.34 || ^10.5.63 || ^11.5.55 || ^12.5.14" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:06:52+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.15.5", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "reference": "ee80339fd9177ba44c49cdb653ff02a4d1106b9a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.5.3", + "guzzlehttp/psr7": "^2.13.1", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.3", + "guzzlehttp/test-server": "^0.7", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.15.5" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:21:06+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.5.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "reference": "cde49999552d185d64715fe9c1f77a2aadd2f9f1", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.5.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:11:28+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.13.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "95e7828100de18b4e269fb1703be530082d5166d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/95e7828100de18b4e269fb1703be530082d5166d", + "reference": "95e7828100de18b4e269fb1703be530082d5166d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.13.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-08-24T09:13:11+00:00" + }, + { + "name": "guzzlehttp/uri-template", + "version": "v2.0.1", + "source": { + "type": "git", + "url": "https://github.com/guzzle/uri-template.git", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/uri-template/zipball/7a466ad606491eb6528c717482f7cca77f1851f3", + "reference": "7a466ad606491eb6528c717482f7cca77f1851f3", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "symfony/polyfill-php80": "^1.25" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^9.6.34", + "uri-template/tests": "1.0.2" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\UriTemplate\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + } + ], + "description": "A polyfill class for uri_template of PHP", + "keywords": [ + "guzzlehttp", + "uri-template" + ], + "support": { + "issues": "https://github.com/guzzle/uri-template/issues", + "source": "https://github.com/guzzle/uri-template/tree/v2.0.1" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/uri-template", + "type": "tidelift" + } + ], + "time": "2026-08-24T17:13:02+00:00" + }, + { + "name": "laravel/framework", + "version": "v13.30.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/framework.git", + "reference": "718d17db56861e0a49f644217c8853dab1bff8ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/framework/zipball/718d17db56861e0a49f644217c8853dab1bff8ce", + "reference": "718d17db56861e0a49f644217c8853dab1bff8ce", + "shasum": "" + }, + "require": { + "brick/math": "^0.14.2 || ^0.15 || ^0.16 || ^0.17 || ^0.18 || ^0.19", + "composer-runtime-api": "^2.2", + "doctrine/inflector": "^2.0.5", + "dragonmantank/cron-expression": "^3.4", + "egulias/email-validator": "^4.0", + "ext-ctype": "*", + "ext-filter": "*", + "ext-hash": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-session": "*", + "ext-tokenizer": "*", + "fruitcake/php-cors": "^1.3", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.9 || ^3.0", + "guzzlehttp/uri-template": "^1.0 || ^2.0", + "laravel/prompts": "^0.3.11", + "laravel/serializable-closure": "^2.0.10", + "league/commonmark": "^2.8.1", + "league/flysystem": "^3.25.1", + "league/flysystem-local": "^3.25.1", + "league/uri": "^7.5.1", + "monolog/monolog": "^3.10", + "nesbot/carbon": "^3.8.4", + "nunomaduro/termwind": "^2.0", + "php": "^8.3", + "psr/container": "^1.1.1 || ^2.0.1", + "psr/http-message": "^1.0 || ^2.0", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "psr/simple-cache": "^1.0 || ^2.0 || ^3.0", + "ramsey/uuid": "^4.7", + "symfony/console": "^7.4.0 || ^8.0.0", + "symfony/error-handler": "^7.4.0 || ^8.0.0", + "symfony/finder": "^7.4.0 || ^8.0.0", + "symfony/http-foundation": "^7.4.0 || ^8.0.0", + "symfony/http-kernel": "^7.4.0 || ^8.0.0", + "symfony/mailer": "^7.4.0 || ^8.0.0", + "symfony/mime": "^7.4.0 || ^8.0.0", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", + "symfony/polyfill-php86": "^1.36", + "symfony/process": "^7.4.5 || ^8.0.5", + "symfony/routing": "^7.4.0 || ^8.0.0", + "symfony/uid": "^7.4.0 || ^8.0.0", + "symfony/var-dumper": "^7.4.0 || ^8.0.0", + "tijsverkoyen/css-to-inline-styles": "^2.2.5", + "vlucas/phpdotenv": "^5.6.1", + "voku/portable-ascii": "^2.0.2" + }, + "conflict": { + "tightenco/collect": "<5.5.33" + }, + "provide": { + "psr/container-implementation": "1.1 || 2.0", + "psr/log-implementation": "1.0 || 2.0 || 3.0", + "psr/simple-cache-implementation": "1.0 || 2.0 || 3.0" + }, + "replace": { + "illuminate/auth": "self.version", + "illuminate/broadcasting": "self.version", + "illuminate/bus": "self.version", + "illuminate/cache": "self.version", + "illuminate/collections": "self.version", + "illuminate/concurrency": "self.version", + "illuminate/conditionable": "self.version", + "illuminate/config": "self.version", + "illuminate/console": "self.version", + "illuminate/container": "self.version", + "illuminate/contracts": "self.version", + "illuminate/cookie": "self.version", + "illuminate/database": "self.version", + "illuminate/encryption": "self.version", + "illuminate/events": "self.version", + "illuminate/filesystem": "self.version", + "illuminate/hashing": "self.version", + "illuminate/http": "self.version", + "illuminate/image": "self.version", + "illuminate/json-schema": "self.version", + "illuminate/log": "self.version", + "illuminate/macroable": "self.version", + "illuminate/mail": "self.version", + "illuminate/notifications": "self.version", + "illuminate/pagination": "self.version", + "illuminate/pipeline": "self.version", + "illuminate/process": "self.version", + "illuminate/queue": "self.version", + "illuminate/redis": "self.version", + "illuminate/reflection": "self.version", + "illuminate/routing": "self.version", + "illuminate/session": "self.version", + "illuminate/support": "self.version", + "illuminate/testing": "self.version", + "illuminate/translation": "self.version", + "illuminate/validation": "self.version", + "illuminate/view": "self.version", + "spatie/once": "*" + }, + "require-dev": { + "ably/ably-php": "^1.0", + "aws/aws-sdk-php": "^3.322.9", + "ext-gmp": "*", + "fakerphp/faker": "^1.24", + "intervention/image": "^4.0", + "laravel/pint": "^1.18", + "league/flysystem-aws-s3-v3": "^3.25.1", + "league/flysystem-ftp": "^3.25.1", + "league/flysystem-path-prefixing": "^3.25.1", + "league/flysystem-read-only": "^3.25.1", + "league/flysystem-sftp-v3": "^3.25.1", + "mockery/mockery": "^1.6.10", + "opis/json-schema": "^2.4.1", + "orchestra/testbench-core": "^11.0.0", + "pda/pheanstalk": "^7.0.0 || ^8.0.0", + "php-http/discovery": "^1.15", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^11.5.50 || ^12.5.8 || ^13.0.3", + "predis/predis": "^2.3 || ^3.0", + "rector/rector": "2.6.3", + "resend/resend-php": "^1.0", + "symfony/cache": "^7.4.0 || ^8.0.0", + "symfony/http-client": "^7.4.0 || ^8.0.0", + "symfony/psr-http-message-bridge": "^7.4.0 || ^8.0.0", + "symfony/translation": "^7.4.0 || ^8.0.0" + }, + "suggest": { + "ably/ably-php": "Required to use the Ably broadcast driver (^1.0).", + "aws/aws-sdk-php": "Required to use the SQS queue driver, DynamoDb failed job storage, and SES mail driver (^3.322.9).", + "brianium/paratest": "Required to run tests in parallel (^7.0 || ^8.0).", + "ext-apcu": "Required to use the APC cache driver.", + "ext-fileinfo": "Required to use the Filesystem class.", + "ext-ftp": "Required to use the Flysystem FTP driver.", + "ext-gd": "Required to use Illuminate\\Http\\Testing\\FileFactory::image().", + "ext-memcached": "Required to use the memcache cache driver.", + "ext-pcntl": "Required to use all features of the queue worker and console signal trapping.", + "ext-pdo": "Required to use all database features.", + "ext-posix": "Required to use all features of the queue worker.", + "ext-redis": "Required to use the Redis cache and queue drivers (^4.0 || ^5.0 || ^6.0).", + "fakerphp/faker": "Required to generate fake data using the fake() helper (^1.23).", + "filp/whoops": "Required for friendly error pages in development (^2.14.3).", + "intervention/image": "Required to use the image processing features (^4.0).", + "laravel/tinker": "Required to use the tinker console command (^2.0).", + "league/flysystem-aws-s3-v3": "Required to use the Flysystem S3 driver (^3.25.1).", + "league/flysystem-ftp": "Required to use the Flysystem FTP driver (^3.25.1).", + "league/flysystem-path-prefixing": "Required to use the scoped driver (^3.25.1).", + "league/flysystem-read-only": "Required to use read-only disks (^3.25.1)", + "league/flysystem-sftp-v3": "Required to use the Flysystem SFTP driver (^3.25.1).", + "mockery/mockery": "Required to use mocking (^1.6).", + "pda/pheanstalk": "Required to use the beanstalk queue driver (^7.0 || ^8.0).", + "php-http/discovery": "Required to use PSR-7 bridging features (^1.15).", + "phpunit/phpunit": "Required to use assertions and run tests (^11.5.50 || ^12.5.8 || ^13.0.3).", + "predis/predis": "Required to use the predis connector (^2.3 || ^3.0).", + "pusher/pusher-php-server": "Required to use the Pusher broadcast driver (^6.0 || ^7.0).", + "resend/resend-php": "Required to enable support for the Resend mail transport (^0.10.0 || ^1.0).", + "spatie/fork": "Required to use the 'fork' concurrency driver (^1.2).", + "symfony/cache": "Required to PSR-6 cache bridge (^7.4 || ^8.0).", + "symfony/filesystem": "Required to enable support for relative symbolic links (^7.4 || ^8.0).", + "symfony/http-client": "Required to enable support for the Symfony API mail transports (^7.4 || ^8.0).", + "symfony/mailgun-mailer": "Required to enable support for the Mailgun mail transport (^7.4 || ^8.0).", + "symfony/postmark-mailer": "Required to enable support for the Postmark mail transport (^7.4 || ^8.0).", + "symfony/psr-http-message-bridge": "Required to use PSR-7 bridging features (^7.4 || ^8.0)." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "13.0.x-dev" + } + }, + "autoload": { + "files": [ + "src/Illuminate/Collections/functions.php", + "src/Illuminate/Collections/helpers.php", + "src/Illuminate/Events/functions.php", + "src/Illuminate/Filesystem/functions.php", + "src/Illuminate/Foundation/helpers.php", + "src/Illuminate/Log/functions.php", + "src/Illuminate/Reflection/helpers.php", + "src/Illuminate/Support/functions.php", + "src/Illuminate/Support/helpers.php" + ], + "psr-4": { + "Illuminate\\": "src/Illuminate/", + "Illuminate\\Support\\": [ + "src/Illuminate/Macroable/", + "src/Illuminate/Collections/", + "src/Illuminate/Conditionable/", + "src/Illuminate/Reflection/" + ] + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "The Laravel Framework.", + "homepage": "https://laravel.com", + "keywords": [ + "framework", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/framework/issues", + "source": "https://github.com/laravel/framework" + }, + "time": "2026-09-01T21:42:30+00:00" + }, + { + "name": "laravel/prompts", + "version": "v0.3.24", + "source": { + "type": "git", + "url": "https://github.com/laravel/prompts.git", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/prompts/zipball/5d3cdef29e93ca3b62b1871359db3078cd99908b", + "reference": "5d3cdef29e93ca3b62b1871359db3078cd99908b", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.2", + "ext-mbstring": "*", + "php": "^8.1", + "symfony/console": "^6.2|^7.0|^8.0" + }, + "conflict": { + "illuminate/console": ">=10.17.0 <10.25.0", + "laravel/framework": ">=10.17.0 <10.25.0" + }, + "require-dev": { + "illuminate/collections": "^10.0|^11.0|^12.0|^13.0", + "mockery/mockery": "^1.5", + "pestphp/pest": "^2.3|^3.4|^4.0", + "phpstan/phpstan": "^1.12.28", + "phpstan/phpstan-mockery": "^1.1.3" + }, + "suggest": { + "ext-pcntl": "Required for the spinner to be animated." + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Laravel\\Prompts\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Add beautiful and user-friendly forms to your command-line applications.", + "support": { + "issues": "https://github.com/laravel/prompts/issues", + "source": "https://github.com/laravel/prompts/tree/v0.3.24" + }, + "time": "2026-08-20T12:55:36+00:00" + }, + { + "name": "laravel/reverb", + "version": "v1.11.1", + "source": { + "type": "git", + "url": "https://github.com/laravel/reverb.git", + "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/reverb/zipball/52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "reference": "52ce5fd88cd1d7eacfdcf6b91cea4704cc546f27", + "shasum": "" + }, + "require": { + "clue/redis-react": "^2.6", + "guzzlehttp/psr7": "^2.6", + "illuminate/console": "^10.47|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.47|^11.0|^12.0|^13.0", + "illuminate/http": "^10.47|^11.0|^12.0|^13.0", + "illuminate/support": "^10.47|^11.0|^12.0|^13.0", + "laravel/prompts": "^0.1.15|^0.2.0|^0.3.0", + "php": "^8.2", + "pusher/pusher-php-server": "^7.2", + "ratchet/rfc6455": "^0.4", + "react/promise-timer": "^1.10", + "react/socket": "^1.14", + "symfony/console": "^6.0|^7.0|^8.0", + "symfony/http-foundation": "^6.3|^7.0|^8.0" + }, + "require-dev": { + "orchestra/testbench": "^8.36|^9.15|^10.8|^11.0", + "pestphp/pest": "^2.0|^3.0|^4.0", + "phpstan/phpstan": "^1.10", + "ratchet/pawl": "^0.4.1", + "react/async": "^4.2", + "react/http": "^1.9" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Reverb\\ApplicationManagerServiceProvider", + "Laravel\\Reverb\\ReverbServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Reverb\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Joe Dixon", + "email": "joe@laravel.com" + } + ], + "description": "Laravel Reverb provides a real-time WebSocket communication backend for Laravel applications.", + "keywords": [ + "WebSockets", + "laravel", + "real-time", + "websocket" + ], + "support": { + "issues": "https://github.com/laravel/reverb/issues", + "source": "https://github.com/laravel/reverb/tree/v1.11.1" + }, + "time": "2026-08-06T14:48:58+00:00" + }, + { + "name": "laravel/serializable-closure", + "version": "v2.0.16", + "source": { + "type": "git", + "url": "https://github.com/laravel/serializable-closure.git", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "reference": "7cfc24e4fa2cca045fb8dd2a797a2b2b13b655ed", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "nesbot/carbon": "^2.67|^3.0", + "pestphp/pest": "^2.36|^3.0|^4.0", + "phpstan/phpstan": "^2.0", + "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\SerializableClosure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "nuno@laravel.com" + } + ], + "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.", + "keywords": [ + "closure", + "laravel", + "serializable" + ], + "support": { + "issues": "https://github.com/laravel/serializable-closure/issues", + "source": "https://github.com/laravel/serializable-closure" + }, + "time": "2026-08-18T20:28:54+00:00" + }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, + { + "name": "league/commonmark", + "version": "2.10.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/commonmark.git", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/commonmark/zipball/d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "reference": "d2d1aa8b35e072966c89bc0c66cf926e56767dc4", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "league/config": "^1.1.1", + "php": "^7.4 || ^8.0", + "psr/event-dispatcher": "^1.0", + "symfony/deprecation-contracts": "^2.1 || ^3.0", + "symfony/polyfill-php80": "^1.16" + }, + "require-dev": { + "cebe/markdown": "^1.0", + "commonmark/cmark": "0.31.1", + "commonmark/commonmark.js": "0.31.1", + "composer/package-versions-deprecated": "^1.8", + "embed/embed": "^4.4", + "erusev/parsedown": "^1.0", + "ext-json": "*", + "github/gfm": "0.29.0", + "michelf/php-markdown": "^1.4 || ^2.0", + "nyholm/psr7": "^1.5", + "phpstan/phpstan": "^2.0.0", + "phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "scrutinizer/ocular": "^1.8.1", + "symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0", + "symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0", + "symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0", + "unleashedtech/php-coding-standard": "^3.1.1", + "vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0" + }, + "suggest": { + "symfony/yaml": "v2.3+ required if using the Front Matter extension" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.11-dev" + } + }, + "autoload": { + "psr-4": { + "League\\CommonMark\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)", + "homepage": "https://commonmark.thephpleague.com", + "keywords": [ + "commonmark", + "flavored", + "gfm", + "github", + "github-flavored", + "markdown", + "md", + "parser" + ], + "support": { + "docs": "https://commonmark.thephpleague.com/", + "forum": "https://github.com/thephpleague/commonmark/discussions", + "issues": "https://github.com/thephpleague/commonmark/issues", + "rss": "https://github.com/thephpleague/commonmark/releases.atom", + "source": "https://github.com/thephpleague/commonmark" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/commonmark", + "type": "tidelift" + } + ], + "time": "2026-08-11T16:06:25+00:00" + }, + { + "name": "league/config", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/config.git", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3", + "shasum": "" + }, + "require": { + "dflydev/dot-access-data": "^3.0.1", + "nette/schema": "^1.2", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.8.2", + "phpunit/phpunit": "^9.5.5", + "scrutinizer/ocular": "^1.8.1", + "unleashedtech/php-coding-standard": "^3.1", + "vimeo/psalm": "^4.7.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.2-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Config\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Colin O'Dell", + "email": "colinodell@gmail.com", + "homepage": "https://www.colinodell.com", + "role": "Lead Developer" + } + ], + "description": "Define configuration arrays with strict schemas and access values with dot notation", + "homepage": "https://config.thephpleague.com", + "keywords": [ + "array", + "config", + "configuration", + "dot", + "dot-access", + "nested", + "schema" + ], + "support": { + "docs": "https://config.thephpleague.com/", + "issues": "https://github.com/thephpleague/config/issues", + "rss": "https://github.com/thephpleague/config/releases.atom", + "source": "https://github.com/thephpleague/config" + }, + "funding": [ + { + "url": "https://www.colinodell.com/sponsor", + "type": "custom" + }, + { + "url": "https://www.paypal.me/colinpodell/10.00", + "type": "custom" + }, + { + "url": "https://github.com/colinodell", + "type": "github" + } + ], + "time": "2022-12-11T20:36:23+00:00" + }, + { + "name": "league/flysystem", + "version": "3.36.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem.git", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/f7fb152932f30072d573510cbd4dd657d6475b25", + "reference": "f7fb152932f30072d573510cbd4dd657d6475b25", + "shasum": "" + }, + "require": { + "league/flysystem-local": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "conflict": { + "async-aws/core": "<1.19.0", + "async-aws/s3": "<1.14.0", + "aws/aws-sdk-php": "3.209.31 || 3.210.0", + "guzzlehttp/guzzle": "<7.0", + "guzzlehttp/ringphp": "<1.1.1", + "phpseclib/phpseclib": "3.0.15", + "symfony/http-client": "<5.2" + }, + "require-dev": { + "async-aws/s3": "^1.5 || ^2.0", + "async-aws/simple-s3": "^1.1 || ^2.0", + "aws/aws-sdk-php": "^3.295.10", + "composer/semver": "^3.0", + "ext-fileinfo": "*", + "ext-ftp": "*", + "ext-mongodb": "^1.3|^2", + "ext-zip": "*", + "friendsofphp/php-cs-fixer": "^3.5", + "google/cloud-storage": "^1.23", + "guzzlehttp/psr7": "^2.6", + "microsoft/azure-storage-blob": "^1.1", + "mongodb/mongodb": "^1.2|^2", + "phpseclib/phpseclib": "^3.0.36", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^9.5.11|^10.0", + "sabre/dav": "^4.6.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "File storage abstraction for PHP", + "keywords": [ + "WebDAV", + "aws", + "cloud", + "file", + "files", + "filesystem", + "filesystems", + "ftp", + "s3", + "sftp", + "storage" + ], + "support": { + "issues": "https://github.com/thephpleague/flysystem/issues", + "source": "https://github.com/thephpleague/flysystem/tree/3.36.0" + }, + "time": "2026-09-02T08:00:27+00:00" + }, + { + "name": "league/flysystem-local", + "version": "3.35.3", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/flysystem-local.git", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/flysystem-local/zipball/a099b24dce160f3b2239043d13d47c4a1a214ea4", + "reference": "a099b24dce160f3b2239043d13d47c4a1a214ea4", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "league/flysystem": "^3.0.0", + "league/mime-type-detection": "^1.0.0", + "php": "^8.0.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\Flysystem\\Local\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Local filesystem adapter for Flysystem.", + "keywords": [ + "Flysystem", + "file", + "files", + "filesystem", + "local" + ], + "support": { + "source": "https://github.com/thephpleague/flysystem-local/tree/3.35.3" + }, + "time": "2026-08-12T13:29:21+00:00" + }, + { + "name": "league/mime-type-detection", + "version": "1.17.0", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/mime-type-detection.git", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/mime-type-detection/zipball/f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "reference": "f5f47eff7c48ed1003069a2ca67f316fb4021c76", + "shasum": "" + }, + "require": { + "ext-fileinfo": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "phpstan/phpstan": "^0.12.68", + "phpunit/phpunit": "^8.5.8 || ^9.3 || ^10.0 || ^11.0 || ^12.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "League\\MimeTypeDetection\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Frank de Jonge", + "email": "info@frankdejonge.nl" + } + ], + "description": "Mime-type detection for Flysystem", + "support": { + "issues": "https://github.com/thephpleague/mime-type-detection/issues", + "source": "https://github.com/thephpleague/mime-type-detection/tree/1.17.0" + }, + "funding": [ + { + "url": "https://github.com/frankdejonge", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/league/flysystem", + "type": "tidelift" + } + ], + "time": "2026-07-09T11:49:27+00:00" + }, + { + "name": "league/uri", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri.git", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri/zipball/08cf38e3924d4f56238125547b5720496fac8fd4", + "reference": "08cf38e3924d4f56238125547b5720496fac8fd4", + "shasum": "" + }, + "require": { + "league/uri-interfaces": "^7.8.1", + "php": "^8.1", + "psr/http-factory": "^1" + }, + "conflict": { + "league/uri-schemes": "^1.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-dom": "to convert the URI into an HTML anchor tag", + "ext-fileinfo": "to create Data URI from file contennts", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "ext-uri": "to use the PHP native URI class", + "jeremykendall/php-domain-parser": "to further parse the URI host and resolve its Public Suffix and Top Level Domain", + "league/uri-components": "to provide additional tools to manipulate URI objects components", + "league/uri-polyfill": "to backport the PHP URI extension for older versions of PHP", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "URI manipulation library", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "URN", + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "middleware", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc2141", + "rfc3986", + "rfc3987", + "rfc6570", + "rfc8141", + "uri", + "uri-template", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-15T20:22:25+00:00" + }, + { + "name": "league/uri-interfaces", + "version": "7.8.1", + "source": { + "type": "git", + "url": "https://github.com/thephpleague/uri-interfaces.git", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thephpleague/uri-interfaces/zipball/85d5c77c5d6d3af6c54db4a78246364908f3c928", + "reference": "85d5c77c5d6d3af6c54db4a78246364908f3c928", + "shasum": "" + }, + "require": { + "ext-filter": "*", + "php": "^8.1", + "psr/http-message": "^1.1 || ^2.0" + }, + "suggest": { + "ext-bcmath": "to improve IPV4 host parsing", + "ext-gmp": "to improve IPV4 host parsing", + "ext-intl": "to handle IDN host with the best performance", + "php-64bit": "to improve IPV4 host parsing", + "rowbot/url": "to handle URLs using the WHATWG URL Living Standard specification", + "symfony/polyfill-intl-idn": "to handle IDN host via the Symfony polyfill if ext-intl is not present" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "7.x-dev" + } + }, + "autoload": { + "psr-4": { + "League\\Uri\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://nyamsprod.com" + } + ], + "description": "Common tools for parsing and resolving RFC3987/RFC3986 URI", + "homepage": "https://uri.thephpleague.com", + "keywords": [ + "data-uri", + "file-uri", + "ftp", + "hostname", + "http", + "https", + "parse_str", + "parse_url", + "psr-7", + "query-string", + "querystring", + "rfc3986", + "rfc3987", + "rfc6570", + "uri", + "url", + "ws" + ], + "support": { + "docs": "https://uri.thephpleague.com", + "forum": "https://thephpleague.slack.com", + "issues": "https://github.com/thephpleague/uri-src/issues", + "source": "https://github.com/thephpleague/uri-interfaces/tree/7.8.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2026-03-08T20:05:35+00:00" + }, + { + "name": "monolog/monolog", + "version": "3.11.0", + "source": { + "type": "git", + "url": "https://github.com/Seldaek/monolog.git", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/147f303310f06334f03f409e49d7ad1e275ff05a", + "reference": "147f303310f06334f03f409e49d7ad1e275ff05a", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/log": "^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2.0", + "guzzlehttp/guzzle": "^7.4.5", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "php-console/php-console": "^3.1.8", + "phpstan/phpstan": "^2", + "phpstan/phpstan-deprecation-rules": "^2", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^10.5.17 || ^11.0.7", + "predis/predis": "^1.1 || ^2", + "rollbar/rollbar": "^4.0", + "ruflin/elastica": "^7 || ^8", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/3.11.0" + }, + "funding": [ + { + "url": "https://github.com/Seldaek", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/monolog/monolog", + "type": "tidelift" + } + ], + "time": "2026-09-02T12:39:56+00:00" + }, + { + "name": "nesbot/carbon", + "version": "3.13.2", + "source": { + "type": "git", + "url": "https://github.com/CarbonPHP/carbon.git", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "reference": "a1c54919f5fff9800cd03c32bd01defd5a4061cb", + "shasum": "" + }, + "require": { + "carbonphp/carbon-doctrine-types": "<100.0", + "ext-json": "*", + "php": "^8.1", + "psr/clock": "^1.0", + "symfony/clock": "^6.3.12 || ^7.0 || ^8.0", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation": "^4.4.18 || ^5.2.1 || ^6.0 || ^7.0 || ^8.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "require-dev": { + "doctrine/dbal": "^3.6.3 || ^4.0", + "doctrine/orm": "^2.15.2 || ^3.0", + "friendsofphp/php-cs-fixer": "^v3.87.1", + "kylekatarnls/multi-tester": "^2.5.3", + "phpmd/phpmd": "^2.15.0", + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^2.1.22", + "phpunit/phpunit": "^10.5.53", + "squizlabs/php_codesniffer": "^3.13.4 || ^4.0.0" + }, + "bin": [ + "bin/carbon" + ], + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Carbon\\Laravel\\ServiceProvider" + ] + }, + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev", + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Carbon\\": "src/Carbon/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Brian Nesbitt", + "email": "brian@nesbot.com", + "homepage": "https://markido.com" + }, + { + "name": "kylekatarnls", + "homepage": "https://github.com/kylekatarnls" + } + ], + "description": "An API extension for DateTime that supports 281 different languages.", + "homepage": "https://carbonphp.github.io/carbon/", + "keywords": [ + "date", + "datetime", + "time" + ], + "support": { + "docs": "https://carbonphp.github.io/carbon/guide/getting-started/introduction.html", + "issues": "https://github.com/CarbonPHP/carbon/issues", + "source": "https://github.com/CarbonPHP/carbon" + }, + "funding": [ + { + "url": "https://github.com/sponsors/kylekatarnls", + "type": "github" + }, + { + "url": "https://opencollective.com/Carbon#sponsor", + "type": "opencollective" + }, + { + "url": "https://tidelift.com/subscription/pkg/packagist-nesbot-carbon?utm_source=packagist-nesbot-carbon&utm_medium=referral&utm_campaign=readme", + "type": "tidelift" + } + ], + "time": "2026-08-08T11:40:35+00:00" + }, + { + "name": "nette/schema", + "version": "v1.3.6", + "source": { + "type": "git", + "url": "https://github.com/nette/schema.git", + "reference": "c54350438cd6914616f790a49cb424605f421562" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/schema/zipball/c54350438cd6914616f790a49cb424605f421562", + "reference": "c54350438cd6914616f790a49cb424605f421562", + "shasum": "" + }, + "require": { + "nette/utils": "^4.0", + "php": "8.1 - 8.5" + }, + "require-dev": { + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.6", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1.39@stable", + "tracy/tracy": "^2.8" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "📐 Nette Schema: validating data structures against a given Schema.", + "homepage": "https://nette.org", + "keywords": [ + "config", + "nette" + ], + "support": { + "issues": "https://github.com/nette/schema/issues", + "source": "https://github.com/nette/schema/tree/v1.3.6" + }, + "time": "2026-08-16T21:58:41+00:00" + }, + { + "name": "nette/utils", + "version": "v4.1.5", + "source": { + "type": "git", + "url": "https://github.com/nette/utils.git", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nette/utils/zipball/b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "reference": "b043439dbdf954e6c28b5ea7e34b0100f83165e0", + "shasum": "" + }, + "require": { + "php": "8.2 - 8.5" + }, + "conflict": { + "nette/finder": "<3", + "nette/schema": "<1.2.2" + }, + "require-dev": { + "jetbrains/phpstorm-attributes": "^1.2", + "nette/phpstan-rules": "^1.0", + "nette/tester": "^2.5", + "phpstan/extension-installer": "^1.4@stable", + "phpstan/phpstan": "^2.1@stable", + "tracy/tracy": "^2.9" + }, + "suggest": { + "ext-gd": "to use Image", + "ext-iconv": "to use Strings::chr(), ord() and reverse()", + "ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()", + "ext-json": "to use Nette\\Utils\\Json", + "ext-mbstring": "to use Strings::lower() etc...", + "ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.1-dev" + } + }, + "autoload": { + "psr-4": { + "Nette\\": "src" + }, + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause", + "GPL-2.0-only", + "GPL-3.0-only" + ], + "authors": [ + { + "name": "David Grudl", + "homepage": "https://davidgrudl.com" + }, + { + "name": "Nette Community", + "homepage": "https://nette.org/contributors" + } + ], + "description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.", + "homepage": "https://nette.org", + "keywords": [ + "array", + "core", + "datetime", + "images", + "json", + "nette", + "paginator", + "password", + "slugify", + "string", + "unicode", + "utf-8", + "utility", + "validation" + ], + "support": { + "issues": "https://github.com/nette/utils/issues", + "source": "https://github.com/nette/utils/tree/v4.1.5" + }, + "time": "2026-07-17T23:02:45+00:00" + }, + { + "name": "nikic/php-parser", + "version": "v5.8.0", + "source": { + "type": "git", + "url": "https://github.com/nikic/PHP-Parser.git", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "php": ">=7.4" + }, + "require-dev": { + "ircmaxell/php-yacc": "^0.0.7", + "phpunit/phpunit": "^9.0" + }, + "bin": [ + "bin/php-parse" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.x-dev" + } + }, + "autoload": { + "psr-4": { + "PhpParser\\": "lib/PhpParser" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov" + } + ], + "description": "A PHP parser written in PHP", + "keywords": [ + "parser", + "php" + ], + "support": { + "issues": "https://github.com/nikic/PHP-Parser/issues", + "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0" + }, + "time": "2026-07-04T14:30:18+00:00" + }, + { + "name": "nunomaduro/termwind", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/termwind.git", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/termwind/zipball/712a31b768f5daea284c2169a7d227031001b9a8", + "reference": "712a31b768f5daea284c2169a7d227031001b9a8", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^8.2", + "symfony/console": "^7.4.4 || ^8.0.4" + }, + "require-dev": { + "illuminate/console": "^11.47.0", + "laravel/pint": "^1.27.1", + "mockery/mockery": "^1.6.12", + "pestphp/pest": "^2.36.0 || ^3.8.4 || ^4.3.2", + "phpstan/phpstan": "^1.12.32", + "phpstan/phpstan-strict-rules": "^1.6.2", + "symfony/var-dumper": "^7.3.5 || ^8.0.4", + "thecodingmachine/phpstan-strict-rules": "^1.0.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Termwind\\Laravel\\TermwindServiceProvider" + ] + }, + "branch-alias": { + "dev-2.x": "2.x-dev" + } + }, + "autoload": { + "files": [ + "src/Functions.php" + ], + "psr-4": { + "Termwind\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "It's like Tailwind CSS, but for the console.", + "keywords": [ + "cli", + "console", + "css", + "package", + "php", + "style" + ], + "support": { + "issues": "https://github.com/nunomaduro/termwind/issues", + "source": "https://github.com/nunomaduro/termwind/tree/v2.4.0" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://github.com/xiCO2k", + "type": "github" + } + ], + "time": "2026-02-16T23:10:27+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.10.0", + "source": { + "type": "git", + "url": "https://github.com/schmittjoh/php-option.git", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "reference": "67b192b6a42ec03944b972d6e633ddec78ad2c6d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.54 || ^9.6.36 || ^10.5.64 || ^11.5.56 || ^12.5.33" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.10.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption", + "type": "tidelift" + } + ], + "time": "2026-08-24T00:54:40+00:00" + }, + { + "name": "predis/predis", + "version": "v3.6.0", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "2ff20c08bb63697245ffee3f198d1673086c302d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/2ff20c08bb63697245ffee3f198d1673086c302d", + "reference": "2ff20c08bb63697245ffee3f198d1673086c302d", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.6.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-08-14T23:07:56+00:00" + }, + { + "name": "psr/clock", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/clock.git", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Psr\\Clock\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for reading the clock.", + "homepage": "https://github.com/php-fig/clock", + "keywords": [ + "clock", + "now", + "psr", + "psr-20", + "time" + ], + "support": { + "issues": "https://github.com/php-fig/clock/issues", + "source": "https://github.com/php-fig/clock/tree/1.0.0" + }, + "time": "2022-11-25T14:36:26+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/container.git", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/event-dispatcher", + "version": "1.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/event-dispatcher.git", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0", + "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0", + "shasum": "" + }, + "require": { + "php": ">=7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\EventDispatcher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Standard interfaces for event handling.", + "keywords": [ + "events", + "psr", + "psr-14" + ], + "support": { + "issues": "https://github.com/php-fig/event-dispatcher/issues", + "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0" + }, + "time": "2019-01-08T18:20:26+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/simple-cache.git", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, + { + "name": "pusher/pusher-php-server", + "version": "7.3.0", + "source": { + "type": "git", + "url": "https://github.com/pusher/pusher-http-php.git", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/pusher/pusher-http-php/zipball/058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "reference": "058d8464246118110a341fc2e6e70c7c8b6a7f2c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "guzzlehttp/guzzle": "^7.8.2 || ^8.0", + "guzzlehttp/promises": "^2.0.3 || ^3.0", + "guzzlehttp/psr7": "^2.6.3 || ^3.0", + "php": "^7.3|^8.0", + "psr/http-client": "^1.0", + "psr/log": "^1.0 || ^2.0 || ^3.0" + }, + "require-dev": { + "overtrue/phplint": "^2.3", + "phpunit/phpunit": "^9.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0-dev" + } + }, + "autoload": { + "psr-4": { + "Pusher\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Library for interacting with the Pusher REST API", + "keywords": [ + "events", + "messaging", + "php-pusher-server", + "publish", + "push", + "pusher", + "real time", + "real-time", + "realtime", + "rest", + "trigger" + ], + "support": { + "issues": "https://github.com/pusher/pusher-http-php/issues", + "source": "https://github.com/pusher/pusher-http-php/tree/7.3.0" + }, + "time": "2026-08-07T12:47:11+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "ramsey/collection", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/ramsey/collection.git", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/collection/zipball/344572933ad0181accbf4ba763e85a0306a8c5e2", + "reference": "344572933ad0181accbf4ba763e85a0306a8c5e2", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "captainhook/plugin-composer": "^5.3", + "ergebnis/composer-normalize": "^2.45", + "fakerphp/faker": "^1.24", + "hamcrest/hamcrest-php": "^2.0", + "jangregor/phpstan-prophecy": "^2.1", + "mockery/mockery": "^1.6", + "php-parallel-lint/php-console-highlighter": "^1.0", + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpspec/prophecy-phpunit": "^2.3", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^10.5", + "ramsey/coding-standard": "^2.3", + "ramsey/conventional-commits": "^1.6", + "roave/security-advisories": "dev-latest" + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + }, + "ramsey/conventional-commits": { + "configFile": "conventional-commits.json" + } + }, + "autoload": { + "psr-4": { + "Ramsey\\Collection\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ben Ramsey", + "email": "ben@benramsey.com", + "homepage": "https://benramsey.com" + } + ], + "description": "A PHP library for representing and manipulating collections.", + "keywords": [ + "array", + "collection", + "hash", + "map", + "queue", + "set" + ], + "support": { + "issues": "https://github.com/ramsey/collection/issues", + "source": "https://github.com/ramsey/collection/tree/2.1.1" + }, + "time": "2025-03-22T05:38:12+00:00" + }, + { + "name": "ramsey/uuid", + "version": "4.9.3", + "source": { + "type": "git", + "url": "https://github.com/ramsey/uuid.git", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8", + "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8", + "shasum": "" + }, + "require": { + "brick/math": ">=0.8.16 <=0.18", + "php": "^8.0", + "ramsey/collection": "^1.2 || ^2.0" + }, + "replace": { + "rhumsaa/uuid": "self.version" + }, + "require-dev": { + "captainhook/captainhook": "^5.25", + "captainhook/plugin-composer": "^5.3", + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "ergebnis/composer-normalize": "^2.47", + "mockery/mockery": "^1.6", + "paragonie/random-lib": "^2", + "php-mock/php-mock": "^2.6", + "php-mock/php-mock-mockery": "^1.5", + "php-parallel-lint/php-parallel-lint": "^1.4.0", + "phpbench/phpbench": "^1.2.14", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-mockery": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^9.6", + "slevomat/coding-standard": "^8.18", + "squizlabs/php_codesniffer": "^3.13" + }, + "suggest": { + "ext-bcmath": "Enables faster math with arbitrary-precision integers using BCMath.", + "ext-gmp": "Enables faster math with arbitrary-precision integers using GMP.", + "ext-uuid": "Enables the use of PeclUuidTimeGenerator and PeclUuidRandomGenerator.", + "paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter", + "ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type." + }, + "type": "library", + "extra": { + "captainhook": { + "force-install": true + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Ramsey\\Uuid\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A PHP library for generating and working with universally unique identifiers (UUIDs).", + "keywords": [ + "guid", + "identifier", + "uuid" + ], + "support": { + "issues": "https://github.com/ramsey/uuid/issues", + "source": "https://github.com/ramsey/uuid/tree/4.9.3" + }, + "time": "2026-06-18T03:57:49+00:00" + }, + { + "name": "ratchet/rfc6455", + "version": "v0.4.1", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/9b05f371219cbaf9748b505f139617dd0715592b", + "reference": "9b05f371219cbaf9748b505f139617dd0715592b", + "shasum": "" + }, + "require": { + "php": ">=7.4", + "psr/http-factory-implementation": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "require-dev": { + "guzzlehttp/psr7": "^2.7", + "phpunit/phpunit": "^9.5", + "react/socket": "^1.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\RFC6455\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.4.1" + }, + "time": "2026-06-06T14:34:23+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.14.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3", + "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.14.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-18T19:34:28+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.6.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.6.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-17T20:46:25+00:00" + }, + { + "name": "react/promise", + "version": "v3.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a", + "reference": "23444f53a813a3296c1368bb104793ce8d88f04a", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.12.28 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-08-19T18:57:03+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/socket", + "version": "v1.17.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08", + "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.17.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2025-11-19T20:47:34+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "symfony/clock", + "version": "v8.1.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/clock.git", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/clock/zipball/701ef4de9705d6c32292ebee5e8044094a09fbf6", + "reference": "701ef4de9705d6c32292ebee5e8044094a09fbf6", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/clock": "^1.0" + }, + "provide": { + "psr/clock-implementation": "1.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/now.php" + ], + "psr-4": { + "Symfony\\Component\\Clock\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Decouples applications from the system clock", + "homepage": "https://symfony.com", + "keywords": [ + "clock", + "psr20", + "time" + ], + "support": { + "source": "https://github.com/symfony/clock/tree/v8.1.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-29T05:06:50+00:00" + }, + { + "name": "symfony/console", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/eb7d9957d66739649e931ce7a9d05dab69f8abac", + "reference": "eb7d9957d66739649e931ce7a9d05dab69f8abac", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php85": "^1.32", + "symfony/service-contracts": "^2.5|^3", + "symfony/string": "^7.4.6|^8.0.6" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/event-dispatcher": "<8.1" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/event-dispatcher": "^8.1", + "symfony/filesystem": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/lock": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Eases the creation of beautiful and testable command line interfaces", + "homepage": "https://symfony.com", + "keywords": [ + "cli", + "command-line", + "console", + "terminal" + ], + "support": { + "source": "https://github.com/symfony/console/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:18:42+00:00" + }, + { + "name": "symfony/css-selector", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/css-selector.git", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/08e2905152a39cf3fd1745d83f8c483e258887d9", + "reference": "08e2905152a39cf3fd1745d83f8c483e258887d9", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\CssSelector\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Jean-François Simon", + "email": "jeanfrancois.simon@sensiolabs.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Converts CSS selectors to XPath expressions", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/css-selector/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-23T10:06:25+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", + "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/error-handler", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/error-handler.git", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/error-handler/zipball/8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "reference": "8b2a4289ffe5e2dc8fcf645b8e7870e1fa0325ce", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/polyfill-php85": "^1.32", + "symfony/var-dumper": "^7.4|^8.0" + }, + "conflict": { + "symfony/deprecation-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/webpack-encore-bundle": "^1.0|^2.0" + }, + "bin": [ + "Resources/bin/patch-type-declarations" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\ErrorHandler\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to manage errors and ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/error-handler/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher.git", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/7458da64220376b2e0dc2d8451bf43382c1ad297", + "reference": "7458da64220376b2e0dc2d8451bf43382c1ad297", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/security-http": "<7.4", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "psr/event-dispatcher-implementation": "1.0", + "symfony/event-dispatcher-implementation": "2.0|3.0" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/error-handler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/stopwatch": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\EventDispatcher\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/event-dispatcher-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/event-dispatcher-contracts.git", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/event-dispatcher": "^1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\EventDispatcher\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to dispatching event", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/finder", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/finder.git", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/finder/zipball/8d7acede2b2ae07605783d1c43e49b5767036474", + "reference": "8d7acede2b2ae07605783d1c43e49b5767036474", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "require-dev": { + "symfony/filesystem": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Finder\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Finds files and directories via an intuitive fluent interface", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/finder/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T12:16:08+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "reference": "093b78326f649c3a9db922b9f17123b6aeb3b8fb", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "^1.1" + }, + "conflict": { + "doctrine/dbal": "<4.3" + }, + "require-dev": { + "doctrine/dbal": "^4.3", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:55+00:00" + }, + { + "name": "symfony/http-kernel", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-kernel.git", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "reference": "2f73beb7c6f1a97d2c17bbf4dbd59da8cc18b355", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "psr/log": "^1|^2|^3", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/error-handler": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/dependency-injection": "<8.1", + "symfony/flex": "<2.10", + "symfony/http-client-contracts": "<2.5", + "symfony/serializer": "<7.4.15|>=8.0,<8.0.15|>=8.1,<8.1.2", + "symfony/translation-contracts": "<2.5", + "symfony/var-dumper": "<8.1", + "symfony/web-profiler-bundle": "<8.1", + "twig/twig": "<3.21" + }, + "provide": { + "psr/log-implementation": "1.0|2.0|3.0" + }, + "require-dev": { + "psr/cache": "^1.0|^2.0|^3.0", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/clock": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/css-selector": "^7.4|^8.0", + "symfony/dependency-injection": "^8.1", + "symfony/dom-crawler": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/rate-limiter": "^7.4|^8.0", + "symfony/routing": "^7.4|^8.0", + "symfony/serializer": "^7.4|^8.0", + "symfony/stopwatch": "^7.4|^8.0", + "symfony/translation": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/uid": "^7.4|^8.0", + "symfony/validator": "^7.4|^8.0", + "symfony/var-dumper": "^8.1", + "symfony/var-exporter": "^7.4|^8.0", + "twig/twig": "^3.21|^4.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpKernel\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides a structured process for converting a Request into a Response", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-kernel/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T21:40:49+00:00" + }, + { + "name": "symfony/mailer", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "89f43137da74b8f1aab37c99926482b7084f51b9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/89f43137da74b8f1aab37c99926482b7084f51b9", + "reference": "89f43137da74b8f1aab37c99926482b7084f51b9", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3|^4", + "php": ">=8.4.1", + "psr/event-dispatcher": "^1", + "psr/log": "^1|^2|^3", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/mime": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/http-client-contracts": "<2.5" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/messenger": "^7.4|^8.0", + "symfony/twig-bridge": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/mime", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "reference": "1b36ccfd7ccb9ad1d6eafb9024b3dd3d9606b15f", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/property-access": "^7.4|^8.0", + "symfony/property-info": "^7.4|^8.0", + "symfony/serializer": "^7.4.17|^8.1.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-22T09:06:25+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-ctype.git", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-intl-grapheme", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-grapheme.git", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "reference": "bb899c1db0aa8127dc3afe8cda4a67eb24915f8d", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Grapheme\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's grapheme_* functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "grapheme", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T08:25:59+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/51b5ff5ba85452b31ec6f55490b08148612339d9", + "reference": "51b5ff5ba85452b31ec6f55490b08148612339d9", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-24T10:51:20+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.42.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "reference": "aa20edea75bd9c48cfecc8360922e5a6e5c44502", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.42.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-07T06:33:24+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php80.git", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-php84", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php84.git", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php84\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-26T12:51:13+00:00" + }, + { + "name": "symfony/polyfill-php85", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php85.git", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/255fab485aaa1006ed411040c42aecd7b5302d7a", + "reference": "255fab485aaa1006ed411040c42aecd7b5302d7a", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php85\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.5+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php85/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-01T12:47:55+00:00" + }, + { + "name": "symfony/polyfill-php86", + "version": "v1.41.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php86.git", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php86/zipball/6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "reference": "6bc356ed3d8dbfeea8f0de235e34d670704e880e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php86\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.6+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php86/tree/v1.41.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-02T13:42:24+00:00" + }, + { + "name": "symfony/polyfill-uuid", + "version": "v1.37.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-uuid.git", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-uuid/zipball/26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "reference": "26dfec253c4cf3e51b541b52ddf7e42cb0908e94", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-uuid": "*" + }, + "suggest": { + "ext-uuid": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Uuid\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for uuid functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/polyfill-uuid/tree/v1.37.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/process", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/d863f5e70d7c87abb906ac11b61f83036093000b", + "reference": "d863f5e70d7c87abb906ac11b61f83036093000b", + "shasum": "" + }, + "require": { + "php": ">=8.4.1" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Executes commands in sub-processes", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/routing", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/3c188091b6b4fa2e4bc83a135caede12deb8576c", + "reference": "3c188091b6b4fa2e4bc83a135caede12deb8576c", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "require-dev": { + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/expression-language": "^7.4|^8.0", + "symfony/http-foundation": "^7.4|^8.0", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-17T13:18:34+00:00" + }, + { + "name": "symfony/service-contracts", + "version": "v3.7.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/service-contracts.git", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "psr/container": "^1.1|^2.0", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "ext-psr": "<1.1|>=2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Service\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to writing services", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/service-contracts/tree/v3.7.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-27T15:39:01+00:00" + }, + { + "name": "symfony/string", + "version": "v8.1.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/string.git", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8", + "symfony/polyfill-intl-grapheme": "^1.33", + "symfony/polyfill-intl-normalizer": "^1.0", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/translation-contracts": "<2.5" + }, + "require-dev": { + "symfony/emoji": "^7.4|^8.0", + "symfony/http-client": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/translation-contracts": "^2.5|^3.0", + "symfony/var-exporter": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\String\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way", + "homepage": "https://symfony.com", + "keywords": [ + "grapheme", + "i18n", + "string", + "unicode", + "utf-8", + "utf8" + ], + "support": { + "source": "https://github.com/symfony/string/tree/v8.1.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-07-28T07:35:25+00:00" + }, + { + "name": "symfony/translation", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation.git", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation/zipball/d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "reference": "d9e1caba0d6b6f9a26710af8a2f88d37f001215a", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0", + "symfony/translation-contracts": "^3.6.1" + }, + "conflict": { + "nikic/php-parser": "<5.0", + "symfony/http-client-contracts": "<2.5", + "symfony/service-contracts": "<2.5" + }, + "provide": { + "symfony/translation-implementation": "2.3|3.0" + }, + "require-dev": { + "nikic/php-parser": "^5.0", + "psr/log": "^1|^2|^3", + "symfony/config": "^7.4|^8.0", + "symfony/console": "^7.4|^8.0", + "symfony/dependency-injection": "^7.4|^8.0", + "symfony/finder": "^7.4|^8.0", + "symfony/http-client-contracts": "^2.5|^3.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/routing": "^7.4|^8.0", + "symfony/service-contracts": "^2.5|^3", + "symfony/yaml": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "files": [ + "Resources/functions.php" + ], + "psr-4": { + "Symfony\\Component\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to internationalize your application", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/translation/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-21T17:47:34+00:00" + }, + { + "name": "symfony/translation-contracts", + "version": "v3.7.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/translation-contracts.git", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Contracts\\Translation\\": "" + }, + "exclude-from-classmap": [ + "/Test/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Generic abstractions related to translation", + "homepage": "https://symfony.com", + "keywords": [ + "abstractions", + "contracts", + "decoupling", + "interfaces", + "interoperability", + "standards" + ], + "support": { + "source": "https://github.com/symfony/translation-contracts/tree/v3.7.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-06-05T06:23:12+00:00" + }, + { + "name": "symfony/uid", + "version": "v8.1.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/uid.git", + "reference": "a08aef47989093f32fe50fd11859be1b427df389" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/uid/zipball/a08aef47989093f32fe50fd11859be1b427df389", + "reference": "a08aef47989093f32fe50fd11859be1b427df389", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-uuid": "^1.15" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Uid\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides an object-oriented API to generate and represent UIDs", + "homepage": "https://symfony.com", + "keywords": [ + "UID", + "ulid", + "uuid" + ], + "support": { + "source": "https://github.com/symfony/uid/tree/v8.1.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-11T13:39:01+00:00" + }, + { + "name": "symfony/var-dumper", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/var-dumper.git", + "reference": "3783365b58972f4779254d98372af80fbf15e170" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/var-dumper/zipball/3783365b58972f4779254d98372af80fbf15e170", + "reference": "3783365b58972f4779254d98372af80fbf15e170", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "symfony/console": "<7.4", + "symfony/error-handler": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/process": "^7.4|^8.0", + "symfony/uid": "^7.4|^8.0", + "twig/twig": "^3.12|^4.0" + }, + "bin": [ + "Resources/bin/var-dump-server" + ], + "type": "library", + "autoload": { + "files": [ + "Resources/functions/dump.php" + ], + "psr-4": { + "Symfony\\Component\\VarDumper\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides mechanisms for walking through any arbitrary PHP variable", + "homepage": "https://symfony.com", + "keywords": [ + "debug", + "dump" + ], + "support": { + "source": "https://github.com/symfony/var-dumper/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T20:10:55+00:00" + }, + { + "name": "tijsverkoyen/css-to-inline-styles", + "version": "v2.4.0", + "source": { + "type": "git", + "url": "https://github.com/tijsverkoyen/CssToInlineStyles.git", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/tijsverkoyen/CssToInlineStyles/zipball/f0292ccf0ec75843d65027214426b6b163b48b41", + "reference": "f0292ccf0ec75843d65027214426b6b163b48b41", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "php": "^7.4 || ^8.0", + "symfony/css-selector": "^5.4 || ^6.0 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/phpunit": "^8.5.21 || ^9.5.10" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "TijsVerkoyen\\CssToInlineStyles\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Tijs Verkoyen", + "email": "css_to_inline_styles@verkoyen.eu", + "role": "Developer" + } + ], + "description": "CssToInlineStyles is a class that enables you to convert HTML-pages/files into HTML-pages/files with inline styles. This is very useful when you're sending emails.", + "homepage": "https://github.com/tijsverkoyen/CssToInlineStyles", + "support": { + "issues": "https://github.com/tijsverkoyen/CssToInlineStyles/issues", + "source": "https://github.com/tijsverkoyen/CssToInlineStyles/tree/v2.4.0" + }, + "time": "2025-12-02T11:56:42+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.7.0", + "source": { + "type": "git", + "url": "https://github.com/vlucas/phpdotenv.git", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/301c07936b16d88628b126b01d082ba153cf4c40", + "reference": "301c07936b16d88628b126b01d082ba153cf4c40", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.2", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.10", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `$_ENV` and `$_SERVER` automagically, and optionally to `getenv()`.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv", + "type": "tidelift" + } + ], + "time": "2026-08-24T18:07:49+00:00" + }, + { + "name": "voku/portable-ascii", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/voku/portable-ascii.git", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/voku/portable-ascii/zipball/8e1051fe39379367aecf014f41744ce7539a856f", + "reference": "8e1051fe39379367aecf014f41744ce7539a856f", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "~8.5 || ~9.6 || ~10.5 || ~11.5" + }, + "suggest": { + "ext-intl": "Use Intl for transliterator_transliterate() support" + }, + "type": "library", + "autoload": { + "psr-4": { + "voku\\": "src/voku/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Lars Moelleken", + "homepage": "https://www.moelleken.org/" + } + ], + "description": "Portable ASCII library - performance optimized (ascii) string functions for php.", + "homepage": "https://github.com/voku/portable-ascii", + "keywords": [ + "ascii", + "clean", + "php" + ], + "support": { + "issues": "https://github.com/voku/portable-ascii/issues", + "source": "https://github.com/voku/portable-ascii/tree/2.1.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/moelleken", + "type": "custom" + }, + { + "url": "https://github.com/voku", + "type": "github" + }, + { + "url": "https://opencollective.com/portable-ascii", + "type": "open_collective" + }, + { + "url": "https://www.patreon.com/voku", + "type": "patreon" + }, + { + "url": "https://tidelift.com/funding/github/packagist/voku/portable-ascii", + "type": "tidelift" + } + ], + "time": "2026-04-26T05:33:54+00:00" + } + ], + "packages-dev": [ + { + "name": "composer/semver", + "version": "3.4.4", + "source": { + "type": "git", + "url": "https://github.com/composer/semver.git", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95", + "shasum": "" + }, + "require": { + "php": "^5.3.2 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpstan/phpstan": "^1.11", + "symfony/phpunit-bridge": "^3 || ^7" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Composer\\Semver\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nils Adermann", + "email": "naderman@naderman.de", + "homepage": "http://www.naderman.de" + }, + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "http://seld.be" + }, + { + "name": "Rob Bast", + "email": "rob.bast@gmail.com", + "homepage": "http://robbast.nl" + } + ], + "description": "Semver library that offers utilities, version constraint parsing and validation.", + "keywords": [ + "semantic", + "semver", + "validation", + "versioning" + ], + "support": { + "irc": "ircs://irc.libera.chat:6697/composer", + "issues": "https://github.com/composer/semver/issues", + "source": "https://github.com/composer/semver/tree/3.4.4" + }, + "funding": [ + { + "url": "https://packagist.com", + "type": "custom" + }, + { + "url": "https://github.com/composer", + "type": "github" + } + ], + "time": "2025-08-20T19:15:30+00:00" + }, + { + "name": "fakerphp/faker", + "version": "v1.24.1", + "source": { + "type": "git", + "url": "https://github.com/FakerPHP/Faker.git", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/FakerPHP/Faker/zipball/e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "reference": "e0ee18eb1e6dc3cda3ce9fd97e5a0689a88a64b5", + "shasum": "" + }, + "require": { + "php": "^7.4 || ^8.0", + "psr/container": "^1.0 || ^2.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "conflict": { + "fzaninotto/faker": "*" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.4.1", + "doctrine/persistence": "^1.3 || ^2.0", + "ext-intl": "*", + "phpunit/phpunit": "^9.5.26", + "symfony/phpunit-bridge": "^5.4.16" + }, + "suggest": { + "doctrine/orm": "Required to use Faker\\ORM\\Doctrine", + "ext-curl": "Required by Faker\\Provider\\Image to download images.", + "ext-dom": "Required by Faker\\Provider\\HtmlLorem for generating random HTML.", + "ext-iconv": "Required by Faker\\Provider\\ru_RU\\Text::realText() for generating real Russian text.", + "ext-mbstring": "Required for multibyte Unicode string functionality." + }, + "type": "library", + "autoload": { + "psr-4": { + "Faker\\": "src/Faker/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "François Zaninotto" + } + ], + "description": "Faker is a PHP library that generates fake data for you.", + "keywords": [ + "data", + "faker", + "fixtures" + ], + "support": { + "issues": "https://github.com/FakerPHP/Faker/issues", + "source": "https://github.com/FakerPHP/Faker/tree/v1.24.1" + }, + "time": "2024-11-21T13:46:39+00:00" + }, + { + "name": "filp/whoops", + "version": "2.18.4", + "source": { + "type": "git", + "url": "https://github.com/filp/whoops.git", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/filp/whoops/zipball/d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "reference": "d2102955e48b9fd9ab24280a7ad12ed552752c4d", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "require-dev": { + "mockery/mockery": "^1.0", + "phpunit/phpunit": "^7.5.20 || ^8.5.8 || ^9.3.3", + "symfony/var-dumper": "^4.0 || ^5.0" + }, + "suggest": { + "symfony/var-dumper": "Pretty print complex values better with var-dumper available", + "whoops/soap": "Formats errors as SOAP responses" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.7-dev" + } + }, + "autoload": { + "psr-4": { + "Whoops\\": "src/Whoops/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Filipe Dobreira", + "homepage": "https://github.com/filp", + "role": "Developer" + } + ], + "description": "php error handling for cool kids", + "homepage": "https://filp.github.io/whoops/", + "keywords": [ + "error", + "exception", + "handling", + "library", + "throwable", + "whoops" + ], + "support": { + "issues": "https://github.com/filp/whoops/issues", + "source": "https://github.com/filp/whoops/tree/2.18.4" + }, + "funding": [ + { + "url": "https://github.com/denis-sokolov", + "type": "github" + } + ], + "time": "2025-08-08T12:00:00+00:00" + }, + { + "name": "hamcrest/hamcrest-php", + "version": "v3.0.0", + "source": { + "type": "git", + "url": "https://github.com/hamcrest/hamcrest-php.git", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/hamcrest/hamcrest-php/zipball/b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "reference": "b61cd040da1a4925bc90a51c074f5297e7c0fa52", + "shasum": "" + }, + "require": { + "ext-ctype": "*", + "ext-dom": "*", + "php": "^7.4|^8.0" + }, + "replace": { + "cordoval/hamcrest-php": "*", + "davedevelopment/hamcrest-php": "*", + "kodova/hamcrest-php": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpunit/php-file-iterator": "^1.4 || ^2.0 || ^3.0", + "phpunit/phpunit": "^4.8.36 || ^5.7 || ^6.5 || ^7.0 || ^8.0 || ^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "classmap": [ + "hamcrest" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "This is the PHP port of Hamcrest Matchers", + "keywords": [ + "test" + ], + "support": { + "issues": "https://github.com/hamcrest/hamcrest-php/issues", + "source": "https://github.com/hamcrest/hamcrest-php/tree/v3.0.0" + }, + "time": "2026-03-17T11:56:53+00:00" + }, + { + "name": "laravel/agent-detector", + "version": "v2.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/agent-detector.git", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/agent-detector/zipball/90694b9256099591cf9e55d08c18ba7a00bf099f", + "reference": "90694b9256099591cf9e55d08c18ba7a00bf099f", + "shasum": "" + }, + "require": { + "php": "^8.2.0" + }, + "require-dev": { + "laravel/pint": "^1.24.0", + "pestphp/pest": "^3.8.5|^4.1.0", + "pestphp/pest-plugin-type-coverage": "^3.0|^4.0.2", + "phpstan/phpstan": "^2.1.26", + "rector/rector": "^2.1.7", + "symfony/var-dumper": "^7.3.3" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Laravel\\AgentDetector\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Detect if code is running in an AI agent or automated development environment", + "homepage": "https://github.com/laravel/agent-detector", + "keywords": [ + "Agent", + "ai", + "automation", + "claude", + "cursor", + "detection", + "devin", + "php" + ], + "support": { + "issues": "https://github.com/laravel/agent-detector/issues", + "source": "https://github.com/laravel/agent-detector" + }, + "time": "2026-04-29T18:32:34+00:00" + }, + { + "name": "laravel/boost", + "version": "v2.7.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/boost.git", + "reference": "b19e98a8637cb69b2aab7b5b6c5fe9e2c79d182f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/boost/zipball/b19e98a8637cb69b2aab7b5b6c5fe9e2c79d182f", + "reference": "b19e98a8637cb69b2aab7b5b6c5fe9e2c79d182f", + "shasum": "" + }, + "require": { + "guzzlehttp/guzzle": "^7.9|^8.0", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "laravel/mcp": "^0.7.1|^0.8.0|^0.9.0", + "laravel/prompts": "^0.3.10", + "laravel/roster": "^1.0.0", + "php": "^8.2" + }, + "require-dev": { + "laravel/pint": "^1.27.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench": "^9.15.0|^10.6|^11.0", + "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Boost\\BoostServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Boost\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.", + "homepage": "https://github.com/laravel/boost", + "keywords": [ + "ai", + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/boost/issues", + "source": "https://github.com/laravel/boost" + }, + "time": "2026-08-26T21:39:22+00:00" + }, + { + "name": "laravel/mcp", + "version": "v0.9.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/mcp.git", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/mcp/zipball/7ca5b923630118696602d14348cd0466a5e853ec", + "reference": "7ca5b923630118696602d14348cd0466a5e853ec", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "illuminate/console": "^11.45.3|^12.41.1|^13.0", + "illuminate/container": "^11.45.3|^12.41.1|^13.0", + "illuminate/contracts": "^11.45.3|^12.41.1|^13.0", + "illuminate/http": "^11.45.3|^12.41.1|^13.0", + "illuminate/json-schema": "^12.41.1|^13.0", + "illuminate/routing": "^11.45.3|^12.41.1|^13.0", + "illuminate/support": "^11.45.3|^12.41.1|^13.0", + "illuminate/validation": "^11.45.3|^12.41.1|^13.0", + "php": "^8.2", + "symfony/process": "^7.4.5|^8.0.5" + }, + "require-dev": { + "laravel/pint": "^1.20", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "pestphp/pest": "^3.8.5|^4.3.2", + "phpstan/phpstan": "^2.1.27", + "rector/rector": "^2.2.4" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mcp": "Laravel\\Mcp\\Facades\\Mcp" + }, + "providers": [ + "Laravel\\Mcp\\Server\\McpServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Mcp\\": "src/", + "Laravel\\Mcp\\Server\\": "src/Server/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Rapidly build MCP servers for your Laravel applications.", + "homepage": "https://github.com/laravel/mcp", + "keywords": [ + "laravel", + "mcp" + ], + "support": { + "issues": "https://github.com/laravel/mcp/issues", + "source": "https://github.com/laravel/mcp" + }, + "time": "2026-08-13T15:01:07+00:00" + }, + { + "name": "laravel/pail", + "version": "v1.2.7", + "source": { + "type": "git", + "url": "https://github.com/laravel/pail.git", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "illuminate/console": "^10.24|^11.0|^12.0|^13.0", + "illuminate/contracts": "^10.24|^11.0|^12.0|^13.0", + "illuminate/log": "^10.24|^11.0|^12.0|^13.0", + "illuminate/process": "^10.24|^11.0|^12.0|^13.0", + "illuminate/support": "^10.24|^11.0|^12.0|^13.0", + "nunomaduro/termwind": "^1.15|^2.0", + "php": "^8.2", + "symfony/console": "^6.0|^7.0|^8.0" + }, + "require-dev": { + "laravel/framework": "^10.24|^11.0|^12.0|^13.0", + "laravel/pint": "^1.13", + "orchestra/testbench-core": "^8.13|^9.17|^10.8|^11.0", + "pestphp/pest": "^2.20|^3.0|^4.0", + "pestphp/pest-plugin-type-coverage": "^2.3|^3.0|^4.0", + "phpstan/phpstan": "^1.12.27", + "symfony/var-dumper": "^6.3|^7.0|^8.0", + "symfony/yaml": "^6.3|^7.0|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Pail\\PailServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Pail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + }, + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Easily delve into your Laravel application's log files directly from the command line.", + "homepage": "https://github.com/laravel/pail", + "keywords": [ + "dev", + "laravel", + "logs", + "php", + "tail" + ], + "support": { + "issues": "https://github.com/laravel/pail/issues", + "source": "https://github.com/laravel/pail" + }, + "time": "2026-05-20T22:24:57+00:00" + }, + { + "name": "laravel/pao", + "version": "v1.1.4", + "source": { + "type": "git", + "url": "https://github.com/laravel/pao.git", + "reference": "5aee99c8c37565e9c457c33f4d36aa363a389dc8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pao/zipball/5aee99c8c37565e9c457c33f4d36aa363a389dc8", + "reference": "5aee99c8c37565e9c457c33f4d36aa363a389dc8", + "shasum": "" + }, + "require": { + "laravel/agent-detector": "^2.0.2", + "php": "^8.3" + }, + "conflict": { + "laravel/framework": "<12.0.0", + "nunomaduro/collision": "<8.9.3", + "pestphp/pest": "<4.6.3 || >=6.0.0", + "phpunit/phpunit": "<12.5.23 || >=13.0.0 <13.1.7 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.20.0", + "laravel/pint": "^1.30.5", + "orchestra/testbench": "^10.11.0 || ^11.1.0", + "pestphp/pest": "^4.7.8 || ^5.1.0", + "pestphp/pest-plugin-type-coverage": "^4.0.4 || ^5.0.2", + "phpstan/phpstan": "^2.2.8", + "rector/rector": "^2.6.1", + "symfony/process": "^7.4.13 || ^8.1.0", + "symfony/var-dumper": "^7.4.15 || ^8.1.2" + }, + "type": "library", + "extra": { + "pest": { + "plugins": [ + "Laravel\\Pao\\Drivers\\Pest\\Plugin" + ] + }, + "laravel": { + "providers": [ + "Laravel\\Pao\\Laravel\\ServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/Autoload.php" + ], + "psr-4": { + "Laravel\\Pao\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Agent-optimized output for PHP testing tools", + "keywords": [ + "Agent", + "PHPStan", + "ai", + "dev", + "paratest", + "pest", + "php", + "phpunit", + "rector", + "testing" + ], + "support": { + "issues": "https://github.com/laravel/pao/issues", + "source": "https://github.com/laravel/pao" + }, + "time": "2026-08-10T23:45:44+00:00" + }, + { + "name": "laravel/pint", + "version": "v1.30.5", + "source": { + "type": "git", + "url": "https://github.com/laravel/pint.git", + "reference": "fe4148c503a0e266353d61396b79bbf7f35122df" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/pint/zipball/fe4148c503a0e266353d61396b79bbf7f35122df", + "reference": "fe4148c503a0e266353d61396b79bbf7f35122df", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-mbstring": "*", + "ext-tokenizer": "*", + "ext-xml": "*", + "php": "^8.3.0" + }, + "require-dev": { + "composer/semver": "^3.4.4", + "friendsofphp/php-cs-fixer": "^3.95.18", + "illuminate/view": "^13.24.0", + "larastan/larastan": "^3.10.0", + "laravel-zero/framework": "^13.0.0", + "laravel/agent-detector": "^2.0.2", + "laravel/prompts": "^0.3.22", + "mockery/mockery": "^1.6.12", + "nunomaduro/termwind": "^2.4.0", + "pestphp/pest": "^4.7.8" + }, + "bin": [ + "builds/pint" + ], + "type": "project", + "autoload": { + "psr-4": { + "App\\": "app/", + "Database\\Seeders\\": "database/seeders/", + "Database\\Factories\\": "database/factories/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "An opinionated code formatter for PHP.", + "homepage": "https://laravel.com", + "keywords": [ + "dev", + "format", + "formatter", + "lint", + "linter", + "php" + ], + "support": { + "issues": "https://github.com/laravel/pint/issues", + "source": "https://github.com/laravel/pint" + }, + "time": "2026-08-10T15:35:50+00:00" + }, + { + "name": "laravel/roster", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/laravel/roster.git", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa", + "shasum": "" + }, + "require": { + "composer/semver": "^3.0", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/yaml": "^7.2|^8.0" + }, + "require-dev": { + "laravel/pint": "^1.29", + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0|^11.0", + "pestphp/pest": "^3.0|^4.1", + "phpstan/phpstan": "^2.0", + "rector/rector": "^2.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Roster\\RosterServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Roster\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Detect packages & approaches in use within a Laravel project", + "homepage": "https://github.com/laravel/roster", + "keywords": [ + "dev", + "laravel" + ], + "support": { + "issues": "https://github.com/laravel/roster/issues", + "source": "https://github.com/laravel/roster" + }, + "time": "2026-07-18T17:53:15+00:00" + }, + { + "name": "mockery/mockery", + "version": "1.6.15", + "source": { + "type": "git", + "url": "https://github.com/mockery/mockery.git", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/mockery/mockery/zipball/967a801bd188989a5669bd280f252d51c0fdc9ee", + "reference": "967a801bd188989a5669bd280f252d51c0fdc9ee", + "shasum": "" + }, + "require": { + "hamcrest/hamcrest-php": "^2.0 || ^3.0", + "php": ">=7.3" + }, + "conflict": { + "phpunit/phpunit": "<8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6.36", + "symplify/easy-coding-standard": "^13.2.17" + }, + "type": "library", + "autoload": { + "files": [ + "library/helpers.php", + "library/Mockery.php" + ], + "psr-4": { + "Mockery\\": "library/Mockery" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Pádraic Brady", + "email": "padraic.brady@gmail.com", + "homepage": "https://github.com/padraic", + "role": "Author" + }, + { + "name": "Dave Marshall", + "email": "dave.marshall@atstsolutions.co.uk", + "homepage": "https://davedevelopment.co.uk", + "role": "Developer" + }, + { + "name": "Nathanael Esayeas", + "email": "nathanael.esayeas@protonmail.com", + "homepage": "https://github.com/ghostwriter", + "role": "Lead Developer" + } + ], + "description": "Mockery is a simple yet flexible PHP mock object framework", + "homepage": "https://github.com/mockery/mockery", + "keywords": [ + "BDD", + "TDD", + "library", + "mock", + "mock objects", + "mockery", + "stub", + "test", + "test double", + "testing" + ], + "support": { + "docs": "https://docs.mockery.io/", + "issues": "https://github.com/mockery/mockery/issues", + "rss": "https://github.com/mockery/mockery/releases.atom", + "security": "https://github.com/mockery/mockery/security/advisories", + "source": "https://github.com/mockery/mockery" + }, + "time": "2026-08-19T19:37:52+00:00" + }, + { + "name": "myclabs/deep-copy", + "version": "1.14.0", + "source": { + "type": "git", + "url": "https://github.com/myclabs/DeepCopy.git", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "reference": "8680aa248f8e07bc8fb43f56f0f5fc77a0c96aae", + "shasum": "" + }, + "require": { + "php": "^8.0" + }, + "conflict": { + "doctrine/collections": "<1.6.8", + "doctrine/common": "<2.13.3 || >=3 <3.2.2" + }, + "require-dev": { + "doctrine/collections": "^1.6.8", + "doctrine/common": "^2.13.3 || ^3.2.2", + "phpspec/prophecy": "^1.10", + "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" + }, + "type": "library", + "autoload": { + "files": [ + "src/DeepCopy/deep_copy.php" + ], + "psr-4": { + "DeepCopy\\": "src/DeepCopy/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Create deep copies (clones) of your objects", + "keywords": [ + "clone", + "copy", + "duplicate", + "object", + "object graph" + ], + "support": { + "issues": "https://github.com/myclabs/DeepCopy/issues", + "source": "https://github.com/myclabs/DeepCopy/tree/1.14.0" + }, + "funding": [ + { + "url": "https://github.com/mnapoli", + "type": "github" + } + ], + "time": "2026-08-11T10:17:44+00:00" + }, + { + "name": "nunomaduro/collision", + "version": "v8.9.5", + "source": { + "type": "git", + "url": "https://github.com/nunomaduro/collision.git", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nunomaduro/collision/zipball/fb53eacd509a1d303858e2d20cfebf2d630254ec", + "reference": "fb53eacd509a1d303858e2d20cfebf2d630254ec", + "shasum": "" + }, + "require": { + "filp/whoops": "^2.18.4", + "nunomaduro/termwind": "^2.4.0", + "php": "^8.2.0", + "symfony/console": "^7.4.14 || ^8.1.1" + }, + "conflict": { + "laravel/framework": "<11.48.0 || >=14.0.0", + "phpunit/phpunit": "<11.5.50 || >=14.0.0" + }, + "require-dev": { + "brianium/paratest": "^7.8.5", + "larastan/larastan": "^3.10.0", + "laravel/framework": "^11.48.0 || ^12.56.0 || ^13.20.0", + "laravel/pint": "^1.29.3", + "orchestra/testbench-core": "^9.12.0 || ^10.12.1 || ^11.3.5", + "pestphp/pest": "^3.8.5 || ^4.7.5 || ^5.0.0", + "sebastian/environment": "^7.2.1 || ^8.1.2 || ^9.3.2" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider" + ] + }, + "branch-alias": { + "dev-8.x": "8.x-dev" + } + }, + "autoload": { + "files": [ + "./src/Adapters/Phpunit/Autoload.php" + ], + "psr-4": { + "NunoMaduro\\Collision\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nuno Maduro", + "email": "enunomaduro@gmail.com" + } + ], + "description": "Cli error handling for console/command-line PHP applications.", + "keywords": [ + "artisan", + "cli", + "command-line", + "console", + "dev", + "error", + "handling", + "laravel", + "laravel-zero", + "php", + "symfony" + ], + "support": { + "issues": "https://github.com/nunomaduro/collision/issues", + "source": "https://github.com/nunomaduro/collision" + }, + "funding": [ + { + "url": "https://www.paypal.com/paypalme/enunomaduro", + "type": "custom" + }, + { + "url": "https://github.com/nunomaduro", + "type": "github" + }, + { + "url": "https://www.patreon.com/nunomaduro", + "type": "patreon" + } + ], + "time": "2026-07-15T19:09:14+00:00" + }, + { + "name": "phar-io/manifest", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/phar-io/manifest.git", + "reference": "54750ef60c58e43759730615a392c31c80e23176" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176", + "reference": "54750ef60c58e43759730615a392c31c80e23176", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-phar": "*", + "ext-xmlwriter": "*", + "phar-io/version": "^3.0.1", + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", + "support": { + "issues": "https://github.com/phar-io/manifest/issues", + "source": "https://github.com/phar-io/manifest/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2024-03-03T12:33:53+00:00" + }, + { + "name": "phar-io/version", + "version": "3.2.1", + "source": { + "type": "git", + "url": "https://github.com/phar-io/version.git", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + }, + { + "name": "Sebastian Heuer", + "email": "sebastian@phpeople.de", + "role": "Developer" + }, + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "Developer" + } + ], + "description": "Library for handling version information and constraints", + "support": { + "issues": "https://github.com/phar-io/version/issues", + "source": "https://github.com/phar-io/version/tree/3.2.1" + }, + "time": "2022-02-21T01:04:05+00:00" + }, + { + "name": "phpunit/php-code-coverage", + "version": "12.5.7", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-code-coverage.git", + "reference": "186dab580576598076de6818596d12b61801880e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/186dab580576598076de6818596d12b61801880e", + "reference": "186dab580576598076de6818596d12b61801880e", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-libxml": "*", + "ext-xmlwriter": "*", + "nikic/php-parser": "^5.7.0", + "php": ">=8.3", + "phpunit/php-text-template": "^5.0", + "sebastian/complexity": "^5.0", + "sebastian/environment": "^8.1.2", + "sebastian/lines-of-code": "^4.0.1", + "sebastian/version": "^6.0", + "theseer/tokenizer": "^2.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.28" + }, + "suggest": { + "ext-pcov": "PHP extension that provides line coverage", + "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5.x-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", + "homepage": "https://github.com/sebastianbergmann/php-code-coverage", + "keywords": [ + "coverage", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", + "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", + "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/12.5.7" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage", + "type": "tidelift" + } + ], + "time": "2026-06-01T13:24:19+00:00" + }, + { + "name": "phpunit/php-file-iterator", + "version": "6.0.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-file-iterator.git", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a248d1640ab059b075f53a2ef0f9856e864e06b5", + "reference": "a248d1640ab059b075f53a2ef0f9856e864e06b5", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "FilterIterator implementation that filters files based on a list of suffixes.", + "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", + "keywords": [ + "filesystem", + "iterator" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", + "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", + "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/6.0.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator", + "type": "tidelift" + } + ], + "time": "2026-08-25T14:40:53+00:00" + }, + { + "name": "phpunit/php-invoker", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-invoker.git", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "reference": "12b54e689b07a25a9b41e57736dfab6ec9ae5406", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "^12.0" + }, + "suggest": { + "ext-pcntl": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Invoke callables with a timeout", + "homepage": "https://github.com/sebastianbergmann/php-invoker/", + "keywords": [ + "process" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-invoker/issues", + "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", + "source": "https://github.com/sebastianbergmann/php-invoker/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:58+00:00" + }, + { + "name": "phpunit/php-text-template", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-text-template.git", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/e1367a453f0eda562eedb4f659e13aa900d66c53", + "reference": "e1367a453f0eda562eedb4f659e13aa900d66c53", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Simple template engine.", + "homepage": "https://github.com/sebastianbergmann/php-text-template/", + "keywords": [ + "template" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-text-template/issues", + "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", + "source": "https://github.com/sebastianbergmann/php-text-template/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:16+00:00" + }, + { + "name": "phpunit/php-timer", + "version": "8.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/php-timer.git", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "reference": "f258ce36aa457f3aa3339f9ed4c81fc66dc8c2cc", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Utility class for timing", + "homepage": "https://github.com/sebastianbergmann/php-timer/", + "keywords": [ + "timer" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/php-timer/issues", + "security": "https://github.com/sebastianbergmann/php-timer/security/policy", + "source": "https://github.com/sebastianbergmann/php-timer/tree/8.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:59:38+00:00" + }, + { + "name": "phpunit/phpunit", + "version": "12.5.34", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/phpunit.git", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "reference": "6cbff63d670de92cb1cb3d2ff9f40327e9da9c7f", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-filter": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-xmlwriter": "*", + "myclabs/deep-copy": "^1.14.0", + "phar-io/manifest": "^2.0.4", + "phar-io/version": "^3.2.1", + "php": ">=8.3", + "phpunit/php-code-coverage": "^12.5.7", + "phpunit/php-file-iterator": "^6.0.2", + "phpunit/php-invoker": "^6.0.0", + "phpunit/php-text-template": "^5.0.0", + "phpunit/php-timer": "^8.0.0", + "sebastian/cli-parser": "^4.2.1", + "sebastian/comparator": "^7.1.8", + "sebastian/diff": "^7.0.1", + "sebastian/environment": "^8.1.2", + "sebastian/exporter": "^7.0.3", + "sebastian/global-state": "^8.0.3", + "sebastian/object-enumerator": "^7.0.0", + "sebastian/recursion-context": "^7.0.1", + "sebastian/type": "^6.0.4", + "sebastian/version": "^6.0.0", + "staabm/side-effects-detector": "^1.0.5" + }, + "bin": [ + "phpunit" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "12.5-dev" + } + }, + "autoload": { + "files": [ + "src/Framework/Assert/Functions.php" + ], + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "The PHP Unit Testing framework.", + "homepage": "https://phpunit.de/", + "keywords": [ + "phpunit", + "testing", + "xunit" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/phpunit/issues", + "security": "https://github.com/sebastianbergmann/phpunit/security/policy", + "source": "https://github.com/sebastianbergmann/phpunit/tree/12.5.34" + }, + "funding": [ + { + "url": "https://phpunit.de/sponsoring.html", + "type": "other" + } + ], + "time": "2026-08-27T08:38:28+00:00" + }, + { + "name": "sebastian/cli-parser", + "version": "4.2.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/cli-parser.git", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/7d05781b13f7dec9043a629a21d086ed74582a15", + "reference": "7d05781b13f7dec9043a629a21d086ed74582a15", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.2-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for parsing CLI options", + "homepage": "https://github.com/sebastianbergmann/cli-parser", + "support": { + "issues": "https://github.com/sebastianbergmann/cli-parser/issues", + "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", + "source": "https://github.com/sebastianbergmann/cli-parser/tree/4.2.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/cli-parser", + "type": "tidelift" + } + ], + "time": "2026-05-17T05:29:34+00:00" + }, + { + "name": "sebastian/comparator", + "version": "7.1.8", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/comparator.git", + "reference": "7c65c1e79836812819705b473a90c12399542485" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/7c65c1e79836812819705b473a90c12399542485", + "reference": "7c65c1e79836812819705b473a90c12399542485", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/diff": "^7.0", + "sebastian/exporter": "^7.0.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "suggest": { + "ext-bcmath": "For comparing BcMath\\Number objects" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@2bepublished.at" + } + ], + "description": "Provides the functionality to compare PHP values for equality", + "homepage": "https://github.com/sebastianbergmann/comparator", + "keywords": [ + "comparator", + "compare", + "equality" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/comparator/issues", + "security": "https://github.com/sebastianbergmann/comparator/security/policy", + "source": "https://github.com/sebastianbergmann/comparator/tree/7.1.8" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator", + "type": "tidelift" + } + ], + "time": "2026-05-21T04:45:25+00:00" + }, + { + "name": "sebastian/complexity", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/complexity.git", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/bad4316aba5303d0221f43f8cee37eb58d384bbb", + "reference": "bad4316aba5303d0221f43f8cee37eb58d384bbb", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for calculating the complexity of PHP code units", + "homepage": "https://github.com/sebastianbergmann/complexity", + "support": { + "issues": "https://github.com/sebastianbergmann/complexity/issues", + "security": "https://github.com/sebastianbergmann/complexity/security/policy", + "source": "https://github.com/sebastianbergmann/complexity/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:55:25+00:00" + }, + { + "name": "sebastian/diff", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/diff.git", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "reference": "cd4cabe39f8a4e8ee6818ba99f10a05561ea4ad6", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.33", + "symfony/process": "^7.4.17" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Kore Nordmann", + "email": "mail@kore-nordmann.de" + } + ], + "description": "Diff implementation", + "homepage": "https://github.com/sebastianbergmann/diff", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/diff/issues", + "security": "https://github.com/sebastianbergmann/diff/security/policy", + "source": "https://github.com/sebastianbergmann/diff/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/diff", + "type": "tidelift" + } + ], + "time": "2026-08-25T15:35:54+00:00" + }, + { + "name": "sebastian/environment", + "version": "8.1.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/environment.git", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/9d32c685773823b1983e256ae4ecd48a10d6e439", + "reference": "9d32c685773823b1983e256ae4ecd48a10d6e439", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.26" + }, + "suggest": { + "ext-posix": "*" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.1-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Provides functionality to handle HHVM/PHP environments", + "homepage": "https://github.com/sebastianbergmann/environment", + "keywords": [ + "Xdebug", + "environment", + "hhvm" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/environment/issues", + "security": "https://github.com/sebastianbergmann/environment/security/policy", + "source": "https://github.com/sebastianbergmann/environment/tree/8.1.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/environment", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:40:20+00:00" + }, + { + "name": "sebastian/exporter", + "version": "7.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/exporter.git", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "reference": "c5e21b5de653ce0a769fb36f5cdfcb5e7a32cf23", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.3", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Volker Dusch", + "email": "github@wallbash.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + }, + { + "name": "Bernhard Schussek", + "email": "bschussek@gmail.com" + } + ], + "description": "Provides the functionality to export PHP variables for visualization", + "homepage": "https://www.github.com/sebastianbergmann/exporter", + "keywords": [ + "export", + "exporter" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/exporter/issues", + "security": "https://github.com/sebastianbergmann/exporter/security/policy", + "source": "https://github.com/sebastianbergmann/exporter/tree/7.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter", + "type": "tidelift" + } + ], + "time": "2026-05-20T04:37:17+00:00" + }, + { + "name": "sebastian/global-state", + "version": "8.0.3", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/global-state.git", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b164d3274d6537ab462591c5755f76a8f5b1aae9", + "reference": "b164d3274d6537ab462591c5755f76a8f5b1aae9", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0.1" + }, + "require-dev": { + "ext-dom": "*", + "phpunit/phpunit": "^12.5.28" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "8.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Snapshotting of global state", + "homepage": "https://www.github.com/sebastianbergmann/global-state", + "keywords": [ + "global state" + ], + "support": { + "issues": "https://github.com/sebastianbergmann/global-state/issues", + "security": "https://github.com/sebastianbergmann/global-state/security/policy", + "source": "https://github.com/sebastianbergmann/global-state/tree/8.0.3" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state", + "type": "tidelift" + } + ], + "time": "2026-06-01T15:10:33+00:00" + }, + { + "name": "sebastian/lines-of-code", + "version": "4.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/lines-of-code.git", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d543b8ef219dcd8da262cbb958639a96bedba10e", + "reference": "d543b8ef219dcd8da262cbb958639a96bedba10e", + "shasum": "" + }, + "require": { + "nikic/php-parser": "^5.7.0", + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "4.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library for counting the lines of code in PHP source code", + "homepage": "https://github.com/sebastianbergmann/lines-of-code", + "support": { + "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", + "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", + "source": "https://github.com/sebastianbergmann/lines-of-code/tree/4.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/lines-of-code", + "type": "tidelift" + } + ], + "time": "2026-05-19T16:22:07+00:00" + }, + { + "name": "sebastian/object-enumerator", + "version": "7.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-enumerator.git", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "reference": "1effe8e9b8e068e9ae228e542d5d11b5d16db894", + "shasum": "" + }, + "require": { + "php": ">=8.3", + "sebastian/object-reflector": "^5.0", + "sebastian/recursion-context": "^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Traverses array structures and object graphs to enumerate all referenced objects", + "homepage": "https://github.com/sebastianbergmann/object-enumerator/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", + "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", + "source": "https://github.com/sebastianbergmann/object-enumerator/tree/7.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:57:48+00:00" + }, + { + "name": "sebastian/object-reflector", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/object-reflector.git", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/4bfa827c969c98be1e527abd576533293c634f6a", + "reference": "4bfa827c969c98be1e527abd576533293c634f6a", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "5.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + } + ], + "description": "Allows reflection of object attributes, including inherited and non-public ones", + "homepage": "https://github.com/sebastianbergmann/object-reflector/", + "support": { + "issues": "https://github.com/sebastianbergmann/object-reflector/issues", + "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", + "source": "https://github.com/sebastianbergmann/object-reflector/tree/5.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T04:58:17+00:00" + }, + { + "name": "sebastian/recursion-context", + "version": "7.0.1", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/recursion-context.git", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "reference": "0b01998a7d5b1f122911a66bebcb8d46f0c82d8c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "7.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de" + }, + { + "name": "Jeff Welch", + "email": "whatthejeff@gmail.com" + }, + { + "name": "Adam Harvey", + "email": "aharvey@php.net" + } + ], + "description": "Provides functionality to recursively process PHP variables", + "homepage": "https://github.com/sebastianbergmann/recursion-context", + "support": { + "issues": "https://github.com/sebastianbergmann/recursion-context/issues", + "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", + "source": "https://github.com/sebastianbergmann/recursion-context/tree/7.0.1" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context", + "type": "tidelift" + } + ], + "time": "2025-08-13T04:44:59+00:00" + }, + { + "name": "sebastian/type", + "version": "6.0.4", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/type.git", + "reference": "82ff822c2edc46724be9f7411d3163021f602773" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/82ff822c2edc46724be9f7411d3163021f602773", + "reference": "82ff822c2edc46724be9f7411d3163021f602773", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "require-dev": { + "phpunit/phpunit": "^12.5.25" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Collection of value objects that represent the types of the PHP type system", + "homepage": "https://github.com/sebastianbergmann/type", + "support": { + "issues": "https://github.com/sebastianbergmann/type/issues", + "security": "https://github.com/sebastianbergmann/type/security/policy", + "source": "https://github.com/sebastianbergmann/type/tree/6.0.4" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + }, + { + "url": "https://liberapay.com/sebastianbergmann", + "type": "liberapay" + }, + { + "url": "https://thanks.dev/u/gh/sebastianbergmann", + "type": "thanks_dev" + }, + { + "url": "https://tidelift.com/funding/github/packagist/sebastian/type", + "type": "tidelift" + } + ], + "time": "2026-05-20T06:45:45+00:00" + }, + { + "name": "sebastian/version", + "version": "6.0.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianbergmann/version.git", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/3e6ccf7657d4f0a59200564b08cead899313b53c", + "reference": "3e6ccf7657d4f0a59200564b08cead899313b53c", + "shasum": "" + }, + "require": { + "php": ">=8.3" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "6.0-dev" + } + }, + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Sebastian Bergmann", + "email": "sebastian@phpunit.de", + "role": "lead" + } + ], + "description": "Library that helps with managing the version number of Git-hosted PHP projects", + "homepage": "https://github.com/sebastianbergmann/version", + "support": { + "issues": "https://github.com/sebastianbergmann/version/issues", + "security": "https://github.com/sebastianbergmann/version/security/policy", + "source": "https://github.com/sebastianbergmann/version/tree/6.0.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianbergmann", + "type": "github" + } + ], + "time": "2025-02-07T05:00:38+00:00" + }, + { + "name": "staabm/side-effects-detector", + "version": "1.0.5", + "source": { + "type": "git", + "url": "https://github.com/staabm/side-effects-detector.git", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163", + "reference": "d8334211a140ce329c13726d4a715adbddd0a163", + "shasum": "" + }, + "require": { + "ext-tokenizer": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpstan/extension-installer": "^1.4.3", + "phpstan/phpstan": "^1.12.6", + "phpunit/phpunit": "^9.6.21", + "symfony/var-dumper": "^5.4.43", + "tomasvotruba/type-coverage": "1.0.0", + "tomasvotruba/unused-public": "1.0.0" + }, + "type": "library", + "autoload": { + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "A static analysis tool to detect side effects in PHP code", + "keywords": [ + "static analysis" + ], + "support": { + "issues": "https://github.com/staabm/side-effects-detector/issues", + "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5" + }, + "funding": [ + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2024-10-20T05:08:20+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.1.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "reference": "0b4aa53a67f9fece88c665f1a1dadcfd25d93fe5", + "shasum": "" + }, + "require": { + "php": ">=8.4.1", + "symfony/polyfill-ctype": "^1.8" + }, + "conflict": { + "symfony/console": "<7.4" + }, + "require-dev": { + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v8.1.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-08-30T01:03:44+00:00" + }, + { + "name": "theseer/tokenizer", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/theseer/tokenizer.git", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/theseer/tokenizer/zipball/7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "reference": "7989e43bf381af0eac72e4f0ca5bcbfa81658be4", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": "^8.1" + }, + "type": "library", + "autoload": { + "classmap": [ + "src/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Arne Blankerts", + "email": "arne@blankerts.de", + "role": "Developer" + } + ], + "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", + "support": { + "issues": "https://github.com/theseer/tokenizer/issues", + "source": "https://github.com/theseer/tokenizer/tree/2.0.1" + }, + "funding": [ + { + "url": "https://github.com/theseer", + "type": "github" + } + ], + "time": "2025-12-08T11:19:18+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": true, + "prefer-lowest": false, + "platform": { + "php": "^8.3" + }, + "platform-dev": {}, + "plugin-api-version": "2.9.0" +} diff --git a/config/app.php b/config/app.php new file mode 100644 index 0000000..dfa47ba --- /dev/null +++ b/config/app.php @@ -0,0 +1,126 @@ + env('APP_NAME', 'Laravel'), + + /* + |-------------------------------------------------------------------------- + | Application Environment + |-------------------------------------------------------------------------- + | + | This value determines the "environment" your application is currently + | running in. This may determine how you prefer to configure various + | services the application utilizes. Set this in your ".env" file. + | + */ + + 'env' => env('APP_ENV', 'production'), + + /* + |-------------------------------------------------------------------------- + | Application Debug Mode + |-------------------------------------------------------------------------- + | + | When your application is in debug mode, detailed error messages with + | stack traces will be shown on every error that occurs within your + | application. If disabled, a simple generic error page is shown. + | + */ + + 'debug' => (bool) env('APP_DEBUG', false), + + /* + |-------------------------------------------------------------------------- + | Application URL + |-------------------------------------------------------------------------- + | + | This URL is used by the console to properly generate URLs when using + | the Artisan command line tool. You should set this to the root of + | the application so that it's available within Artisan commands. + | + */ + + 'url' => env('APP_URL', 'http://localhost'), + + /* + |-------------------------------------------------------------------------- + | Application Timezone + |-------------------------------------------------------------------------- + | + | Here you may specify the default timezone for your application, which + | will be used by the PHP date and date-time functions. The timezone + | is set to "UTC" by default as it is suitable for most use cases. + | + */ + + 'timezone' => 'UTC', + + /* + |-------------------------------------------------------------------------- + | Application Locale Configuration + |-------------------------------------------------------------------------- + | + | The application locale determines the default locale that will be used + | by Laravel's translation / localization methods. This option can be + | set to any locale for which you plan to have translation strings. + | + */ + + 'locale' => env('APP_LOCALE', 'en'), + + 'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'), + + 'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'), + + /* + |-------------------------------------------------------------------------- + | Encryption Key + |-------------------------------------------------------------------------- + | + | This key is utilized by Laravel's encryption services and should be set + | to a random, 32 character string to ensure that all encrypted values + | are secure. You should do this prior to deploying the application. + | + */ + + 'cipher' => 'AES-256-CBC', + + 'key' => env('APP_KEY'), + + 'previous_keys' => [ + ...array_filter( + explode(',', (string) env('APP_PREVIOUS_KEYS', '')) + ), + ], + + /* + |-------------------------------------------------------------------------- + | Maintenance Mode Driver + |-------------------------------------------------------------------------- + | + | These configuration options determine the driver used to determine and + | manage Laravel's "maintenance mode" status. The "cache" driver will + | allow maintenance mode to be controlled across multiple machines. + | + | Supported drivers: "file", "cache", "array" + | + */ + + 'maintenance' => [ + 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), + 'store' => env('APP_MAINTENANCE_STORE', 'database'), + ], + +]; diff --git a/config/auth.php b/config/auth.php new file mode 100644 index 0000000..d7568ff --- /dev/null +++ b/config/auth.php @@ -0,0 +1,117 @@ + [ + 'guard' => env('AUTH_GUARD', 'web'), + 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), + ], + + /* + |-------------------------------------------------------------------------- + | Authentication Guards + |-------------------------------------------------------------------------- + | + | Next, you may define every authentication guard for your application. + | Of course, a great default configuration has been defined for you + | which utilizes session storage plus the Eloquent user provider. + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | Supported: "session" + | + */ + + 'guards' => [ + 'web' => [ + 'driver' => 'session', + 'provider' => 'users', + ], + ], + + /* + |-------------------------------------------------------------------------- + | User Providers + |-------------------------------------------------------------------------- + | + | All authentication guards have a user provider, which defines how the + | users are actually retrieved out of your database or other storage + | system used by the application. Typically, Eloquent is utilized. + | + | If you have multiple user tables or models you may configure multiple + | providers to represent the model / table. These providers may then + | be assigned to any extra authentication guards you have defined. + | + | Supported: "database", "eloquent" + | + */ + + 'providers' => [ + 'users' => [ + 'driver' => 'eloquent', + 'model' => env('AUTH_MODEL', User::class), + ], + + // 'users' => [ + // 'driver' => 'database', + // 'table' => 'users', + // ], + ], + + /* + |-------------------------------------------------------------------------- + | Resetting Passwords + |-------------------------------------------------------------------------- + | + | These configuration options specify the behavior of Laravel's password + | reset functionality, including the table utilized for token storage + | and the user provider that is invoked to actually retrieve users. + | + | The expiry time is the number of minutes that each reset token will be + | considered valid. This security feature keeps tokens short-lived so + | they have less time to be guessed. You may change this as needed. + | + | The throttle setting is the number of seconds a user must wait before + | generating more password reset tokens. This prevents the user from + | quickly generating a very large amount of password reset tokens. + | + */ + + 'passwords' => [ + 'users' => [ + 'provider' => 'users', + 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), + 'expire' => 60, + 'throttle' => 60, + ], + ], + + /* + |-------------------------------------------------------------------------- + | Password Confirmation Timeout + |-------------------------------------------------------------------------- + | + | Here you may define the number of seconds before a password confirmation + | window expires and users are asked to re-enter their password via the + | confirmation screen. By default, the timeout lasts for three hours. + | + */ + + 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), + +]; diff --git a/config/broadcasting.php b/config/broadcasting.php new file mode 100644 index 0000000..ebc3fb9 --- /dev/null +++ b/config/broadcasting.php @@ -0,0 +1,82 @@ + env('BROADCAST_CONNECTION', 'null'), + + /* + |-------------------------------------------------------------------------- + | Broadcast Connections + |-------------------------------------------------------------------------- + | + | Here you may define all of the broadcast connections that will be used + | to broadcast events to other systems or over WebSockets. Samples of + | each available type of connection are provided inside this array. + | + */ + + 'connections' => [ + + 'reverb' => [ + 'driver' => 'reverb', + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'pusher' => [ + 'driver' => 'pusher', + 'key' => env('PUSHER_APP_KEY'), + 'secret' => env('PUSHER_APP_SECRET'), + 'app_id' => env('PUSHER_APP_ID'), + 'options' => [ + 'cluster' => env('PUSHER_APP_CLUSTER'), + 'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com', + 'port' => env('PUSHER_PORT', 443), + 'scheme' => env('PUSHER_SCHEME', 'https'), + 'encrypted' => true, + 'useTLS' => env('PUSHER_SCHEME', 'https') === 'https', + ], + 'client_options' => [ + // Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html + ], + ], + + 'ably' => [ + 'driver' => 'ably', + 'key' => env('ABLY_KEY'), + ], + + 'log' => [ + 'driver' => 'log', + ], + + 'null' => [ + 'driver' => 'null', + ], + + ], + +]; diff --git a/config/cache.php b/config/cache.php new file mode 100644 index 0000000..d7eec61 --- /dev/null +++ b/config/cache.php @@ -0,0 +1,136 @@ + env('CACHE_STORE', 'database'), + + /* + |-------------------------------------------------------------------------- + | Cache Stores + |-------------------------------------------------------------------------- + | + | Here you may define all of the cache "stores" for your application as + | well as their drivers. You may even define multiple stores for the + | same cache driver to group types of items stored in your caches. + | + | Supported drivers: "array", "database", "file", "memcached", + | "redis", "dynamodb", "storage", "octane", + | "session", "failover", "null" + | + */ + + 'stores' => [ + + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_CACHE_CONNECTION'), + 'table' => env('DB_CACHE_TABLE', 'cache'), + 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), + 'lock_table' => env('DB_CACHE_LOCK_TABLE'), + ], + + 'file' => [ + 'driver' => 'file', + 'path' => storage_path('framework/cache/data'), + 'lock_path' => storage_path('framework/cache/data'), + ], + + 'storage' => [ + 'driver' => 'storage', + 'disk' => env('CACHE_STORAGE_DISK'), + 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'), + ], + + 'memcached' => [ + 'driver' => 'memcached', + 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), + 'sasl' => [ + env('MEMCACHED_USERNAME'), + env('MEMCACHED_PASSWORD'), + ], + 'options' => [ + // Memcached::OPT_CONNECT_TIMEOUT => 2000, + ], + 'servers' => [ + [ + 'host' => env('MEMCACHED_HOST', '127.0.0.1'), + 'port' => env('MEMCACHED_PORT', 11211), + 'weight' => 100, + ], + ], + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), + 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), + ], + + 'dynamodb' => [ + 'driver' => 'dynamodb', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), + 'endpoint' => env('DYNAMODB_ENDPOINT'), + ], + + 'octane' => [ + 'driver' => 'octane', + ], + + 'failover' => [ + 'driver' => 'failover', + 'stores' => [ + 'database', + 'array', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Cache Key Prefix + |-------------------------------------------------------------------------- + | + | When utilizing the APC, database, memcached, Redis, and DynamoDB cache + | stores, there might be other applications using the same cache. For + | that reason, you may prefix every cache key to avoid collisions. + | + */ + + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), + + /* + |-------------------------------------------------------------------------- + | Serializable Classes + |-------------------------------------------------------------------------- + | + | This value determines the classes that can be unserialized from cache + | storage. By default, no PHP classes will be unserialized from your + | cache to prevent gadget chain attacks if your APP_KEY is leaked. + | + */ + + 'serializable_classes' => false, + +]; diff --git a/config/cors.php b/config/cors.php new file mode 100644 index 0000000..e5f89eb --- /dev/null +++ b/config/cors.php @@ -0,0 +1,37 @@ + ['api/*', 'broadcasting/auth'], + + 'allowed_methods' => ['GET', 'POST', 'OPTIONS'], + + 'allowed_origins' => [ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + ], + + 'allowed_origins_patterns' => [], + + 'allowed_headers' => ['*'], + + 'exposed_headers' => [], + + 'max_age' => 0, + + 'supports_credentials' => true, + +]; diff --git a/config/database.php b/config/database.php new file mode 100644 index 0000000..abbb88e --- /dev/null +++ b/config/database.php @@ -0,0 +1,184 @@ + env('DB_CONNECTION', 'sqlite'), + + /* + |-------------------------------------------------------------------------- + | Database Connections + |-------------------------------------------------------------------------- + | + | Below are all of the database connections defined for your application. + | An example configuration is provided for each database system which + | is supported by Laravel. You're free to add / remove connections. + | + */ + + 'connections' => [ + + 'sqlite' => [ + 'driver' => 'sqlite', + 'url' => env('DB_URL'), + 'database' => env('DB_DATABASE', database_path('database.sqlite')), + 'prefix' => '', + 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), + 'busy_timeout' => null, + 'journal_mode' => null, + 'synchronous' => null, + 'transaction_mode' => 'DEFERRED', + ], + + 'mysql' => [ + 'driver' => 'mysql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'mariadb' => [ + 'driver' => 'mariadb', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '3306'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'unix_socket' => env('DB_SOCKET', ''), + 'charset' => env('DB_CHARSET', 'utf8mb4'), + 'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'), + 'prefix' => '', + 'prefix_indexes' => true, + 'strict' => true, + 'engine' => null, + 'options' => extension_loaded('pdo_mysql') ? array_filter([ + Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), + ]) : [], + ], + + 'pgsql' => [ + 'driver' => 'pgsql', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', '127.0.0.1'), + 'port' => env('DB_PORT', '5432'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + 'search_path' => 'public', + 'sslmode' => env('DB_SSLMODE', 'prefer'), + ], + + 'sqlsrv' => [ + 'driver' => 'sqlsrv', + 'url' => env('DB_URL'), + 'host' => env('DB_HOST', 'localhost'), + 'port' => env('DB_PORT', '1433'), + 'database' => env('DB_DATABASE', 'laravel'), + 'username' => env('DB_USERNAME', 'root'), + 'password' => env('DB_PASSWORD', ''), + 'charset' => env('DB_CHARSET', 'utf8'), + 'prefix' => '', + 'prefix_indexes' => true, + // 'encrypt' => env('DB_ENCRYPT', 'yes'), + // 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => [ + 'table' => 'migrations', + 'update_date_on_publish' => true, + ], + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + + 'redis' => [ + + 'client' => env('REDIS_CLIENT', 'phpredis'), + + 'options' => [ + 'cluster' => env('REDIS_CLUSTER', 'redis'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), + 'persistent' => env('REDIS_PERSISTENT', false), + ], + + 'default' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_DB', '0'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + 'cache' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'port' => env('REDIS_PORT', '6379'), + 'database' => env('REDIS_CACHE_DB', '1'), + 'max_retries' => env('REDIS_MAX_RETRIES', 3), + 'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'), + 'backoff_base' => env('REDIS_BACKOFF_BASE', 100), + 'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000), + ], + + ], + +]; diff --git a/config/filesystems.php b/config/filesystems.php new file mode 100644 index 0000000..37d8fca --- /dev/null +++ b/config/filesystems.php @@ -0,0 +1,80 @@ + env('FILESYSTEM_DISK', 'local'), + + /* + |-------------------------------------------------------------------------- + | Filesystem Disks + |-------------------------------------------------------------------------- + | + | Below you may configure as many filesystem disks as necessary, and you + | may even configure multiple disks for the same driver. Examples for + | most supported storage drivers are configured here for reference. + | + | Supported drivers: "local", "ftp", "sftp", "s3" + | + */ + + 'disks' => [ + + 'local' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'serve' => true, + 'throw' => false, + 'report' => false, + ], + + 'public' => [ + 'driver' => 'local', + 'root' => storage_path('app/public'), + 'url' => rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', + 'visibility' => 'public', + 'throw' => false, + 'report' => false, + ], + + 's3' => [ + 'driver' => 's3', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION'), + 'bucket' => env('AWS_BUCKET'), + 'url' => env('AWS_URL'), + 'endpoint' => env('AWS_ENDPOINT'), + 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), + 'throw' => false, + 'report' => false, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Symbolic Links + |-------------------------------------------------------------------------- + | + | Here you may configure the symbolic links that will be created when the + | `storage:link` Artisan command is executed. The array keys should be + | the locations of the links and the values should be their targets. + | + */ + + 'links' => [ + public_path('storage') => storage_path('app/public'), + ], + +]; diff --git a/config/logging.php b/config/logging.php new file mode 100644 index 0000000..97bdc03 --- /dev/null +++ b/config/logging.php @@ -0,0 +1,140 @@ + env('LOG_CHANNEL', 'stack'), + + /* + |-------------------------------------------------------------------------- + | Deprecations Log Channel + |-------------------------------------------------------------------------- + | + | This option controls the log channel that should be used to log warnings + | regarding deprecated PHP and library features. This allows you to get + | your application ready for upcoming major versions of dependencies. + | + */ + + 'deprecations' => [ + 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), + 'trace' => env('LOG_DEPRECATIONS_TRACE', false), + ], + + /* + |-------------------------------------------------------------------------- + | Log Channels + |-------------------------------------------------------------------------- + | + | Here you may configure the log channels for your application. Laravel + | utilizes the Monolog PHP logging library, which includes a variety + | of powerful log handlers and formatters that you're free to use. + | + | Available drivers: "single", "daily", "monthly", "slack", "syslog", + | "errorlog", "monolog", "custom", "stack" + | + */ + + 'channels' => [ + + 'stack' => [ + 'driver' => 'stack', + 'channels' => explode(',', (string) env('LOG_STACK', 'single')), + 'ignore_exceptions' => false, + ], + + 'single' => [ + 'driver' => 'single', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'max_files' => env('LOG_DAILY_DAYS', 14), + 'replace_placeholders' => true, + ], + + 'monthly' => [ + 'driver' => 'monthly', + 'path' => storage_path('logs/laravel.log'), + 'level' => env('LOG_LEVEL', 'debug'), + 'max_files' => 3, + 'replace_placeholders' => true, + ], + + 'slack' => [ + 'driver' => 'slack', + 'url' => env('LOG_SLACK_WEBHOOK_URL'), + 'username' => env('LOG_SLACK_USERNAME', env('APP_NAME', 'Laravel')), + 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), + 'level' => env('LOG_LEVEL', 'critical'), + 'replace_placeholders' => true, + ], + + 'papertrail' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), + 'handler_with' => [ + 'host' => env('PAPERTRAIL_URL'), + 'port' => env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), + ], + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'stderr' => [ + 'driver' => 'monolog', + 'level' => env('LOG_LEVEL', 'debug'), + 'handler' => StreamHandler::class, + 'handler_with' => [ + 'stream' => 'php://stderr', + ], + 'formatter' => env('LOG_STDERR_FORMATTER'), + 'processors' => [PsrLogMessageProcessor::class], + ], + + 'syslog' => [ + 'driver' => 'syslog', + 'level' => env('LOG_LEVEL', 'debug'), + 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), + 'replace_placeholders' => true, + ], + + 'errorlog' => [ + 'driver' => 'errorlog', + 'level' => env('LOG_LEVEL', 'debug'), + 'replace_placeholders' => true, + ], + + 'null' => [ + 'driver' => 'monolog', + 'handler' => NullHandler::class, + ], + + 'emergency' => [ + 'path' => storage_path('logs/laravel.log'), + ], + + ], + +]; diff --git a/config/mail.php b/config/mail.php new file mode 100644 index 0000000..e32e88d --- /dev/null +++ b/config/mail.php @@ -0,0 +1,118 @@ + env('MAIL_MAILER', 'log'), + + /* + |-------------------------------------------------------------------------- + | Mailer Configurations + |-------------------------------------------------------------------------- + | + | Here you may configure all of the mailers used by your application plus + | their respective settings. Several examples have been configured for + | you and you are free to add your own as your application requires. + | + | Laravel supports a variety of mail "transport" drivers that can be used + | when delivering an email. You may specify which one you're using for + | your mailers below. You may also add additional mailers if needed. + | + | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", + | "postmark", "resend", "log", "array", + | "failover", "roundrobin" + | + */ + + 'mailers' => [ + + 'smtp' => [ + 'transport' => 'smtp', + 'scheme' => env('MAIL_SCHEME'), + 'url' => env('MAIL_URL'), + 'host' => env('MAIL_HOST', '127.0.0.1'), + 'port' => env('MAIL_PORT', 2525), + 'username' => env('MAIL_USERNAME'), + 'password' => env('MAIL_PASSWORD'), + 'timeout' => null, + 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), + ], + + 'ses' => [ + 'transport' => 'ses', + ], + + 'postmark' => [ + 'transport' => 'postmark', + // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), + // 'client' => [ + // 'timeout' => 5, + // ], + ], + + 'resend' => [ + 'transport' => 'resend', + ], + + 'sendmail' => [ + 'transport' => 'sendmail', + 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), + ], + + 'log' => [ + 'transport' => 'log', + 'channel' => env('MAIL_LOG_CHANNEL'), + ], + + 'array' => [ + 'transport' => 'array', + ], + + 'failover' => [ + 'transport' => 'failover', + 'mailers' => [ + 'smtp', + 'log', + ], + 'retry_after' => 60, + ], + + 'roundrobin' => [ + 'transport' => 'roundrobin', + 'mailers' => [ + 'ses', + 'postmark', + ], + 'retry_after' => 60, + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Global "From" Address + |-------------------------------------------------------------------------- + | + | You may wish for all emails sent by your application to be sent from + | the same address. Here you may specify a name and address that is + | used globally for all emails that are sent by your application. + | + */ + + 'from' => [ + 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), + 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), + ], + +]; diff --git a/config/queue.php b/config/queue.php new file mode 100644 index 0000000..79c2c0a --- /dev/null +++ b/config/queue.php @@ -0,0 +1,129 @@ + env('QUEUE_CONNECTION', 'database'), + + /* + |-------------------------------------------------------------------------- + | Queue Connections + |-------------------------------------------------------------------------- + | + | Here you may configure the connection options for every queue backend + | used by your application. An example configuration is provided for + | each backend supported by Laravel. You're also free to add more. + | + | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", + | "deferred", "background", "failover", "null" + | + */ + + 'connections' => [ + + 'sync' => [ + 'driver' => 'sync', + ], + + 'database' => [ + 'driver' => 'database', + 'connection' => env('DB_QUEUE_CONNECTION'), + 'table' => env('DB_QUEUE_TABLE', 'jobs'), + 'queue' => env('DB_QUEUE', 'default'), + 'retry_after' => (int) env('DB_QUEUE_RETRY_AFTER', 90), + 'after_commit' => false, + ], + + 'beanstalkd' => [ + 'driver' => 'beanstalkd', + 'host' => env('BEANSTALKD_QUEUE_HOST', 'localhost'), + 'queue' => env('BEANSTALKD_QUEUE', 'default'), + 'retry_after' => (int) env('BEANSTALKD_QUEUE_RETRY_AFTER', 90), + 'block_for' => 0, + 'after_commit' => false, + ], + + 'sqs' => [ + 'driver' => 'sqs', + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'prefix' => env('SQS_PREFIX', 'https://sqs.us-east-1.amazonaws.com/your-account-id'), + 'queue' => env('SQS_QUEUE', 'default'), + 'suffix' => env('SQS_SUFFIX'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + 'after_commit' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), + 'queue' => env('REDIS_QUEUE', 'default'), + 'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 90), + 'block_for' => null, + 'after_commit' => false, + ], + + 'deferred' => [ + 'driver' => 'deferred', + ], + + 'background' => [ + 'driver' => 'background', + ], + + 'failover' => [ + 'driver' => 'failover', + 'connections' => [ + 'database', + 'deferred', + ], + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Job Batching + |-------------------------------------------------------------------------- + | + | The following options configure the database and table that store job + | batching information. These options can be updated to any database + | connection and table which has been defined by your application. + | + */ + + 'batching' => [ + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'job_batches', + ], + + /* + |-------------------------------------------------------------------------- + | Failed Queue Jobs + |-------------------------------------------------------------------------- + | + | These options configure the behavior of failed queue job logging so you + | can control how and where failed jobs are stored. Laravel ships with + | support for storing failed jobs in a simple file or in a database. + | + | Supported drivers: "database-uuids", "dynamodb", "file", "null" + | + */ + + 'failed' => [ + 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), + 'database' => env('DB_CONNECTION', 'sqlite'), + 'table' => 'failed_jobs', + ], + +]; diff --git a/config/reverb.php b/config/reverb.php new file mode 100644 index 0000000..872ab98 --- /dev/null +++ b/config/reverb.php @@ -0,0 +1,102 @@ + env('REVERB_SERVER', 'reverb'), + + /* + |-------------------------------------------------------------------------- + | Reverb Servers + |-------------------------------------------------------------------------- + | + | Here you may define details for each of the supported Reverb servers. + | Each server has its own configuration options that are defined in + | the array below. You should ensure all the options are present. + | + */ + + 'servers' => [ + + 'reverb' => [ + 'host' => env('REVERB_SERVER_HOST', '0.0.0.0'), + 'port' => env('REVERB_SERVER_PORT', 8080), + 'path' => env('REVERB_SERVER_PATH', ''), + 'hostname' => env('REVERB_HOST'), + 'options' => [ + 'tls' => [], + ], + 'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000), + 'scaling' => [ + 'enabled' => env('REVERB_SCALING_ENABLED', false), + 'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'), + 'server' => [ + 'url' => env('REDIS_URL'), + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', '6379'), + 'username' => env('REDIS_USERNAME'), + 'password' => env('REDIS_PASSWORD'), + 'database' => env('REDIS_DB', '0'), + 'timeout' => env('REDIS_TIMEOUT', 60), + ], + ], + 'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15), + 'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15), + ], + + ], + + /* + |-------------------------------------------------------------------------- + | Reverb Applications + |-------------------------------------------------------------------------- + | + | Here you may define how Reverb applications are managed. If you choose + | to use the "config" provider, you may define an array of apps which + | your server will support, including their connection credentials. + | + */ + + 'apps' => [ + + 'provider' => 'config', + + 'apps' => [ + [ + 'key' => env('REVERB_APP_KEY'), + 'secret' => env('REVERB_APP_SECRET'), + 'app_id' => env('REVERB_APP_ID'), + 'options' => [ + 'host' => env('REVERB_HOST'), + 'port' => env('REVERB_PORT', 443), + 'scheme' => env('REVERB_SCHEME', 'https'), + 'useTLS' => env('REVERB_SCHEME', 'https') === 'https', + ], + 'allowed_origins' => ['*'], + 'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60), + 'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30), + 'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'), + 'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000), + 'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'all'), + 'rate_limiting' => [ + 'enabled' => env('REVERB_APP_RATE_LIMITING_ENABLED', false), + 'max_attempts' => env('REVERB_APP_RATE_LIMIT_MAX_ATTEMPTS', 60), + 'decay_seconds' => env('REVERB_APP_RATE_LIMIT_DECAY_SECONDS', 60), + 'terminate_on_limit' => env('REVERB_APP_RATE_LIMIT_TERMINATE', false), + ], + ], + ], + + ], + +]; diff --git a/config/services.php b/config/services.php new file mode 100644 index 0000000..ec226a4 --- /dev/null +++ b/config/services.php @@ -0,0 +1,38 @@ + [ + 'key' => env('POSTMARK_API_KEY'), + ], + + 'resend' => [ + 'key' => env('RESEND_API_KEY'), + ], + + 'ses' => [ + 'key' => env('AWS_ACCESS_KEY_ID'), + 'secret' => env('AWS_SECRET_ACCESS_KEY'), + 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), + ], + + 'slack' => [ + 'notifications' => [ + 'bot_user_oauth_token' => env('SLACK_BOT_USER_OAUTH_TOKEN'), + 'channel' => env('SLACK_BOT_USER_DEFAULT_CHANNEL'), + ], + ], + +]; diff --git a/config/session.php b/config/session.php new file mode 100644 index 0000000..f574482 --- /dev/null +++ b/config/session.php @@ -0,0 +1,233 @@ + env('SESSION_DRIVER', 'database'), + + /* + |-------------------------------------------------------------------------- + | Session Lifetime + |-------------------------------------------------------------------------- + | + | Here you may specify the number of minutes that you wish the session + | to be allowed to remain idle before it expires. If you want them + | to expire immediately when the browser is closed then you may + | indicate that via the expire_on_close configuration option. + | + */ + + 'lifetime' => (int) env('SESSION_LIFETIME', 120), + + 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), + + /* + |-------------------------------------------------------------------------- + | Session Encryption + |-------------------------------------------------------------------------- + | + | This option allows you to easily specify that all of your session data + | should be encrypted before it's stored. All encryption is performed + | automatically by Laravel and you may use the session like normal. + | + */ + + 'encrypt' => env('SESSION_ENCRYPT', false), + + /* + |-------------------------------------------------------------------------- + | Session File Location + |-------------------------------------------------------------------------- + | + | When utilizing the "file" session driver, the session files are placed + | on disk. The default storage location is defined here; however, you + | are free to provide another location where they should be stored. + | + */ + + 'files' => storage_path('framework/sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Database Connection + |-------------------------------------------------------------------------- + | + | When using the "database" or "redis" session drivers, you may specify a + | connection that should be used to manage these sessions. This should + | correspond to a connection in your database configuration options. + | + */ + + 'connection' => env('SESSION_CONNECTION'), + + /* + |-------------------------------------------------------------------------- + | Session Database Table + |-------------------------------------------------------------------------- + | + | When using the "database" session driver, you may specify the table to + | be used to store sessions. Of course, a sensible default is defined + | for you; however, you're welcome to change this to another table. + | + */ + + 'table' => env('SESSION_TABLE', 'sessions'), + + /* + |-------------------------------------------------------------------------- + | Session Cache Store + |-------------------------------------------------------------------------- + | + | When using one of the framework's cache driven session backends, you may + | define the cache store which should be used to store the session data + | between requests. This must match one of your defined cache stores. + | + | Affects: "dynamodb", "memcached", "redis" + | + */ + + 'store' => env('SESSION_STORE'), + + /* + |-------------------------------------------------------------------------- + | Session Sweeping Lottery + |-------------------------------------------------------------------------- + | + | Some session drivers must manually sweep their storage location to get + | rid of old sessions from storage. Here are the chances that it will + | happen on a given request. By default, the odds are 2 out of 100. + | + */ + + 'lottery' => [2, 100], + + /* + |-------------------------------------------------------------------------- + | Session Cookie Name + |-------------------------------------------------------------------------- + | + | Here you may change the name of the session cookie that is created by + | the framework. Typically, you should not need to change this value + | since doing so does not grant a meaningful security improvement. + | + */ + + 'cookie' => env( + 'SESSION_COOKIE', + Str::slug((string) env('APP_NAME', 'laravel')).'-session' + ), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Path + |-------------------------------------------------------------------------- + | + | The session cookie path determines the path for which the cookie will + | be regarded as available. Typically, this will be the root path of + | your application, but you're free to change this when necessary. + | + */ + + 'path' => env('SESSION_PATH', '/'), + + /* + |-------------------------------------------------------------------------- + | Session Cookie Domain + |-------------------------------------------------------------------------- + | + | This value determines the domain and subdomains the session cookie is + | available to. By default, the cookie will be available to the root + | domain without subdomains. Typically, this shouldn't be changed. + | + */ + + 'domain' => env('SESSION_DOMAIN'), + + /* + |-------------------------------------------------------------------------- + | HTTPS Only Cookies + |-------------------------------------------------------------------------- + | + | By setting this option to true, session cookies will only be sent back + | to the server if the browser has a HTTPS connection. This will keep + | the cookie from being sent to you when it can't be done securely. + | + */ + + 'secure' => env('SESSION_SECURE_COOKIE'), + + /* + |-------------------------------------------------------------------------- + | HTTP Access Only + |-------------------------------------------------------------------------- + | + | Setting this value to true will prevent JavaScript from accessing the + | value of the cookie and the cookie will only be accessible through + | the HTTP protocol. It's unlikely you should disable this option. + | + */ + + 'http_only' => env('SESSION_HTTP_ONLY', true), + + /* + |-------------------------------------------------------------------------- + | Same-Site Cookies + |-------------------------------------------------------------------------- + | + | This option determines how your cookies behave when cross-site requests + | take place, and can be used to mitigate CSRF attacks. By default, we + | will set this value to "lax" to permit secure cross-site requests. + | + | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value + | + | Supported: "lax", "strict", "none", null + | + */ + + 'same_site' => env('SESSION_SAME_SITE', 'lax'), + + /* + |-------------------------------------------------------------------------- + | Partitioned Cookies + |-------------------------------------------------------------------------- + | + | Setting this value to true will tie the cookie to the top-level site for + | a cross-site context. Partitioned cookies are accepted by the browser + | when flagged "secure" and the Same-Site attribute is set to "none". + | + */ + + 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), + + /* + |-------------------------------------------------------------------------- + | Session Serialization + |-------------------------------------------------------------------------- + | + | This value controls the serialization strategy for session data, which + | is JSON by default. Setting this to "php" allows the storage of PHP + | objects in the session but can make an application vulnerable to + | "gadget chain" serialization attacks if the APP_KEY is leaked. + | + | Supported: "json", "php" + | + */ + + 'serialization' => 'json', + +]; diff --git a/database/.gitignore b/database/.gitignore new file mode 100644 index 0000000..9b19b93 --- /dev/null +++ b/database/.gitignore @@ -0,0 +1 @@ +*.sqlite* diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php new file mode 100644 index 0000000..c4ceb07 --- /dev/null +++ b/database/factories/UserFactory.php @@ -0,0 +1,45 @@ + + */ +class UserFactory extends Factory +{ + /** + * The current password being used by the factory. + */ + protected static ?string $password; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->name(), + 'email' => fake()->unique()->safeEmail(), + 'email_verified_at' => now(), + 'password' => static::$password ??= Hash::make('password'), + 'remember_token' => Str::random(10), + ]; + } + + /** + * Indicate that the model's email address should be unverified. + */ + public function unverified(): static + { + return $this->state(fn (array $attributes) => [ + 'email_verified_at' => null, + ]); + } +} diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php new file mode 100644 index 0000000..05fb5d9 --- /dev/null +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -0,0 +1,49 @@ +id(); + $table->string('name'); + $table->string('email')->unique(); + $table->timestamp('email_verified_at')->nullable(); + $table->string('password'); + $table->rememberToken(); + $table->timestamps(); + }); + + Schema::create('password_reset_tokens', function (Blueprint $table) { + $table->string('email')->primary(); + $table->string('token'); + $table->timestamp('created_at')->nullable(); + }); + + Schema::create('sessions', function (Blueprint $table) { + $table->string('id')->primary(); + $table->foreignId('user_id')->nullable()->index(); + $table->string('ip_address', 45)->nullable(); + $table->text('user_agent')->nullable(); + $table->longText('payload'); + $table->integer('last_activity')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('users'); + Schema::dropIfExists('password_reset_tokens'); + Schema::dropIfExists('sessions'); + } +}; diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php new file mode 100644 index 0000000..06dc7a5 --- /dev/null +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -0,0 +1,35 @@ +string('key')->primary(); + $table->mediumText('value'); + $table->bigInteger('expiration')->index(); + }); + + Schema::create('cache_locks', function (Blueprint $table) { + $table->string('key')->primary(); + $table->string('owner'); + $table->bigInteger('expiration')->index(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('cache'); + Schema::dropIfExists('cache_locks'); + } +}; diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php new file mode 100644 index 0000000..edac6fe --- /dev/null +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -0,0 +1,59 @@ +id(); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedSmallInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + + Schema::create('job_batches', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('name'); + $table->integer('total_jobs'); + $table->integer('pending_jobs'); + $table->integer('failed_jobs'); + $table->longText('failed_job_ids'); + $table->mediumText('options')->nullable(); + $table->integer('cancelled_at')->nullable(); + $table->integer('created_at'); + $table->integer('finished_at')->nullable(); + }); + + Schema::create('failed_jobs', function (Blueprint $table) { + $table->id(); + $table->string('uuid')->unique(); + $table->string('connection'); + $table->string('queue'); + $table->longText('payload'); + $table->longText('exception'); + $table->timestamp('failed_at')->useCurrent(); + + $table->index(['connection', 'queue', 'failed_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('jobs'); + Schema::dropIfExists('job_batches'); + Schema::dropIfExists('failed_jobs'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php new file mode 100644 index 0000000..6b901f8 --- /dev/null +++ b/database/seeders/DatabaseSeeder.php @@ -0,0 +1,25 @@ +create(); + + User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + ]); + } +} diff --git a/devenv.lock b/devenv.lock new file mode 100644 index 0000000..3c13aac --- /dev/null +++ b/devenv.lock @@ -0,0 +1,45 @@ +{ + "nodes": { + "devenv": { + "locked": { + "dir": "src/modules", + "lastModified": 1788707710, + "narHash": "sha256-+ZyV4DJ5ljfp3ouZ1m3mAVWawzXLsn3i5YPvg34aURs=", + "owner": "cachix", + "repo": "devenv", + "rev": "34eb5ddc0db51fbf6f3a8df5fc42b0e91a5be8f8", + "type": "github" + }, + "original": { + "dir": "src/modules", + "owner": "cachix", + "repo": "devenv", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1788549839, + "narHash": "sha256-kOrCcSIA6w9J1hX5DqHy2k9pDTJymExTsbV74U9UtCA=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "17de0b976395537756f30a3e78f2f06e5cec89ed", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devenv": "devenv", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} \ No newline at end of file diff --git a/devenv.nix b/devenv.nix new file mode 100644 index 0000000..d4946f1 --- /dev/null +++ b/devenv.nix @@ -0,0 +1,26 @@ +{ pkgs, ... }: +{ + languages.php.enable = true; + languages.php.package = pkgs.php85; + + languages.javascript.enable = true; + languages.javascript.package = pkgs.nodejs; + languages.javascript.npm.enable = true; + + services.redis.enable = true; + + packages = [ + pkgs.php85Packages.composer + ]; + + enterShell = '' + echo "Multiplayer game server dev environment ready." + php -v | head -1 + redis-cli ping >/dev/null 2>&1 || echo "Redis: start with: devenv up" + ''; + + scripts.dev.exec = "php artisan serve"; + scripts.reverb.exec = "php artisan reverb:start"; + scripts.simulate.exec = "php artisan game:simulate"; + scripts.test.exec = "php artisan test"; +} \ No newline at end of file diff --git a/devenv.yaml b/devenv.yaml new file mode 100644 index 0000000..9860f42 --- /dev/null +++ b/devenv.yaml @@ -0,0 +1,3 @@ +inputs: + nixpkgs: + url: github:NixOS/nixpkgs/nixpkgs-unstable \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e3ff11b --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,97 @@ +name: zombies + +services: + redis: + image: redis:8-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "no"] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 10 + networks: + - game + + fpm: + build: + context: . + target: app + restart: unless-stopped + command: ["php-fpm", "-F"] + environment: &game-env + APP_NAME: Zombies + APP_ENV: production + APP_DEBUG: "false" + APP_URL: http://localhost + APP_KEY: ${APP_KEY:-} + LOG_CHANNEL: stderr + BROADCAST_CONNECTION: reverb + REDIS_CLIENT: predis + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PREFIX: "" + CACHE_STORE: file + SESSION_DRIVER: file + QUEUE_CONNECTION: sync + DB_CONNECTION: sqlite + REVERB_APP_ID: ${REVERB_APP_ID:-game-local} + REVERB_APP_KEY: ${REVERB_APP_KEY:-game-local-key} + REVERB_APP_SECRET: ${REVERB_APP_SECRET:-game-local-secret} + REVERB_HOST: reverb + REVERB_PORT: 8080 + REVERB_SCHEME: http + REVERB_SERVER_HOST: 0.0.0.0 + REVERB_SERVER_PORT: 8080 + expose: + - "9000" + depends_on: + redis: + condition: service_healthy + networks: + - game + + reverb: + build: + context: . + target: app + restart: unless-stopped + command: ["php", "artisan", "reverb:start", "--no-interaction"] + environment: *game-env + expose: + - "8080" + depends_on: + redis: + condition: service_healthy + networks: + - game + + simulate: + build: + context: . + target: app + restart: unless-stopped + command: ["php", "artisan", "game:simulate"] + environment: *game-env + depends_on: + redis: + condition: service_healthy + networks: + - game + + web: + build: + context: . + target: web + restart: unless-stopped + ports: + - "80:80" + depends_on: + - fpm + - reverb + networks: + - game + +networks: + game: + name: zombies-game \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..fe13209 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +cd /var/www/html + +mkdir -p storage/framework/sessions storage/framework/views storage/framework/cache +chown -R www-data:www-data storage bootstrap/cache +chmod -R ug+rw storage bootstrap/cache + +if [ -z "${APP_KEY:-}" ]; then + export APP_KEY="base64:$(php artisan key:generate --show --no-interaction)" +fi + +php artisan package:discover --no-interaction +php artisan config:cache --no-interaction +php artisan view:cache --no-interaction + +exec "$@" \ No newline at end of file diff --git a/nginx/default.conf b/nginx/default.conf new file mode 100644 index 0000000..f581491 --- /dev/null +++ b/nginx/default.conf @@ -0,0 +1,53 @@ +server { + listen 80; + server_name _; + root /var/www/html/public; + index index.php; + + client_max_body_size 2m; + + # Built client assets (long-term cacheable) + location /build/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Laravel front controller / SPA entry (serves resources/views/app.blade.php) + location / { + try_files $uri $uri/ /index.php?$query_string; + } + + # Only ever route index.php to PHP-FPM + location ~ ^/index\.php(/|$) { + include fastcgi_params; + fastcgi_pass fpm:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; + } + + location ~ \.php$ { + return 404; + } + + # Optional HTTP input fallback (POST /api/game/{room}/input) + location /api { + include fastcgi_params; + fastcgi_pass fpm:9000; + fastcgi_param SCRIPT_FILENAME $document_root/index.php; + } + + # Same-origin Reverb (Pusher-protocol) websocket + handshake + location /app/ { + proxy_pass http://reverb:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + proxy_send_timeout 60s; + } +} \ No newline at end of file diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..53e16f3 --- /dev/null +++ b/opencode.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "laravel-boost": { + "type": "local", + "enabled": true, + "command": [ + "php", + "artisan", + "boost:mcp" + ] + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f2384c8 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2354 @@ +{ + "name": "server", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^10.0.3", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1" + } + }, + "node_modules/@alcalzone/ansi-tokenize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.3.0.tgz", + "integrity": "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "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.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "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/@laravel/multiplex": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@laravel/multiplex/-/multiplex-0.4.3.tgz", + "integrity": "sha512-jJTZGgKl6sK0ETYmqiPqeAwmErvqjxgDRLfzqsmqiWiupc5dfjaoVVrrdXZwb6eGylSg99WwHLibCPJs8F0bGw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin", + "linux" + ], + "dependencies": { + "ansi-styles": "^6.2.3", + "commander": "^15.0.0", + "ink": "^7.1.0", + "react": "^19.2.7", + "string-width": "^8.2.2", + "wrap-ansi": "^10.0.0" + }, + "bin": { + "multiplex": "dist/cli.js" + }, + "engines": { + "node": ">=22.13.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.148.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", + "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.7.tgz", + "integrity": "sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.7.tgz", + "integrity": "sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.7.tgz", + "integrity": "sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.7.tgz", + "integrity": "sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.7.tgz", + "integrity": "sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.7.tgz", + "integrity": "sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.7.tgz", + "integrity": "sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.7.tgz", + "integrity": "sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.7.tgz", + "integrity": "sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.7.tgz", + "integrity": "sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.7.tgz", + "integrity": "sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.7.tgz", + "integrity": "sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.7.tgz", + "integrity": "sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.7.tgz", + "integrity": "sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.7.tgz", + "integrity": "sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "license": "MIT", + "optional": true, + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/auto-bind": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/auto-bind/-/auto-bind-5.0.1.tgz", + "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cli-boxes": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-4.0.1.tgz", + "integrity": "sha512-5IOn+jcCEHEraYolBPs/sT4BxYCe2nHg374OPiItB1O96KZFseS2gthU4twyYzeDcFew4DaUM/xwc5BQf08JJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18.20 <19 || >=20.10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-4.0.0.tgz", + "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", + "license": "MIT", + "optional": true, + "dependencies": { + "restore-cursor": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-6.1.1.tgz", + "integrity": "sha512-06p9vyLahLa4zkGcgsGxU6iEkSOiuI4fhCH6Emhe2lPAcoUv73n72DnODsnHA+5wwXGnV0n9M9/qOQJSjYhFhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "slice-ansi": "^9.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/code-excerpt": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/code-excerpt/-/code-excerpt-4.0.0.tgz", + "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", + "license": "MIT", + "optional": true, + "dependencies": { + "convert-to-spaces": "^2.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/commander": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz", + "integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/concurrently": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.5.tgz", + "integrity": "sha512-JaP/CoftUrCcAFW/g//RbgEGwlelnEae6cfBLgH6ZdO6s8jPkn6p9SB9u6pdVxYXoiSnFqseOlHfrEfF82TVOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "5.6.2", + "rxjs": "7.8.2", + "shell-quote": "1.9.0", + "supports-color": "10.2.2", + "tree-kill": "1.2.2", + "yargs": "18.0.0" + }, + "bin": { + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" + } + }, + "node_modules/convert-to-spaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", + "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/es-toolkit": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz", + "integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==", + "license": "MIT", + "optional": true, + "workspaces": [ + "docs", + "benchmarks", + "tests/types", + "tests/browser-compat" + ] + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/indent-string": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-5.0.0.tgz", + "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ink": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/ink/-/ink-7.1.1.tgz", + "integrity": "sha512-Y43xxa1ZSPvpmfLHcN5o+OdP8Rf8ykkNJEuKYOUNZKT8wXVNLFTtEm1nSDMQkfBH+YANF4Xuu0hhZ4ejqAtN2w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@alcalzone/ansi-tokenize": "^0.3.0", + "ansi-escapes": "^7.3.0", + "ansi-styles": "^6.2.3", + "auto-bind": "^5.0.1", + "chalk": "^5.6.2", + "cli-boxes": "^4.0.1", + "cli-cursor": "^4.0.0", + "cli-truncate": "^6.0.0", + "code-excerpt": "^4.0.0", + "es-toolkit": "^1.45.1", + "indent-string": "^5.0.0", + "is-in-ci": "^2.0.0", + "patch-console": "^2.0.0", + "react-reconciler": "^0.33.0", + "scheduler": "^0.27.0", + "signal-exit": "^3.0.7", + "slice-ansi": "^9.0.0", + "stack-utils": "^2.0.6", + "string-width": "^8.2.0", + "terminal-size": "^4.0.1", + "type-fest": "^5.5.0", + "widest-line": "^6.0.0", + "wrap-ansi": "^10.0.0", + "ws": "^8.20.0", + "yoga-layout": "~3.2.1" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "@types/react": ">=19.2.0", + "react": ">=19.2.0", + "react-devtools-core": ">=6.1.2" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-devtools-core": { + "optional": true + } + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ci": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-in-ci/-/is-in-ci-2.0.0.tgz", + "integrity": "sha512-cFeerHriAnhrQSbpAxL37W1wcJKUUX07HyLWZCW1URJT/ra3GyUTzBgUnh24TMVfNTV2Hij2HLxkPHFZfOZy5w==", + "license": "MIT", + "optional": true, + "bin": { + "is-in-ci": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/laravel-vite-plugin": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-3.2.0.tgz", + "integrity": "sha512-xSxY9Gzeb/eancd8WeK09piAFP+a6i5QIBqNCKNv9L0Eq6wziwzSem7F1GvMSrtjMh5F/QVKFxn8t9naGOA66A==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "tinyglobby": "^0.2.12", + "vite-plugin-full-reload": "^1.1.0" + }, + "bin": { + "clean-orphaned-assets": "bin/clean.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "fontaine": "^0.8.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "fontaine": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/patch-console": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/patch-console/-/patch-console-2.0.0.tgz", + "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "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.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz", + "integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.2.0" + } + }, + "node_modules/restore-cursor": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz", + "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", + "license": "MIT", + "optional": true, + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/rolldown": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.7.tgz", + "integrity": "sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.148.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.7", + "@rolldown/binding-android-arm64": "1.2.7", + "@rolldown/binding-darwin-arm64": "1.2.7", + "@rolldown/binding-darwin-x64": "1.2.7", + "@rolldown/binding-freebsd-x64": "1.2.7", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.7", + "@rolldown/binding-linux-arm64-gnu": "1.2.7", + "@rolldown/binding-linux-arm64-musl": "1.2.7", + "@rolldown/binding-linux-ppc64-gnu": "1.2.7", + "@rolldown/binding-linux-s390x-gnu": "1.2.7", + "@rolldown/binding-linux-x64-gnu": "1.2.7", + "@rolldown/binding-linux-x64-musl": "1.2.7", + "@rolldown/binding-openharmony-arm64": "1.2.7", + "@rolldown/binding-win32-arm64-msvc": "1.2.7", + "@rolldown/binding-win32-x64-msvc": "1.2.7" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT", + "optional": true + }, + "node_modules/shell-quote": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "optional": true + }, + "node_modules/slice-ansi": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-9.0.0.tgz", + "integrity": "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "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/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "license": "MIT", + "optional": true, + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/supports-color": { + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terminal-size": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.1.tgz", + "integrity": "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "license": "MIT", + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.9.0.tgz", + "integrity": "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==", + "license": "(MIT OR CC0-1.0)", + "optional": true, + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/vite": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", + "integrity": "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.26", + "rolldown": "~1.2.4", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-full-reload": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vite-plugin-full-reload/-/vite-plugin-full-reload-1.2.0.tgz", + "integrity": "sha512-kz18NW79x0IHbxRSHm0jttP4zoO9P9gXh+n6UTwlNKnviTTEpOlum6oS9SmecrTtSr+muHEn5TUuC75UovQzcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "picomatch": "^2.3.1" + } + }, + "node_modules/vite-plugin-full-reload/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/widest-line": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-6.0.0.tgz", + "integrity": "sha512-U89AsyEeAsyoF0zVJBkG9zBgekjgjK7yk9sje3F4IQpXBJ10TF6ByLlIfjMhcmHMJgHZI4KHt4rdNfktzxIAMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "string-width": "^8.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoga-layout": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz", + "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", + "license": "MIT", + "optional": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..13ec885 --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://www.schemastore.org/package.json", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "concurrently": "^10.0.3", + "laravel-vite-plugin": "^3.1", + "tailwindcss": "^4.0.0", + "vite": "^8.0.0" + }, + "optionalDependencies": { + "@laravel/multiplex": "^0.4.1" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e7f0a48 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,36 @@ + + + + + tests/Unit + + + tests/Feature + + + + + app + + + + + + + + + + + + + + + + + + + diff --git a/public/.htaccess b/public/.htaccess new file mode 100644 index 0000000..b574a59 --- /dev/null +++ b/public/.htaccess @@ -0,0 +1,25 @@ + + + Options -MultiViews -Indexes + + + RewriteEngine On + + # Handle Authorization Header + RewriteCond %{HTTP:Authorization} . + RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] + + # Handle X-XSRF-Token Header + RewriteCond %{HTTP:x-xsrf-token} . + RewriteRule .* - [E=HTTP_X_XSRF_TOKEN:%{HTTP:X-XSRF-Token}] + + # Redirect Trailing Slashes If Not A Folder... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_URI} (.+)/$ + RewriteRule ^ %1 [L,R=301] + + # Send Requests To Front Controller... + RewriteCond %{REQUEST_FILENAME} !-d + RewriteCond %{REQUEST_FILENAME} !-f + RewriteRule ^ index.php [L] + diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..ee8f07e --- /dev/null +++ b/public/index.php @@ -0,0 +1,20 @@ +handleRequest(Request::capture()); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..eb05362 --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Disallow: diff --git a/resources/css/game.css b/resources/css/game.css new file mode 100644 index 0000000..91253ed --- /dev/null +++ b/resources/css/game.css @@ -0,0 +1,245 @@ +html, body { + margin: 0; + height: 100%; + overflow: hidden; + background: #000; +} +#game { + display: block; + width: 100vw; + height: 100vh; + background: #000; +} +#fullscreen { + position: fixed; + right: 12px; + bottom: 12px; + z-index: 10; + padding: 8px 14px; + border: 1px solid #333; + border-radius: 4px; + background: #fff; + cursor: pointer; + font: 14px sans-serif; +} +#lobby { + position: fixed; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.72); +} +#lobby.hidden { + display: none; +} +#lobby-panel { + width: min(420px, 90vw); + padding: 28px 32px; + border: 1px solid #222; + border-radius: 8px; + background: #0c0c0e; + color: #eee; + font: 15px/1.5 sans-serif; +} +#lobby-panel h1 { + margin: 0 0 4px; + font-size: 34px; + letter-spacing: 6px; + color: #fff; +} +#lobby-panel .sub { + margin: 0 0 20px; + color: #888; + font-size: 13px; +} +#lobby-panel label { + display: block; + margin: 12px 0 4px; + font-size: 13px; + color: #aaa; +} +#lobby-panel input { + width: 100%; + box-sizing: border-box; + padding: 9px 10px; + border: 1px solid #333; + border-radius: 4px; + background: #111; + color: #eee; + font: 14px sans-serif; +} +#lobby-panel .row { + display: flex; + gap: 10px; +} +#lobby-panel .row > div { + flex: 1; +} +.mode { + display: flex; + gap: 10px; + margin-top: 22px; +} +.mode button { + flex: 1; + padding: 11px; + border: 0; + border-radius: 4px; + color: #fff; + font: 15px sans-serif; + font-weight: 600; + cursor: pointer; +} +#lobby-host { + background: #1971c2; +} +#lobby-host:hover { + background: #228be6; +} +#lobby-join { + background: #2f9e44; +} +#lobby-join:hover { + background: #37b24d; +} +#join-form { + margin-top: 14px; +} +#join-form.hidden { + display: none; +} +#lobby-join-confirm { + flex: 0 0 auto; + padding: 9px 18px; + border: 0; + border-radius: 4px; + background: #2f9e44; + color: #fff; + font: 14px sans-serif; + font-weight: 600; + cursor: pointer; +} +#lobby-join-confirm:hover { + background: #37b24d; +} +#prejoin.hidden, +#inroom.hidden { + display: none; +} +.room-code { + display: flex; + align-items: center; + gap: 10px; + margin-top: 12px; +} +#room-code { + flex: 1; + padding: 9px 10px; + border: 1px solid #333; + border-radius: 4px; + background: #111; + color: #f5c518; + font: 18px monospace; + letter-spacing: 2px; + text-align: center; +} +#room-copy { + padding: 8px 12px; + border: 1px solid #333; + border-radius: 4px; + background: #111; + color: #eee; + font: 13px sans-serif; + cursor: pointer; +} +#room-copy:hover { + background: #1a1a1f; +} +#lobby-start { + width: 100%; + margin-top: 10px; + padding: 11px; + border: 0; + border-radius: 4px; + background: #1971c2; + color: #fff; + font: 15px sans-serif; + font-weight: 600; + cursor: pointer; +} +#lobby-start:disabled { + background: #25252a; + color: #666; + cursor: not-allowed; +} +#lobby-status { + margin-top: 14px; + font-size: 13px; + color: #8a8a8a; + min-height: 18px; +} +#lobby-status.error { + color: #e8590c; +} +#lobby-players { + margin-top: 14px; + font-size: 13px; + color: #bbb; + min-height: 18px; +} +#gameover { + position: fixed; + inset: 0; + z-index: 20; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.78); +} +#gameover.show { + display: flex; +} +#gameover-panel { + width: min(380px, 90vw); + padding: 28px 32px; + border: 1px solid #333; + border-radius: 8px; + background: #0c0c0e; + color: #eee; + text-align: center; + font: 15px/1.5 sans-serif; +} +#gameover-panel h2 { + margin: 0 0 6px; + font-size: 36px; + letter-spacing: 5px; + color: #e03131; +} +#gameover-scores { + margin: 16px 0; + font-size: 14px; + color: #bbb; + min-height: 40px; +} +#gameover-wait { + min-height: 20px; + margin-top: 10px; + font-size: 13px; + color: #8a8a8a; +} +#gameover-again { + width: 100%; + padding: 11px; + border: 0; + border-radius: 4px; + background: #2f9e44; + color: #fff; + font: 15px sans-serif; + font-weight: 600; + cursor: pointer; +} +#gameover-again:hover { + background: #37b24d; +} \ No newline at end of file diff --git a/resources/js/engine/Effects.js b/resources/js/engine/Effects.js new file mode 100644 index 0000000..2798e9a --- /dev/null +++ b/resources/js/engine/Effects.js @@ -0,0 +1,231 @@ +const MAX_RINGS = 40; +const MAX_GLOWS = 60; +const MAX_STREAKS = 90; +const MAX_ARCS = 28; + +const easeOut = (t) => 1 - (1 - t) * (1 - t); + +export class Effects { + constructor() { + this.rings = []; + this.glows = []; + this.streaks = []; + this.arcs = []; + } + + ring(x, y, { + endRadius = 140, + width = 5, + life = 0.5, + color = [1, 1, 1, 1], + additive = true, + } = {}) { + if (this.rings.length >= MAX_RINGS) this.rings.shift(); + this.rings.push({ + x, y, + radius: endRadius * 0.12, + endRadius, + width, + life, + maxLife: life, + color, + additive, + }); + } + + glow(x, y, { + radius = 90, + life = 0.35, + color = [1, 1, 1, 1], + additive = true, + } = {}) { + if (this.glows.length >= MAX_GLOWS) this.glows.shift(); + this.glows.push({ + x, y, + radius: radius * 0.2, + endRadius: radius, + life, + maxLife: life, + color, + additive, + }); + } + + streak(x, y, angle, length, { + width = 3, + life = 0.25, + fade = true, + color = [1, 1, 1, 1], + additive = true, + tail = 0.7, + } = {}) { + if (this.streaks.length >= MAX_STREAKS) this.streaks.shift(); + this.streaks.push({ + x: x - Math.cos(angle) * length * tail, + y: y - Math.sin(angle) * length * tail, + angle, + length, + width, + life, + maxLife: life, + fade, + color, + additive, + tail, + }); + } + + arc(x1, y1, x2, y2, { + segments = 8, + width = 3, + life = 0.3, + color = [1, 1, 1, 1], + jitter = 26, + additive = true, + } = {}) { + if (this.arcs.length >= MAX_ARCS) this.arcs.shift(); + this.arcs.push({ + x1, y1, x2, y2, + segments, + width, + life, + maxLife: life, + color, + jitter, + additive, + seed: Math.random() * 100, + }); + } + + update(dt) { + const tick = (list) => { + for (let i = list.length - 1; i >= 0; i -= 1) { + list[i].life -= dt; + if (list[i].life <= 0) list.splice(i, 1); + } + }; + tick(this.rings); + tick(this.glows); + tick(this.streaks); + tick(this.arcs); + } + + draw(renderer) { + for (const list of [this.glows, this.rings, this.streaks, this.arcs]) { + const additive = list.filter((effect) => effect.additive); + const normal = list.filter((effect) => !effect.additive); + + if (additive.length > 0) renderer.setBlendMode('additive'); + for (const effect of additive) this._drawEffect(renderer, effect); + if (additive.length > 0) renderer.setBlendMode('normal'); + + for (const effect of normal) this._drawEffect(renderer, effect); + } + } + + _drawEffect(renderer, effect) { + const t = 1 - effect.life / effect.maxLife; + const alpha = effect.life / effect.maxLife; + + if (effect.x1 !== undefined) { + this._drawArc(renderer, effect, t, alpha); + } else if (effect.angle !== undefined) { + this._drawStreak(renderer, effect, t, alpha); + } else if (effect.width !== undefined) { + this._drawRing(renderer, effect, t, alpha); + } else { + this._drawGlow(renderer, effect, t, alpha); + } + } + + _drawGlow(renderer, effect, t, alpha) { + const radius = effect.radius + (effect.endRadius - effect.radius) * easeOut(t); + renderer.drawTexture( + renderer.glowTexture, + effect.x - radius, + effect.y - radius, + radius * 2, + radius * 2, + [ + effect.color[0], + effect.color[1], + effect.color[2], + effect.color[3] * alpha, + ], + ); + } + + _drawRing(renderer, effect, t, alpha) { + const radius = effect.radius + (effect.endRadius - effect.radius) * easeOut(t); + const fade = Math.min(1, alpha * 1.3); + renderer.drawTexture( + renderer.ringTexture, + effect.x - radius, + effect.y - radius, + radius * 2, + radius * 2, + [ + effect.color[0], + effect.color[1], + effect.color[2], + Math.max(0, effect.color[3] * fade), + ], + ); + } + + _drawStreak(renderer, effect, t, alpha) { + const progress = easeOut(t); + const len = effect.length * (1 - progress * (1 - effect.tail)); + const fade = effect.fade ? alpha : 1; + renderer.drawTexture( + renderer.glowTexture, + effect.x, + effect.y, + len, + effect.width, + [ + effect.color[0], + effect.color[1], + effect.color[2], + effect.color[3] * fade, + ], + effect.angle, + ); + } + + _drawArc(renderer, effect, t, alpha) { + const { x1, y1, x2, y2, segments, width, jitter, seed, color } = effect; + const wobble = 0.45 + 0.55 * Math.abs(Math.sin(seed + t * 34)); + const amp = jitter * alpha * wobble; + + let prevX = x1; + let prevY = y1; + for (let i = 1; i <= segments; i += 1) { + const u = i / segments; + const edge = (i === segments) ? 0 : 1; + const px = x1 + (x2 - x1) * u + (Math.random() - 0.5) * 2 * amp * edge; + const py = y1 + (y2 - y1) * u + (Math.random() - 0.5) * 2 * amp * edge; + const len = Math.hypot(px - prevX, py - prevY); + if (len > 0) { + renderer.drawTexture( + renderer.whiteTexture, + prevX, + prevY, + len, + width, + [color[0], color[1], color[2], color[3] * alpha], + Math.atan2(py - prevY, px - prevX), + ); + } + prevX = px; + prevY = py; + } + } + + clear() { + this.rings.length = 0; + this.glows.length = 0; + this.streaks.length = 0; + this.arcs.length = 0; + } +} \ No newline at end of file diff --git a/resources/js/engine/GameObject.js b/resources/js/engine/GameObject.js new file mode 100644 index 0000000..711b23c --- /dev/null +++ b/resources/js/engine/GameObject.js @@ -0,0 +1,29 @@ +import { Vector2D } from './Vector2D.js'; + +export class GameObject { + constructor(x = 0, y = 0) { + this.pos = new Vector2D(x, y); + this.velocity = new Vector2D(0, 0); + this.width = 0; + this.height = 0; + } + + update(dt = 1 / 60) { + this.pos = this.velocity.mulS(dt).addV(this.pos); + } + + intersect(other) { + return ( + Math.abs(this.pos.x - other.pos.x) * 2 < this.width + other.width && + Math.abs(this.pos.y - other.pos.y) * 2 < this.height + other.height + ); + } + + draw() {} + + remove(container) { + if (!container) return; + const index = container.indexOf(this); + if (index !== -1) container.splice(index, 1); + } +} \ No newline at end of file diff --git a/resources/js/engine/Input.js b/resources/js/engine/Input.js new file mode 100644 index 0000000..429bde7 --- /dev/null +++ b/resources/js/engine/Input.js @@ -0,0 +1,56 @@ +import { Vector2D } from './Vector2D.js'; + +export class Input { + constructor(canvas, viewport) { + this.keys = new Set(); + this.pressed = new Set(); + this.mouse = new Vector2D(viewport.width / 2, viewport.height / 2); + this.mouseDown = false; + this.mouseClicked = false; + + window.addEventListener('keydown', (event) => { + this.keys.add(event.key.toLowerCase()); + if (!event.repeat) { + this.pressed.add(event.key.toLowerCase()); + } + if (event.key === ' ') { + event.preventDefault(); + if ( + document.activeElement && + document.activeElement.tagName === 'BUTTON' && + typeof document.activeElement.blur === 'function' + ) { + document.activeElement.blur(); + } + } + }); + window.addEventListener('keyup', (event) => { + this.keys.delete(event.key.toLowerCase()); + }); + window.addEventListener('blur', () => { + this.keys.clear(); + this.pressed.clear(); + this.mouseDown = false; + }); + + canvas.addEventListener('mousemove', (event) => { + const rect = canvas.getBoundingClientRect(); + this.mouse.x = ((event.clientX - rect.left) / rect.width) * viewport.width; + this.mouse.y = ((event.clientY - rect.top) / rect.height) * viewport.height; + }); + canvas.addEventListener('mousedown', (event) => { + if (event.button === 0) { + this.mouseDown = true; + this.mouseClicked = true; + } + }); + window.addEventListener('mouseup', () => { + this.mouseDown = false; + }); + } + + clearPressed() { + this.pressed.clear(); + this.mouseClicked = false; + } +} \ No newline at end of file diff --git a/resources/js/engine/ParticleSystem.js b/resources/js/engine/ParticleSystem.js new file mode 100644 index 0000000..cb8e0e3 --- /dev/null +++ b/resources/js/engine/ParticleSystem.js @@ -0,0 +1,109 @@ +import { Vector2D } from './Vector2D.js'; + +const MAX_PARTICLES = 450; + +export class ParticleSystem { + constructor() { + this.particles = []; + } + + burst( + x, + y, + { + count = 12, + speed = 150, + direction = 0, + spread = Math.PI * 2, + life = 0.4, + size = 4, + color = [1, 1, 1, 1], + gravity = 0, + drag = 0, + shape = 'square', + additive = false, + endSize = null, + spin = 0, + } = {}, + ) { + for (let i = 0; i < count; i++) { + if (this.particles.length >= MAX_PARTICLES) return; + + const angle = direction + (Math.random() - 0.5) * spread; + const magnitude = speed * (0.4 + Math.random() * 0.8); + const velocity = new Vector2D(Math.cos(angle), Math.sin(angle)).mulS(magnitude); + const lifetime = life * (0.6 + Math.random() * 0.6); + const startSize = size * (0.6 + Math.random() * 0.8); + + this.particles.push({ + pos: new Vector2D(x, y), + velocity, + gravity, + drag, + life: lifetime, + maxLife: lifetime, + size: startSize, + endSize: endSize === null ? startSize * 0.15 : endSize * (0.6 + Math.random() * 0.8), + color: [...color], + shape, + additive, + angle: Math.random() * Math.PI * 2, + spin: (Math.random() - 0.5) * (spin * 2), + }); + } + } + + update(dt) { + const { particles } = this; + for (let i = particles.length - 1; i >= 0; i--) { + const particle = particles[i]; + particle.pos = particle.velocity.mulS(dt).addV(particle.pos); + particle.velocity.y += particle.gravity * dt; + if (particle.drag > 0) { + particle.velocity = particle.velocity.mulS(Math.max(0, 1 - particle.drag * dt)); + } + particle.angle += particle.spin * dt; + particle.life -= dt; + if (particle.life <= 0) { + particles.splice(i, 1); + } + } + } + + draw(renderer) { + const additive = this.particles.filter((particle) => particle.additive); + const normal = this.particles.filter((particle) => !particle.additive); + + if (additive.length > 0) renderer.setBlendMode('additive'); + for (const particle of additive) this._drawParticle(renderer, particle); + if (additive.length > 0) renderer.setBlendMode('normal'); + + for (const particle of normal) this._drawParticle(renderer, particle); + } + + _drawParticle(renderer, particle) { + const alpha = Math.max(0, particle.life / particle.maxLife); + const progress = 1 - alpha; + const size = particle.size + (particle.endSize - particle.size) * progress; + const color = [ + particle.color[0], + particle.color[1], + particle.color[2], + particle.color[3] * alpha, + ]; + const texture = particle.shape === 'glow' ? renderer.glowTexture : renderer.whiteTexture; + renderer.drawTexture( + texture, + particle.pos.x - size / 2, + particle.pos.y - size / 2, + size, + size, + color, + particle.angle, + ); + } + + clear() { + this.particles.length = 0; + } +} \ No newline at end of file diff --git a/resources/js/engine/Renderer.js b/resources/js/engine/Renderer.js new file mode 100644 index 0000000..2577a24 --- /dev/null +++ b/resources/js/engine/Renderer.js @@ -0,0 +1,251 @@ +import { Texture } from './Texture.js'; + +const VERTEX_SOURCE = `#version 300 es +layout(location = 0) in vec2 aPosition; + +uniform mat4 uProjection; +uniform mat4 uModel; + +out vec2 vTexCoord; + +void main() { + vTexCoord = aPosition; + gl_Position = uProjection * uModel * vec4(aPosition, 0.0, 1.0); +} +`; + +const FRAGMENT_SOURCE = `#version 300 es +precision mediump float; + +in vec2 vTexCoord; +out vec4 outColor; + +uniform sampler2D uSampler; +uniform vec4 uColor; + +void main() { + outColor = texture(uSampler, vTexCoord) * uColor; +} +`; + +const QUAD_POSITIONS = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); +const QUAD_INDICES = new Uint16Array([0, 1, 2, 2, 1, 3]); + +function compileShader(gl, type, source) { + const shader = gl.createShader(type); + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader); + gl.deleteShader(shader); + throw new Error(`Shader compile error: ${message}`); + } + return shader; +} + +export class Renderer { + constructor(canvas) { + this.canvas = canvas; + + const gl = canvas.getContext('webgl2', { + alpha: true, + premultipliedAlpha: false, + antialias: true, + }); + if (!gl) { + throw new Error('WebGL2 is not supported by this browser.'); + } + this.gl = gl; + + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, VERTEX_SOURCE); + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SOURCE); + + this.program = gl.createProgram(); + gl.attachShader(this.program, vertexShader); + gl.attachShader(this.program, fragmentShader); + gl.linkProgram(this.program); + if (!gl.getProgramParameter(this.program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(this.program); + throw new Error(`Program link error: ${message}`); + } + gl.deleteShader(vertexShader); + gl.deleteShader(fragmentShader); + + this.projectionLocation = gl.getUniformLocation(this.program, 'uProjection'); + this.modelLocation = gl.getUniformLocation(this.program, 'uModel'); + this.colorLocation = gl.getUniformLocation(this.program, 'uColor'); + this.samplerLocation = gl.getUniformLocation(this.program, 'uSampler'); + + this._centeredProjection = new Float32Array(16); + this._modelMatrix = new Float32Array(16); + + this._setupQuad(); + this.resize(canvas.width, canvas.height); + + gl.enable(gl.BLEND); + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + + this.setBlendMode('normal'); + + gl.useProgram(this.program); + gl.uniform1i(this.samplerLocation, 0); + } + + setBlendMode(mode) { + this._blendMode = mode; + const { gl } = this; + if (mode === 'additive') { + gl.blendFunc(gl.SRC_ALPHA, gl.ONE); + } else { + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + } + } + + clear(r = 0, g = 0, b = 0, a = 0) { + const { gl } = this; + gl.clearColor(r, g, b, a); + gl.clear(gl.COLOR_BUFFER_BIT); + } + + createTextureFromImage(image) { + const texture = new Texture(this.gl, image.width, image.height); + texture.uploadImage(image); + return texture; + } + + get whiteTexture() { + if (!this._whiteTexture) { + this._whiteTexture = new Texture(this.gl, 1, 1); + } + return this._whiteTexture; + } + + get glowTexture() { + if (!this._glowTexture) { + this._glowTexture = this._buildRadialTexture(64, (ctx, size) => { + const gradient = ctx.createRadialGradient( + size / 2, size / 2, 0, + size / 2, size / 2, size / 2, + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 1)'); + gradient.addColorStop(0.35, 'rgba(255, 255, 255, 0.6)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, size, size); + }); + } + return this._glowTexture; + } + + get ringTexture() { + if (!this._ringTexture) { + this._ringTexture = this._buildRadialTexture(128, (ctx, size) => { + ctx.clearRect(0, 0, size, size); + ctx.lineWidth = size * 0.06; + ctx.strokeStyle = '#fff'; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size * 0.42, 0, Math.PI * 2); + ctx.stroke(); + const gradient = ctx.createRadialGradient( + size / 2, size / 2, size * 0.28, + size / 2, size / 2, size * 0.52, + ); + gradient.addColorStop(0, 'rgba(255, 255, 255, 0.5)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)'); + ctx.fillStyle = gradient; + ctx.beginPath(); + ctx.arc(size / 2, size / 2, size * 0.5, 0, Math.PI * 2); + ctx.fill(); + }); + } + return this._ringTexture; + } + + _buildRadialTexture(size, draw) { + const canvas = document.createElement('canvas'); + canvas.width = size; + canvas.height = size; + const ctx = canvas.getContext('2d'); + draw(ctx, size); + const texture = new Texture(this.gl, size, size); + texture.uploadCanvas(canvas); + return texture; + } + + drawTexture(texture, x, y, width, height, color = [1, 1, 1, 1], angle = 0) { + const { gl } = this; + + this._setModelMatrix(x, y, width, height, angle); + + gl.useProgram(this.program); + gl.uniformMatrix4fv(this.projectionLocation, false, this._centeredProjection); + gl.uniformMatrix4fv(this.modelLocation, false, this._modelMatrix); + gl.uniform4f(this.colorLocation, ...color); + + gl.bindTexture(gl.TEXTURE_2D, texture.handle); + gl.bindVertexArray(this._quadVao); + gl.drawElements(gl.TRIANGLES, QUAD_INDICES.length, gl.UNSIGNED_SHORT, 0); + gl.bindVertexArray(null); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + resize(deviceWidth, deviceHeight, worldWidth = deviceWidth, worldHeight = deviceHeight) { + this.canvas.width = deviceWidth; + this.canvas.height = deviceHeight; + this.gl.viewport(0, 0, deviceWidth, deviceHeight); + this._worldWidth = worldWidth; + this._worldHeight = worldHeight; + this._syncProjection(0, 0, worldWidth, worldHeight); + } + + setViewOffset(dx = 0, dy = 0) { + this._syncProjection(dx, dy, this._worldWidth, this._worldHeight); + } + + _setupQuad() { + const { gl } = this; + + this._quadVao = gl.createVertexArray(); + gl.bindVertexArray(this._quadVao); + + const positionBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); + gl.bufferData(gl.ARRAY_BUFFER, QUAD_POSITIONS, gl.STATIC_DRAW); + gl.enableVertexAttribArray(0); + gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0); + + const indexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer); + gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, QUAD_INDICES, gl.STATIC_DRAW); + + gl.bindVertexArray(null); + } + + _setModelMatrix(x, y, width, height, angle = 0) { + const m = this._modelMatrix; + if (angle === 0) { + m[0] = width; m[1] = 0; m[2] = 0; m[3] = 0; + m[4] = 0; m[5] = height; m[6] = 0; m[7] = 0; + m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0; + m[12] = x; m[13] = y; m[14] = 0; m[15] = 1; + return; + } + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const cx = x + width / 2; + const cy = y + height / 2; + m[0] = width * cos; m[1] = width * sin; m[2] = 0; m[3] = 0; + m[4] = -height * sin; m[5] = height * cos; m[6] = 0; m[7] = 0; + m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0; + m[12] = cx; m[13] = cy; m[14] = 0; m[15] = 1; + } + + _syncProjection(left, top, width, height) { + const m = this._centeredProjection; + m[0] = 2 / width; m[1] = 0; m[2] = 0; m[3] = 0; + m[4] = 0; m[5] = -2 / height; m[6] = 0; m[7] = 0; + m[8] = 0; m[9] = 0; m[10] = 1; m[11] = 0; + m[12] = -1 - (2 * left) / width; m[13] = 1 + (2 * top) / height; m[14] = 0; m[15] = 1; + } +} \ No newline at end of file diff --git a/resources/js/engine/SoundSystem.js b/resources/js/engine/SoundSystem.js new file mode 100644 index 0000000..78ddd99 --- /dev/null +++ b/resources/js/engine/SoundSystem.js @@ -0,0 +1,372 @@ +export class SoundSystem { + constructor({ muted = false } = {}) { + this.muted = muted; + this.context = null; + this.master = null; + this.delaySend = null; + this.musicOn = false; + this.musicTimer = null; + this._musicStep = 0; + this._nextNoteTime = 0; + this._baseStepDur = 60 / 132 / 4; + this._stepDur = this._baseStepDur; + this._intensity = 0; + this._bossMode = false; + this._stepsPerBar = 16; + this._progression = [ + { root: 73.42, chord: [146.83, 174.61, 220] }, + { root: 58.27, chord: [116.54, 146.83, 174.61] }, + { root: 87.31, chord: [174.61, 220, 261.63] }, + { root: 65.41, chord: [130.81, 164.81, 196] }, + ]; + this._bossProgression = [ + { root: 41.2, chord: [82.41, 98, 123.47] }, + { root: 65.41, chord: [130.81, 164.81, 196] }, + { root: 55, chord: [110, 130.81, 164.81] }, + { root: 73.42, chord: [146.83, 174.61, 220] }, + ]; + this._arpPattern = [0, 1, 2, 1, 0, 2, 1, 2, 0, 1, 2, 1, 0, 2, 1, 2]; + this._leadPattern = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [587.33, 0, 698.46, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 587.33, 0, 523.25, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 440, 0, 523.25, 0, 587.33, 0, 0, 0], + ]; + this._leadPatternB = [ + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [659.26, 0, 880, 0, 987.77, 0, 880, 0, 783.99, 0, 698.46, 0, 659.26, 0, 0, 0], + [0, 0, 0, 0, 587.33, 0, 659.26, 0, 698.46, 0, 659.26, 0, 587.33, 0, 0, 0], + ]; + this._bassPattern = [1, 0, 1, 0, 1, 0, 1, 0]; + this._barPlan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar: -1 }; + + const unlock = () => { + if (!this.context) { + const AudioContext = window.AudioContext || window.webkitAudioContext; + if (!AudioContext) return; + this.context = new AudioContext(); + this.master = this.context.createGain(); + this.master.gain.value = this.muted ? 0 : 0.5; + this.master.connect(this.context.destination); + + const delay = this.context.createDelay(0.6); + delay.delayTime.value = 0.31; + const feedback = this.context.createGain(); + feedback.gain.value = 0.35; + const wet = this.context.createGain(); + wet.gain.value = 0.22; + delay.connect(feedback); + feedback.connect(delay); + delay.connect(wet); + wet.connect(this.master); + this.delaySend = delay; + } + if (this.context.state === 'suspended') { + this.context.resume().catch(() => {}); + } + }; + + window.addEventListener('keydown', unlock, { once: false }); + window.addEventListener('pointerdown', unlock, { once: false }); + } + + startMusic() { + if (this.musicOn) return; + this.musicOn = true; + this._musicStep = 0; + this._nextNoteTime = this.context ? this.context.currentTime + 0.1 : 0; + if (this.musicTimer) clearInterval(this.musicTimer); + this.musicTimer = setInterval(() => this._scheduleMusic(), 100); + } + + stopMusic() { + this.musicOn = false; + if (this.musicTimer) { + clearInterval(this.musicTimer); + this.musicTimer = null; + } + } + + setIntensity(intensity) { + const clamped = Math.max(0, Math.min(1, intensity)); + this._intensity = clamped; + this._stepDur = this._baseStepDur * (1 - 0.35 * clamped); + } + + setBossMode(on) { + this._bossMode = on; + } + + _scheduleMusic() { + if (!this.context || !this.musicOn) return; + const aheadTime = this.context.currentTime + 0.5; + const totalSteps = this._stepsPerBar * this._progression.length; + while (this._nextNoteTime < aheadTime) { + try { + this._scheduleStep(this._musicStep, this._nextNoteTime); + } catch (error) { + console.error('Music schedule error', error); + } + this._nextNoteTime += this._stepDur; + this._musicStep = (this._musicStep + 1) % totalSteps; + } + } + + _rollBarPlan(bar) { + const plan = { fill: false, breakdown: false, lift: false, leadVariant: 0, bar }; + if (bar === 1) { + const roll = Math.random(); + if (roll < 0.2) { + plan.fill = true; + } else if (roll < 0.32) { + plan.breakdown = true; + } + } else if (bar === 2 || bar === 3) { + plan.leadVariant = Math.random() < 0.35 ? 1 : 0; + plan.lift = Math.random() < 0.25; + } + return plan; + } + + _scheduleStep(step, time) { + const delay = time - this.context.currentTime; + const bar = Math.floor(step / this._stepsPerBar) % this._progression.length; + const beat = step % this._stepsPerBar; + const barDur = this._stepDur * this._stepsPerBar; + const intensity = this._intensity; + const drumGain = 1 + intensity * 0.6; + + if (beat === 0 || this._barPlan.bar !== bar) { + this._barPlan = this._rollBarPlan(bar); + } + const plan = this._barPlan; + const fill = plan.fill; + const breakdown = plan.breakdown; + const boss = this._bossMode; + + let { root, chord } = boss + ? this._bossProgression[bar] + : this._progression[bar]; + if (plan.lift && bar === 2 && !boss) { + chord = [174.61, 207.65, 261.63]; + } + + if (boss && beat === 0) { + this.tone(27.5, 'sine', barDur, 0.06, 27.5, delay); + } + + if (beat === 0) { + for (const note of chord) { + this.tone(note, 'sawtooth', barDur, 0.02 + intensity * 0.01, note, delay); + } + } + + const kickFreq = boss ? 44 : 55; + const snareFreq = boss ? 700 : 1800; + + if (!fill && !breakdown) { + if (beat % 4 === 2 && this._bassPattern[Math.floor(beat / 4)]) { + this.tone(root, 'square', this._stepDur * 1.5, 0.055, root, delay); + } + if (intensity > 0.4 && beat % 2 === 0) { + this.tone(root * 2, 'square', this._stepDur * 0.9, 0.02, root * 2, delay); + } + if (beat % 4 === 0) { + this.tone(kickFreq, 'sine', 0.14, 0.1 * drumGain, Math.max(28, kickFreq - 18), delay); + } + if (beat === 4 || beat === 12) { + this.noise(0.1, 0.07 * drumGain, snareFreq, delay); + } + if (beat % 4 === 2) { + this.noise(0.03, 0.02 * drumGain, 9000, delay); + } + if (beat % 4 === 3 || beat % 4 === 1) { + this.noise(0.02, 0.012 * drumGain, 10000, delay); + } + + if (beat % 4 === 1 || beat % 4 === 3) { + const arp = chord[this._arpPattern[beat]]; + this.tone(arp, 'triangle', 0.1, 0.018, arp, delay, this.delaySend); + this.tone(arp * 2, 'triangle', 0.12, 0.012, arp * 2, delay); + } + } else if (fill) { + if (beat % 2 === 0) { + this.tone(55, 'sine', 0.1, 0.06, 34, delay); + } + if (beat >= 12) { + this.noise(0.06, 0.045, 1500, delay); + } + if (beat % 2 === 1) { + this.noise(0.03, 0.018, 8500, delay); + } + if (beat % 3 === 0) { + this.noise(0.05, 0.012, 400 + beat * 160, delay); + } + const index = beat >= 12 ? (beat - 12) % 3 : Math.floor(beat / 2) % 3; + const note = chord[index] * (beat >= 12 ? 4 : 2); + this.tone(note, 'triangle', 0.09, 0.02, note, delay); + } else if (breakdown) { + if (beat === 0 || beat === 8) { + this.tone(55, 'sine', 0.18, 0.08, 32, delay); + } + } + + if (!fill && !breakdown) { + const lead = (plan.leadVariant === 1 ? this._leadPatternB : this._leadPattern)[bar][beat]; + if (lead > 0) { + this.tone(lead, 'sawtooth', 0.22, 0.03, lead, delay, this.delaySend); + } + } + } + + setMuted(muted) { + this.muted = muted; + if (this.master && this.context) { + this.master.gain.setTargetAtTime(muted ? 0 : 0.5, this.context.currentTime, 0.01); + } + } + + toggleMuted() { + this.setMuted(!this.muted); + return this.muted; + } + + tone(freq, type, duration, gain, glideTo = freq, delay = 0, bus = this.master) { + if (!this.context || this.muted) return; + if (!Number.isFinite(freq) || !Number.isFinite(glideTo)) return; + const { context } = this; + const start = context.currentTime + delay; + const oscillator = context.createOscillator(); + const shape = context.createGain(); + + oscillator.type = type; + oscillator.frequency.setValueAtTime(Math.max(1, freq), start); + oscillator.frequency.exponentialRampToValueAtTime(Math.max(1, glideTo), start + duration); + + shape.gain.setValueAtTime(0.0001, start); + shape.gain.exponentialRampToValueAtTime(gain, start + 0.008); + shape.gain.exponentialRampToValueAtTime(0.0001, start + duration); + + oscillator.connect(shape); + shape.connect(bus || this.master); + oscillator.start(start); + oscillator.stop(start + duration + 0.02); + } + + noise(duration, gain, filterFreq, delay = 0, bus = this.master) { + if (!this.context || this.muted) return; + const { context } = this; + const start = context.currentTime + delay; + const bufferSize = Math.max(1, Math.ceil(context.sampleRate * duration)); + const buffer = context.createBuffer(1, bufferSize, context.sampleRate); + const data = buffer.getChannelData(0); + for (let i = 0; i < bufferSize; i += 1) { + data[i] = Math.random() * 2 - 1; + } + + const source = context.createBufferSource(); + source.buffer = buffer; + const filter = context.createBiquadFilter(); + filter.type = 'bandpass'; + filter.frequency.value = filterFreq; + filter.Q.value = 1.2; + + const shape = context.createGain(); + shape.gain.setValueAtTime(0.0001, start); + shape.gain.exponentialRampToValueAtTime(gain, start + 0.005); + shape.gain.exponentialRampToValueAtTime(0.0001, start + duration); + + source.connect(filter); + filter.connect(shape); + shape.connect(bus || this.master); + source.start(start); + source.stop(start + duration + 0.02); + } + + shoot() { + this.tone(240, 'square', 0.1, 0.075, 85); + this.noise(0.05, 0.028, 3200); + } + + hit() { + this.noise(0.12, 0.16, 1500); + this.tone(180, 'triangle', 0.1, 0.12, 120); + } + + death(big = false) { + if (big) { + this.noise(0.35, 0.22, 700); + this.tone(120, 'sawtooth', 0.3, 0.2, 45); + this.tone(90, 'sine', 0.4, 0.24, 30); + return; + } + this.noise(0.2, 0.14, 500); + this.tone(140, 'triangle', 0.22, 0.16, 55); + } + + damage() { + this.tone(160, 'sawtooth', 0.2, 0.18, 80); + this.noise(0.15, 0.1, 900); + } + + explosion() { + this.noise(0.7, 0.3, 400); + this.tone(200, 'sawtooth', 0.6, 0.22, 30); + this.tone(60, 'sine', 0.8, 0.3, 20); + } + + pickup() { + this.tone(660, 'sine', 0.12, 0.14, 660); + this.tone(990, 'sine', 0.18, 0.14, 990, 0.09); + } + + stingerStart() { + this.tone(392, 'triangle', 0.12, 0.1); + this.tone(523, 'triangle', 0.12, 0.1, undefined, 0.1); + this.tone(659, 'triangle', 0.3, 0.12, undefined, 0.2); + } + + bossEntrance() { + this.tone(27.5, 'sine', 1.6, 0.22, 27.5); + this.tone(110, 'sawtooth', 0.5, 0.1, 55); + this.noise(0.5, 0.12, 250); + this.tone(82.41, 'square', 0.25, 0.08, 82.41, 0.7); + this.tone(61.74, 'square', 0.4, 0.08, 61.74, 0.95); + } + + playGameOverMusic() { + this.stopMusic(); + this._playGameOverSeq(); + } + + _playGameOverSeq() { + if (!this.context) return; + const now = this.context.currentTime; + const t = (offset) => Math.max(0, offset - now); + + this.tone(55, 'sine', 8, 0.05, 51, t(0)); + this.tone(55, 'sine', 8, 0.03, 55, t(4)); + + const chords = [ + [110, 164.81, 220], + [98, 146.83, 196], + [87.31, 130.81, 174.61], + [82.41, 123.47, 164.81], + ]; + chords.forEach((chord, i) => { + const start = 0.2 + i * 1.5; + for (const note of chord) { + this.tone(note, 'sawtooth', 1.5, 0.018, note, t(start)); + } + if (i === 0 || i === 2) { + this.tone(55, 'sine', 0.14, 0.09, 32, t(start)); + } + }); + + const melody = [880, 783.99, 698.46, 659.26, 587.33, 523.25, 493.88, 440]; + melody.forEach((note, i) => { + this.tone(note, 'triangle', 0.7, 0.032, note, t(0.5 + i * 0.55), this.delaySend); + }); + } +} \ No newline at end of file diff --git a/resources/js/engine/TextRenderer.js b/resources/js/engine/TextRenderer.js new file mode 100644 index 0000000..5955da1 --- /dev/null +++ b/resources/js/engine/TextRenderer.js @@ -0,0 +1,70 @@ +import { Texture } from './Texture.js'; + +export class TextRenderer { + constructor(gl) { + this.gl = gl; + this.canvas = document.createElement('canvas'); + this.ctx = this.canvas.getContext('2d'); + this._cachedKey = null; + this._cachedEntry = null; + } + + draw(renderer, text, x, y, options = {}) { + const entry = this._resolve(text, options); + renderer.drawTexture(entry.texture, x, y, entry.width, entry.height); + return entry; + } + + measure(text, options = {}) { + return this._resolve(text, options); + } + + _resolve(text, options = {}) { + const key = this._makeKey(text, options); + if (this._cachedKey !== key) { + this._flushCache(); + this._cachedKey = key; + this._cachedEntry = this._rasterize(text, options); + } + return this._cachedEntry; + } + + _makeKey(text, { size = 20, font = 'arial', fillStyle = 'black', bold = true }) { + return `${text}|${size}|${font}|${fillStyle}|${bold}`; + } + + _rasterize(text, { size = 20, font = 'arial', fillStyle = 'black', bold = true }) { + const { ctx, canvas } = this; + const pad = 4; + const fontString = `${bold ? 'bold ' : ''}${size}px ${font}`; + + ctx.font = fontString; + const textWidth = Math.ceil(ctx.measureText(text).width); + const width = textWidth + pad * 2; + const height = Math.ceil(size * 1.3) + pad * 2; + const scale = Math.min(window.devicePixelRatio || 1, 2); + + canvas.width = Math.max(1, Math.ceil(width * scale)); + canvas.height = Math.max(1, Math.ceil(height * scale)); + ctx.setTransform(scale, 0, 0, scale, 0, 0); + ctx.clearRect(0, 0, width, height); + + ctx.font = fontString; + ctx.fillStyle = fillStyle; + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(text, pad, pad + size); + + const texture = new Texture(this.gl, canvas.width, canvas.height); + texture.uploadCanvas(canvas); + + return { texture, width, height }; + } + + _flushCache() { + if (this._cachedEntry) { + this._cachedEntry.texture.dispose(); + this._cachedEntry = null; + } + } +} \ No newline at end of file diff --git a/resources/js/engine/Texture.js b/resources/js/engine/Texture.js new file mode 100644 index 0000000..8609bb6 --- /dev/null +++ b/resources/js/engine/Texture.js @@ -0,0 +1,56 @@ +export class Texture { + constructor(gl, width = 1, height = 1) { + this.gl = gl; + this.width = width; + this.height = height; + this.handle = gl.createTexture(); + + gl.bindTexture(gl.TEXTURE_2D, this.handle); + gl.texImage2D( + gl.TEXTURE_2D, + 0, + gl.RGBA, + 1, + 1, + 0, + gl.RGBA, + gl.UNSIGNED_BYTE, + new Uint8Array([255, 255, 255, 255]), + ); + this._configureSampling(); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + uploadImage(image) { + const { gl } = this; + this.width = image.width; + this.height = image.height; + + gl.bindTexture(gl.TEXTURE_2D, this.handle); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + uploadCanvas(canvas) { + const { gl } = this; + this.width = canvas.width; + this.height = canvas.height; + + gl.bindTexture(gl.TEXTURE_2D, this.handle); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, canvas); + gl.bindTexture(gl.TEXTURE_2D, null); + } + + _configureSampling() { + const { gl } = this; + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + } + + dispose() { + this.gl.deleteTexture(this.handle); + this.handle = null; + } +} \ No newline at end of file diff --git a/resources/js/engine/Vector2D.js b/resources/js/engine/Vector2D.js new file mode 100644 index 0000000..c755a86 --- /dev/null +++ b/resources/js/engine/Vector2D.js @@ -0,0 +1,67 @@ +export class Vector2D { + constructor(x = 0, y = 0) { + this.x = x; + this.y = y; + } + + mulS(scalar) { + return new Vector2D(this.x * scalar, this.y * scalar); + } + + mulV(vector) { + return new Vector2D(this.x * vector.x, this.y * vector.y); + } + + divS(scalar) { + return new Vector2D(this.x / scalar, this.y / scalar); + } + + divV(vector) { + return new Vector2D(this.x / vector.x, this.y / vector.y); + } + + addS(scalar) { + return new Vector2D(this.x + scalar, this.y + scalar); + } + + addV(vector) { + return new Vector2D(this.x + vector.x, this.y + vector.y); + } + + subS(scalar) { + return new Vector2D(this.x - scalar, this.y - scalar); + } + + subV(vector) { + return new Vector2D(this.x - vector.x, this.y - vector.y); + } + + dot(vector) { + return this.x * vector.x + this.y * vector.y; + } + + abs() { + return new Vector2D(Math.abs(this.x), Math.abs(this.y)); + } + + length() { + return Math.sqrt(this.dot(this)); + } + + lengthSqrt() { + return this.dot(this); + } + + normalize() { + const length = this.length(); + this.x /= length; + this.y /= length; + return this; + } + + rotate(angle) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + return new Vector2D(this.x * cos - this.y * sin, this.x * sin + this.y * cos); + } +} \ No newline at end of file diff --git a/resources/js/game/Boss.js b/resources/js/game/Boss.js new file mode 100644 index 0000000..bf86f18 --- /dev/null +++ b/resources/js/game/Boss.js @@ -0,0 +1,107 @@ +import { Zombie } from './Zombie.js'; + +export class Boss extends Zombie { + constructor( + x, + y, + target, + { + container, + viewport, + speed = 38, + size = 90, + color = [0.45, 0.05, 0.1, 1], + health = 80, + knockback = 40, + scoreValue = 50, + spawnInterval = 5, + spawnCount = 1, + spawnZombie = () => {}, + onDeath = null, + } = {}, + ) { + super(x, y, target, { + container, + viewport, + speed, + size, + color, + health, + knockback, + scoreValue, + onDeath, + }); + this.spawnInterval = spawnInterval; + this.spawnCount = spawnCount; + this.spawnTimer = 3; + this.spawnZombie = spawnZombie; + this._time = 0; + this._hurt = 0; + } + + hit(impulse, damage = 1) { + const dead = super.hit(impulse, damage); + this._flash = 0.18; + this._hurt = 0.18; + return dead; + } + + update(dt) { + super.update(dt); + this._time += dt; + this._hurt = Math.max(0, this._hurt - dt); + + this.spawnTimer -= dt; + if (this.spawnTimer <= 0) { + this.spawnTimer = this.spawnInterval; + const minions = this.container.filter( + (obj) => obj instanceof Zombie && !(obj instanceof Boss), + ); + if (minions.length < 15) { + for (let i = 0; i < this.spawnCount; i += 1) { + const angle = (i / this.spawnCount) * Math.PI * 2 + Math.random() * 0.6; + this.spawnZombie( + this.pos.x + Math.cos(angle) * 70, + this.pos.y + Math.sin(angle) * 70, + this.target, + this.container, + ); + } + } + } + } + + draw(renderer) { + this.width = this.size; + this.height = this.size; + const pulse = 1 + 0.05 * Math.sin(this._time * 4); + const hurtRatio = this._hurt / 0.18; + const s = this.size * pulse * (1 + hurtRatio * 0.14); + const hpRatio = Math.max(0, this.health / this.maxHealth); + const color = [ + Math.min(1, this.baseColor[0] + (1 - hpRatio) * 0.3), + this.baseColor[1] * hpRatio, + this.baseColor[2] * hpRatio, + 1, + ]; + + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s / 2, + this.pos.y - s / 2, + s, + s, + this._flash > 0 ? [1, 1, 1, 1] : color, + ); + + const core = s * (0.35 + (1 - hpRatio) * 0.4); + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - core / 2, + this.pos.y - core / 2, + core, + core, + [0.12, 0.02, 0.03, 0.9], + ); + } +} \ No newline at end of file diff --git a/resources/js/game/Bullet.js b/resources/js/game/Bullet.js new file mode 100644 index 0000000..3d2ee66 --- /dev/null +++ b/resources/js/game/Bullet.js @@ -0,0 +1,46 @@ +import { GameObject } from '../engine/GameObject.js'; +import { Vector2D } from '../engine/Vector2D.js'; + +export class Bullet extends GameObject { + constructor( + x, + y, + direction, + { container, viewport, speed = 800, size = 8, damage = 1, color = [1, 0.2, 0.2, 1] } = {}, + ) { + super(x, y); + this.velocity = direction.mulS(speed); + this.container = container; + this.viewport = viewport; + this.size = size; + this.damage = damage; + this.color = color; + this.width = size; + this.height = size; + } + + update(dt) { + super.update(dt); + + const { width, height } = this.viewport; + if ( + this.pos.x < -this.size || + this.pos.x > width + this.size || + this.pos.y < -this.size || + this.pos.y > height + this.size + ) { + this.remove(this.container); + } + } + + draw(renderer) { + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - this.size / 2, + this.pos.y - this.size / 2, + this.size, + this.size, + this.color, + ); + } +} \ No newline at end of file diff --git a/resources/js/game/Leaper.js b/resources/js/game/Leaper.js new file mode 100644 index 0000000..b8fcdd8 --- /dev/null +++ b/resources/js/game/Leaper.js @@ -0,0 +1,263 @@ +import { Zombie } from './Zombie.js'; +import { GameObject } from '../engine/GameObject.js'; +import { Vector2D } from '../engine/Vector2D.js'; + +export class Leaper extends Zombie { + constructor( + x, + y, + target, + { + container, + viewport, + speed = 95, + size = 28, + color = [0.15, 0.25, 0.75, 1], + health = 3, + knockback = 240, + scoreValue = 6, + dashRange = 300, + windupTime = 0.35, + dashTime = 0.28, + dashSpeed = 680, + dashCooldown = 1.8, + emergeTime = 0, + } = {}, + ) { + super(x, y, target, { + container, + viewport, + speed, + size, + color, + health, + knockback, + scoreValue, + emergeTime, + }); + this.dashRange = dashRange; + this.windupTime = windupTime; + this.dashTime = dashTime; + this.dashSpeed = dashSpeed; + this.dashCooldown = dashCooldown; + this._dashTimer = 0; + this._windupTimer = 0; + this._dashCooldown = 0; + this._dashDirection = new Vector2D(1, 0); + this._time = 0; + } + + update(dt) { + this._time += dt; + if (this._updateEmerge(dt)) return; + this._dashCooldown = Math.max(0, this._dashCooldown - dt); + + const delta = this.target.pos.subV(this.pos); + const distance = delta.length(); + + let move = new Vector2D(0, 0); + if (this._windupTimer > 0) { + this._windupTimer -= dt; + if (this._windupTimer <= 0) { + this._dashTimer = this.dashTime; + this._dashDirection = distance > 1 ? delta.divS(distance) : new Vector2D(1, 0); + } + } else if (this._dashTimer > 0) { + this._dashTimer -= dt; + move = this._dashDirection.mulS(this.dashSpeed); + } else if (distance > 1) { + if (distance < this.dashRange && this._dashCooldown <= 0) { + this._windupTimer = this.windupTime; + } else { + move = delta.divS(distance).mulS(this.speed); + } + } + + this.velocity = move.addV(this._hitKnockback); + GameObject.prototype.update.call(this, dt); + + this._hitKnockback = this._hitKnockback.mulS(Math.max(0, 1 - dt * this.knockbackDamping)); + this._flash = Math.max(0, this._flash - dt); + } + + draw(renderer) { + this._drawEmerge(renderer); + const s = this.size * this._emergeSize(); + const dashing = this._dashTimer > 0; + const winding = this._windupTimer > 0; + + let color = this.baseColor; + if (this._flash > 0) { + color = [1, 1, 1, 1]; + } else if (winding || dashing) { + color = [ + this.baseColor[0] + (1 - this.baseColor[0]) * 0.8, + this.baseColor[1] + (1 - this.baseColor[1]) * 0.8, + this.baseColor[2], + 1, + ]; + } + + const towards = dashing ? this._dashDirection : this.target.pos.subV(this.pos); + const facing = Math.atan2(towards.y, towards.x); + const dirX = Math.cos(facing); + const dirY = Math.sin(facing); + const perpX = -dirY; + const perpY = dirX; + + const shade = [color[0] * 0.65, color[1] * 0.65, color[2] * 0.65, 1]; + const shadeDark = [color[0] * 0.4, color[1] * 0.4, color[2] * 0.4, 1]; + const light = [ + Math.min(1, color[0] + 0.25), + Math.min(1, color[1] + 0.25), + Math.min(1, color[2] + 0.25), + 1, + ]; + + const drawLimb = (ax, ay, bx, by, thickness, limbColor) => { + const len = Math.hypot(bx - ax, by - ay); + if (len < 1) return; + renderer.drawTexture( + renderer.whiteTexture, + (ax + bx) / 2 - len / 2, + (ay + by) / 2 - thickness / 2, + len, + thickness, + limbColor, + Math.atan2(by - ay, bx - ax), + ); + }; + + const bodyLen = s * (dashing ? 1.45 : winding ? 0.9 : 1.15); + const bodyH = s * (dashing ? 0.42 : winding ? 0.7 : 0.55); + + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s * 0.72, + this.pos.y - s * 0.26 + s * 0.08, + s * 1.44, + s * 0.52, + [0, 0, 0, 0.35], + ); + + if (winding) { + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s * 0.9, + this.pos.y - s * 0.6, + s * 1.8, + s * 1.2, + [1, 1, 1, 0.3], + ); + } + + const tailBaseX = this.pos.x - dirX * bodyLen * 0.45; + const tailBaseY = this.pos.y - dirY * bodyLen * 0.45; + const tailSway = dashing ? 0 : Math.sin(this._time * 16) * s * 0.14; + const tailTipX = tailBaseX - dirX * (dashing ? s * 0.6 : s * 0.42) + perpX * tailSway; + const tailTipY = tailBaseY - dirY * (dashing ? s * 0.6 : s * 0.42) + perpY * tailSway; + drawLimb(tailBaseX, tailBaseY, tailTipX, tailTipY, s * 0.11, shadeDark); + drawLimb( + tailTipX, tailTipY, + tailTipX + perpX * tailSway * 0.5 - dirX * s * 0.08, + tailTipY + perpY * tailSway * 0.5 - dirY * s * 0.08, + s * 0.15, + shade, + ); + + const legL = s * (dashing ? 0.3 : winding ? 0.16 : 0.4); + const legW = s * 0.11; + for (let pair = 0; pair < 2; pair += 1) { + const front = pair === 0; + const baseFx = this.pos.x + dirX * bodyLen * (front ? 0.4 : -0.4); + const baseFy = this.pos.y + dirY * bodyLen * (front ? 0.4 : -0.4); + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const bx = baseFx + perpX * bodyH * 0.5 * sign; + const by = baseFy + perpY * bodyH * 0.5 * sign; + let fx = bx; + let fy = by; + if (dashing) { + fx = bx - dirX * legL; + fy = by - dirY * legL; + } else if (winding) { + fx = bx + perpX * sign * s * 0.1; + fy = by + s * 0.13; + } else { + const run = Math.sin(this._time * 18 + (front ? 0 : Math.PI)) * s * 0.16; + fx = bx + dirX * run * 0.7 + perpX * sign * s * 0.04; + fy = by + legL; + } + drawLimb(bx, by, fx, fy, legW, shadeDark); + } + } + + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - bodyLen / 2, + this.pos.y - bodyH / 2, + bodyLen, + bodyH, + color, + facing, + ); + + const headLen = s * (dashing ? 0.38 : 0.44); + const headH = s * 0.3; + const headCx = this.pos.x + dirX * (bodyLen * 0.5 - s * 0.03); + const headCy = this.pos.y + dirY * (bodyLen * 0.5 - s * 0.03); + renderer.drawTexture( + renderer.whiteTexture, + headCx - headLen / 2, + headCy - headH / 2, + headLen, + headH, + light, + facing, + ); + + const snoutLen = s * 0.28; + const snoutCx = headCx + dirX * (headLen * 0.5 + snoutLen * 0.4); + const snoutCy = headCy + dirY * (headLen * 0.5 + snoutLen * 0.4); + drawLimb(headCx, headCy, snoutCx, snoutCy, s * 0.18, shade); + const noseS = s * 0.08; + renderer.drawTexture( + renderer.whiteTexture, + snoutCx + dirX * snoutLen * 0.5 - noseS / 2, + snoutCy + dirY * snoutLen * 0.5 - noseS / 2, + noseS, + noseS, + [0.03, 0.05, 0.12, 1], + ); + + const eyeS = s * 0.08; + const eyeCx = headCx + dirX * headLen * 0.28; + const eyeCy = headCy + dirY * headLen * 0.28; + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + renderer.drawTexture( + renderer.whiteTexture, + eyeCx + perpX * eyeS * 1.5 * sign - eyeS / 2, + eyeCy + perpY * eyeS * 1.5 * sign - eyeS / 2, + eyeS, + eyeS, + [0.03, 0.05, 0.12, 1], + ); + } + + const earS = s * 0.13; + const earBack = dashing || winding ? 0.6 : 1; + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const eax = headCx + perpX * earS * 1.55 * sign; + const eay = headCy + perpY * earS * 1.55 * sign; + drawLimb( + eax, eay, + eax - dirX * earS * earBack, + eay - dirY * earS * earBack, + earS * 0.5, + shadeDark, + ); + } + } +} \ No newline at end of file diff --git a/resources/js/game/MegaTank.js b/resources/js/game/MegaTank.js new file mode 100644 index 0000000..e88db94 --- /dev/null +++ b/resources/js/game/MegaTank.js @@ -0,0 +1,44 @@ +import { Zombie } from './Zombie.js'; + +export class MegaTank extends Zombie { + constructor( + x, + y, + target, + { + container, + viewport, + speed = 30, + size = 64, + color = [0.55, 0.28, 0.12, 1], + health = 18, + knockback = 90, + scoreValue = 15, + spawnOnDeath = () => {}, + spawnCount = 3, + } = {}, + ) { + super(x, y, target, { + container, + viewport, + speed, + size, + color, + health, + knockback, + scoreValue, + onDeath: (mega) => { + for (let i = 0; i < spawnCount; i += 1) { + const angle = (i / spawnCount) * Math.PI * 2; + const offset = mega.size + 28; + spawnOnDeath( + mega.pos.x + Math.cos(angle) * offset, + mega.pos.y + Math.sin(angle) * offset, + target, + container, + ); + } + }, + }); + } +} \ No newline at end of file diff --git a/resources/js/game/MegaWitch.js b/resources/js/game/MegaWitch.js new file mode 100644 index 0000000..a2641e0 --- /dev/null +++ b/resources/js/game/MegaWitch.js @@ -0,0 +1,163 @@ +import { Witch } from './Witch.js'; +import { WitchProjectile } from './WitchProjectile.js'; +import { Vector2D } from '../engine/Vector2D.js'; + +export class MegaWitch extends Witch { + constructor( + x, + y, + target, + { + container, + viewport, + speed = 55, + size = 52, + color = [0.8, 0.15, 0.7, 1], + health = 22, + knockback = 130, + scoreValue = 12, + preferredDistance = 240, + fireInterval = 2, + projectileSpeed = 240, + projectileCount = 3, + projectileSpread = 0.35, + projectileColor = [1, 0.3, 0.3, 1], + novaInterval = 5, + novaCount = 16, + novaProjectileSpeed = 250, + spawnProjectile = (p) => {}, + spawnOnDeath = () => {}, + spawnCount = 3, + } = {}, + ) { + super(x, y, target, { + container, + viewport, + speed, + size, + color, + health, + knockback, + scoreValue, + preferredDistance, + fireInterval, + projectileSpeed, + projectileCount, + projectileSpread, + projectileColor, + spawnProjectile, + onDeath: (mega) => { + for (let i = 0; i < spawnCount; i += 1) { + const angle = (i / spawnCount) * Math.PI * 2; + const offset = mega.size + 30; + spawnOnDeath( + mega.pos.x + Math.cos(angle) * offset, + mega.pos.y + Math.sin(angle) * offset, + target, + container, + ); + } + }, + }); + this.novaInterval = novaInterval; + this.novaCount = novaCount; + this.novaProjectileSpeed = novaProjectileSpeed; + this._novaTimer = novaInterval * 0.5; + this._novaRotation = Math.random() * Math.PI * 2; + this._pendingNova = []; + } + + update(dt) { + super.update(dt); + + if (this.target.health <= 0) return; + + for (let i = this._pendingNova.length - 1; i >= 0; i -= 1) { + const entry = this._pendingNova[i]; + entry.remaining -= dt; + if (entry.remaining <= 0) { + this._pendingNova.splice(i, 1); + entry.fire(); + } + } + + this._novaTimer -= dt; + if (this._novaTimer <= 0) { + this._novaTimer = this.novaInterval * (0.85 + Math.random() * 0.3); + this.nova(); + } + } + + nova() { + const lost = 1 - Math.max(0, this.health / this.maxHealth); + const count = Math.max(6, Math.round(this.novaCount * (0.75 + 0.25 * lost))); + const speed = this.novaProjectileSpeed * (1 + lost * 0.45); + const size = this.size * 0.3; + const color = [1, 0.5, 0.3, 1]; + this._novaRotation += 0.36; + + const fireRing = (offset) => { + for (let i = 0; i < count; i += 1) { + const angle = (i / count) * Math.PI * 2 + this._novaRotation + offset; + const direction = new Vector2D(Math.cos(angle), Math.sin(angle)); + const projectile = new WitchProjectile(this.pos.x, this.pos.y, direction, { + container: this.container, + viewport: this.viewport, + speed, + size, + color, + }); + this.spawnProjectile(projectile); + } + }; + + fireRing(0); + this._pendingNova.push({ + remaining: 0.22, + fire: () => fireRing(Math.PI / count), + }); + if (lost > 0.34) { + this._pendingNova.push({ + remaining: 0.44, + fire: () => fireRing(0), + }); + } + this._cast = 0.32; + } + + draw(renderer) { + const charge = 1 - Math.max(0, this._novaTimer) / this.novaInterval; + if (charge > 0.72) { + const hp = Math.max(0, this.health / this.maxHealth); + const punch = (charge - 0.72) / 0.28; + const r = this.size * (0.6 + punch * 1.7) * (1.4 - hp * 0.6); + renderer.setBlendMode('additive'); + renderer.drawTexture( + renderer.glowTexture, + this.pos.x - r, + this.pos.y - r, + r * 2, + r * 2, + [1, 0.3, 0.15, punch * 0.7], + ); + renderer.drawTexture( + renderer.ringTexture, + this.pos.x - r * 1.5, + this.pos.y - r * 1.5, + r * 3, + r * 3, + [1, 0.45, 0.2, punch * 0.9], + ); + renderer.drawTexture( + renderer.ringTexture, + this.pos.x - r * 2.1, + this.pos.y - r * 2.1, + r * 4.2, + r * 4.2, + [1, 0.25, 0.1, punch * 0.35], + ); + renderer.setBlendMode('normal'); + } + super.draw(renderer); + } +} \ No newline at end of file diff --git a/resources/js/game/Pickup.js b/resources/js/game/Pickup.js new file mode 100644 index 0000000..ce80593 --- /dev/null +++ b/resources/js/game/Pickup.js @@ -0,0 +1,75 @@ +import { GameObject } from '../engine/GameObject.js'; + +export const PICKUP_TYPES = { + rapid: { name: 'Rapid Fire', color: [1, 0.8, 0.2, 1] }, + damage: { name: 'Damage +1', color: [1, 0.3, 0.3, 1] }, + multishot: { name: 'Multishot', color: [0.2, 0.8, 1, 1] }, + speed: { name: 'Bullet Speed', color: [0.6, 0.9, 1, 1] }, +}; + +const PICKUP_KEYS = Object.keys(PICKUP_TYPES); + +export const randomPickupType = (types = PICKUP_KEYS) => ( + types[Math.floor(Math.random() * types.length)] +); + +export class Pickup extends GameObject { + constructor(x, y, type, { container, viewport, life = 10, collectRadius = 46 } = {}) { + super(x, y); + this.type = type; + this.container = container; + this.viewport = viewport; + this.life = life; + this.collectRadius = collectRadius; + this._time = 0; + this.size = 16; + this.width = this.size; + this.height = this.size; + this.color = PICKUP_TYPES[type].color; + } + + intersect(other) { + const dx = this.pos.x - other.pos.x; + const dy = this.pos.y - other.pos.y; + return dx * dx + dy * dy <= this.collectRadius * this.collectRadius + || super.intersect(other); + } + + update(dt) { + this._time += dt; + if (this._time >= this.life) { + this.remove(this.container); + } + } + + draw(renderer, textRenderer) { + const remaining = this.life - this._time; + let alpha = 1; + if (remaining < 2) { + alpha = 0.3 + 0.7 * Math.max(0, Math.sin(remaining * 6)); + } + + const pulse = 1 + 0.15 * Math.sin(this._time * 6); + const s = this.size * pulse; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s / 2, + this.pos.y - s / 2, + s, + s, + [...this.color.slice(0, 3), alpha], + ); + + const label = PICKUP_TYPES[this.type].name; + const opts = { size: 12, fillStyle: '#fff', bold: true }; + const entry = textRenderer?.measure(label, opts); + const textHeight = entry ? entry.height : 0; + textRenderer?.draw( + renderer, + label, + this.pos.x - (entry ? entry.width / 2 : label.length * 4.5), + this.pos.y - s / 2 - textHeight - 4, + opts, + ); + } +} \ No newline at end of file diff --git a/resources/js/game/Player.js b/resources/js/game/Player.js new file mode 100644 index 0000000..84fd769 --- /dev/null +++ b/resources/js/game/Player.js @@ -0,0 +1,391 @@ +import { GameObject } from '../engine/GameObject.js'; +import { Vector2D } from '../engine/Vector2D.js'; +import { Bullet } from './Bullet.js'; +import { Zombie } from './Zombie.js'; + +export class Player extends GameObject { + constructor( + { + input, + container, + viewport, + moveSpeed = 420, + fireRate = 0.2, + recoilSpeed = 100, + recoilDamping = 15, + bulletSpeed = 800, + bulletSize = 8, + bulletCount = 1, + bulletSpread = 0.09, + damage = 1, + maxUpgradeLevel = 3, + health = 3, + particles = null, + sounds = null, + size = 24, + autoAim = false, + } = {}, + ) { + super(viewport.width / 2, viewport.height / 2); + this.input = input; + this.container = container; + this.viewport = viewport; + this.moveSpeed = moveSpeed; + this.fireRate = fireRate; + this.bulletSpeed = bulletSpeed; + this.bulletSize = bulletSize; + this.bulletCount = bulletCount; + this.bulletSpread = bulletSpread; + this.damage = damage; + this.maxUpgradeLevel = maxUpgradeLevel; + this.upgrades = { rapid: 0, damage: 0, multishot: 0, speed: 0 }; + this.recoilSpeed = recoilSpeed; + this.recoilDamping = recoilDamping; + this.health = health; + this.maxHealth = health; + this.particles = particles; + this.sounds = sounds; + this.size = size; + this._cooldown = 0; + this._aimDirection = new Vector2D(1, 0); + this._recoil = new Vector2D(0, 0); + this._damageCooldown = 0; + this._time = 0; + this.autoAim = autoAim; + } + + update(dt) { + const { width, height } = this.viewport; + this._time += dt; + + const movement = new Vector2D(0, 0); + const { keys } = this.input; + if (keys.has('w') || keys.has('arrowup')) movement.y -= 1; + if (keys.has('s') || keys.has('arrowdown')) movement.y += 1; + if (keys.has('a') || keys.has('arrowleft')) movement.x -= 1; + if (keys.has('d') || keys.has('arrowright')) movement.x += 1; + + let moveVelocity = new Vector2D(0, 0); + if (movement.lengthSqrt() > 0) { + moveVelocity = movement.normalize().mulS(this.moveSpeed); + } + + this.velocity = this._recoil.lengthSqrt() > 1 + ? moveVelocity.addV(this._recoil) + : moveVelocity; + super.update(dt); + + const maxX = Math.max(this.width / 2, 0); + const maxY = Math.max(this.height / 2, 0); + this.pos.x = Math.min(Math.max(this.pos.x, maxX), width - maxX); + this.pos.y = Math.min(Math.max(this.pos.y, maxY), height - maxY); + + let aim = this.input.mouse.subV(this.pos); + if (this.autoAim) { + const nearest = this._nearestZombie(); + if (nearest) { + const toEnemy = nearest.pos.subV(this.pos); + if (toEnemy.lengthSqrt() > 1) { + aim = toEnemy; + } + } + } + if (aim.lengthSqrt() > 1) { + this._aimDirection = aim.divS(aim.length()); + } + + this._recoil = this._recoil.mulS(Math.max(0, 1 - dt * this.recoilDamping)); + this._damageCooldown = Math.max(0, this._damageCooldown - dt); + + this._cooldown -= dt; + if (this._cooldown <= 0) { + this._cooldown = this.fireRate; + this.shoot(); + } + } + + _nearestZombie() { + let best = null; + let bestDistSq = Infinity; + for (const obj of this.container || []) { + if (!(obj instanceof Zombie)) continue; + const delta = obj.pos.subV(this.pos); + const distSq = delta.lengthSqrt(); + if (distSq < bestDistSq) { + bestDistSq = distSq; + best = obj; + } + } + return best; + } + + shoot() { + const aimAngle = Math.atan2(this._aimDirection.y, this._aimDirection.x); + this._recoil = this._aimDirection.mulS(-this.recoilSpeed); + this.sounds?.shoot(); + this.particles?.burst(this.pos.x, this.pos.y, { + count: 6, + speed: 120, + direction: aimAngle, + spread: 0.7, + life: 0.25, + size: 4, + color: [1, 0.85, 0.3, 1], + gravity: 0, + }); + this.particles?.burst(this.pos.x, this.pos.y, { + count: 4, + speed: 260, + direction: aimAngle, + spread: 0.25, + life: 0.14, + size: 5, + color: [1, 1, 1, 1], + gravity: 0, + }); + this.particles?.burst(this.pos.x, this.pos.y, { + count: 5, + speed: 70, + spread: Math.PI * 2, + life: 0.4, + size: 4, + color: [0.6, 0.6, 0.6, 1], + gravity: 0, + }); + + const offset = ((this.bulletCount - 1) / 2) * this.bulletSpread; + for (let i = 0; i < this.bulletCount; i += 1) { + const angle = aimAngle - offset + i * this.bulletSpread; + const direction = new Vector2D(Math.cos(angle), Math.sin(angle)); + const bullet = new Bullet(this.pos.x, this.pos.y, direction, { + container: this.container, + viewport: this.viewport, + speed: this.bulletSpeed, + size: this.bulletSize, + damage: this.damage, + }); + this.container?.push(bullet); + } + } + + applyUpgrade(type) { + if (!(type in this.upgrades)) return false; + if (this.upgrades[type] >= this.maxUpgradeLevel) return false; + this.upgrades[type] += 1; + switch (type) { + case 'rapid': + this.fireRate = Math.max(0.06, this.fireRate * 0.8); + break; + case 'damage': + this.damage += 1; + break; + case 'multishot': + this.bulletCount = Math.min(7, this.bulletCount + 1); + break; + case 'speed': + this.bulletSpeed = Math.min(1800, this.bulletSpeed * 1.15); + break; + default: + break; + } + return true; + } + + takeDamage() { + if (this._damageCooldown > 0) return this.health; + this._damageCooldown = 0.5; + this.health -= 1; + this.sounds?.damage(); + return this.health; + } + + draw(renderer) { + const s = this.size; + this.width = s; + this.height = s; + let alpha = 1; + if (this._damageCooldown > 0) { + alpha = Math.sin(this._time * 30) > 0 ? 1 : 0.25; + } + + const moving = this.velocity.lengthSqrt() > 10; + const facing = Math.atan2(this._aimDirection.y, this._aimDirection.x); + const dirX = Math.cos(facing); + const dirY = Math.sin(facing); + const perpX = -dirY; + const perpY = dirX; + + const bob = Math.sin(this._time * (moving ? 11 : 2)) * s * (moving ? 0.04 : 0.015); + const lean = facing * 0.35; + const pull = Math.min(1, this._recoil.lengthSqrt() / Math.max(1, this.recoilSpeed)); + const kickX = -this._recoil.x * 0.04; + const kickY = -this._recoil.y * 0.04; + const ox = this.pos.x + kickX; + const oy = this.pos.y + kickY + bob; + + const armor = [0.17, 0.21, 0.3, alpha]; + const armorLight = [0.3, 0.36, 0.48, alpha]; + const armorDark = [0.09, 0.11, 0.16, alpha]; + const accent = [0.34, 0.96, 1, alpha]; + + const drawLimb = (ax, ay, bx, by, thickness, limbColor) => { + const len = Math.hypot(bx - ax, by - ay); + if (len < 1) return; + renderer.drawTexture( + renderer.whiteTexture, + (ax + bx) / 2 - len / 2, + (ay + by) / 2 - thickness / 2, + len, + thickness, + limbColor, + Math.atan2(by - ay, bx - ax), + ); + }; + + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s * 0.85, + this.pos.y - s * 0.3 + s * 0.14, + s * 1.7, + s * 0.56, + [0, 0, 0, 0.35], + ); + + const capeX = ox - dirX * s * (0.42 + 0.05 * Math.sin(this._time * (moving ? 6 : 2))); + const capeY = oy - dirY * s * (0.42 + 0.05 * Math.sin(this._time * (moving ? 6 : 2))) + s * 0.05; + renderer.drawTexture( + renderer.whiteTexture, + capeX - s * 0.26, + capeY - s * 0.26, + s * 0.52, + s * 0.52, + armorDark, + facing, + ); + + const gripX = ox + dirX * s * (0.22 - 0.14 * pull); + const gripY = oy + dirY * s * (0.22 - 0.14 * pull); + drawLimb(ox - dirX * s * 0.08, oy, gripX - dirX * s * 0.14, gripY, s * 0.11, armorDark); + + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const step = Math.sin(this._time * 13 + i * Math.PI) * s * (moving ? 0.1 : 0.02); + const hx = ox + perpX * s * 0.14 * sign; + const hy = oy + perpY * s * 0.14 * sign; + renderer.drawTexture( + renderer.whiteTexture, + hx + dirX * step - s * 0.07, + hy + s * 0.14 - s * 0.09, + s * 0.14, + s * 0.18, + i === 0 ? armorDark : armor, + ); + } + + renderer.drawTexture( + renderer.whiteTexture, + ox - s * 0.25, + oy - s * 0.31, + s * 0.5, + s * 0.62, + armor, + lean * 0.4, + ); + renderer.drawTexture( + renderer.whiteTexture, + ox + dirX * s * 0.03 - s * 0.18, + oy - s * 0.25, + s * 0.36, + s * 0.4, + armorLight, + lean * 0.4, + ); + renderer.drawTexture( + renderer.whiteTexture, + ox - s * 0.03, + oy - s * 0.08, + s * 0.06, + s * 0.06, + accent, + ); + renderer.drawTexture( + renderer.whiteTexture, + ox - s * 0.25, + oy + s * 0.1 - s * 0.03, + s * 0.5, + s * 0.06, + armorDark, + lean * 0.4, + ); + + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + renderer.drawTexture( + renderer.whiteTexture, + ox + perpX * s * 0.3 * sign - s * 0.11, + oy + perpY * s * 0.3 * sign - s * 0.2 - s * 0.11, + s * 0.22, + s * 0.22, + armorDark, + ); + } + + const hx = ox + dirX * s * 0.06; + const hy = oy - s * 0.44; + renderer.drawTexture( + renderer.whiteTexture, + hx - s * 0.18, + hy - s * 0.17, + s * 0.36, + s * 0.34, + armor, + lean * 0.4, + ); + + const visorLen = s * 0.26; + const visorX = hx + dirX * s * 0.09 - visorLen / 2; + const visorY = hy + dirY * s * 0.09 - s * 0.045; + renderer.drawTexture( + renderer.whiteTexture, + visorX - s * 0.04, + visorY - s * 0.04, + visorLen + s * 0.08, + s * 0.17, + [accent[0], accent[1], accent[2], 0.22 * alpha], + facing, + ); + renderer.drawTexture( + renderer.whiteTexture, + visorX, + visorY, + visorLen, + s * 0.09, + accent, + facing, + ); + + const reach = s * (0.5 - 0.18 * pull); + const gunX = ox + dirX * (s * 0.18 + reach * 0.4); + const gunY = oy + dirY * (s * 0.18 + reach * 0.4); + const muzzleX = gunX + dirX * (reach * 0.6 + s * 0.4); + const muzzleY = gunY + dirY * (reach * 0.6 + s * 0.4); + drawLimb(gunX, gunY, muzzleX, muzzleY, s * 0.12, [0.06, 0.08, 0.12, alpha]); + renderer.drawTexture( + renderer.whiteTexture, + muzzleX - s * 0.04, + muzzleY - s * 0.04, + s * 0.08, + s * 0.08, + accent, + ); + renderer.drawTexture( + renderer.whiteTexture, + gunX - dirX * s * 0.08 - s * 0.09, + gunY - dirY * s * 0.08 - s * 0.09, + s * 0.18, + s * 0.18, + armorDark, + facing, + ); + } +} \ No newline at end of file diff --git a/resources/js/game/Witch.js b/resources/js/game/Witch.js new file mode 100644 index 0000000..fec1413 --- /dev/null +++ b/resources/js/game/Witch.js @@ -0,0 +1,265 @@ +import { Zombie } from './Zombie.js'; +import { GameObject } from '../engine/GameObject.js'; +import { Vector2D } from '../engine/Vector2D.js'; +import { WitchProjectile } from './WitchProjectile.js'; + +export class Witch extends Zombie { + constructor( + x, + y, + target, + { + container, + viewport, + speed = 85, + size = 28, + color = [0.55, 0.2, 0.8, 1], + health = 5, + knockback = 180, + scoreValue = 4, + preferredDistance = 220, + fireInterval = 2.2, + projectileSpeed = 220, + projectileCount = 1, + projectileSpread = 0.08, + projectileColor = [0.7, 0.2, 1, 1], + emergeTime = 0, + spawnProjectile = (p) => {}, + onDeath = null, + } = {}, + ) { + super(x, y, target, { + container, + viewport, + speed, + size, + color, + health, + knockback, + scoreValue, + emergeTime, + onDeath, + }); + this.preferredDistance = preferredDistance; + this.fireInterval = fireInterval; + this.projectileSpeed = projectileSpeed; + this.projectileCount = projectileCount; + this.projectileSpread = projectileSpread; + this.projectileColor = projectileColor; + this.spawnProjectile = spawnProjectile; + this._fireTimer = 0; + this._time = 0; + this._cast = 0; + } + + update(dt) { + this._time += dt; + this._cast = Math.max(0, this._cast - dt); + + if (this._updateEmerge(dt)) { + return; + } + + this._fireTimer -= dt; + + const delta = this.target.pos.subV(this.pos); + const distance = delta.length(); + + let move = new Vector2D(0, 0); + if (distance > 1) { + const dir = delta.divS(distance); + if (distance < this.preferredDistance) { + move = dir.mulS(-this.speed); + } else if (distance > this.preferredDistance + 40) { + move = dir.mulS(this.speed); + } + } + + this.velocity = move.addV(this._hitKnockback); + GameObject.prototype.update.call(this, dt); + + this._hitKnockback = this._hitKnockback.mulS(Math.max(0, 1 - dt * this.knockbackDamping)); + this._flash = Math.max(0, this._flash - dt); + + if (this._fireTimer <= 0 && this.target.health > 0 && distance < 480) { + this._fireTimer = this.fireInterval * (0.8 + Math.random() * 0.4); + this.fire(); + } + } + + _onEmergenceComplete() { + this._fireTimer = this.fireInterval * (0.4 + Math.random() * 0.4); + } + + fire() { + this._cast = 0.22; + const aim = this.target.pos.subV(this.pos); + const aimAngle = aim.lengthSqrt() > 1 + ? Math.atan2(aim.y, aim.x) + : this._time; + + const offset = ((this.projectileCount - 1) / 2) * this.projectileSpread; + for (let i = 0; i < this.projectileCount; i += 1) { + const angle = aimAngle - offset + i * this.projectileSpread; + const direction = new Vector2D(Math.cos(angle), Math.sin(angle)); + const projectile = new WitchProjectile(this.pos.x, this.pos.y, direction, { + container: this.container, + viewport: this.viewport, + speed: this.projectileSpeed, + color: this.projectileColor, + }); + this.spawnProjectile(projectile); + } + } + + draw(renderer) { + this._drawEmerge(renderer); + let s = this.size * this._emergeSize(); + + let bodyColor = this.baseColor; + if (this._flash > 0) { + bodyColor = [1, 1, 1, 1]; + } else if (this.maxHealth > 1) { + const hpRatio = this.health / this.maxHealth; + bodyColor = [ + this.baseColor[0] + (1 - this.baseColor[0]) * (1 - hpRatio), + this.baseColor[1] * hpRatio, + this.baseColor[2] * hpRatio, + 1, + ]; + } + + const bob = Math.sin(this._time * 3.4) * s * 0.09; + const sway = Math.sin(this._time * 1.7) * 0.09; + const castRatio = Math.min(1, this._cast / 0.22); + const charge = Math.max( + 0, + Math.min(1, (this.fireInterval - this._fireTimer) / this.fireInterval), + ); + const targetSide = this.target.pos.x < this.pos.x ? -1 : 1; + const flightSide = Math.abs(this.velocity.x) > 20 + ? (this.velocity.x < 0 ? -1 : 1) + : targetSide; + const facing = flightSide; + const lean = facing * s * 0.05; + + const drawLimb = (ax, ay, bx, by, thickness, limbColor) => { + const len = Math.hypot(bx - ax, by - ay); + if (len < 1) return; + renderer.drawTexture( + renderer.whiteTexture, + (ax + bx) / 2 - len / 2, + (ay + by) / 2 - thickness / 2, + len, + thickness, + limbColor, + Math.atan2(by - ay, bx - ax), + ); + }; + + const robe = s * 0.9; + const robeW = robe * (1 + Math.sin(this._time * 3.4) * 0.05); + const cy = this.pos.y + bob; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x + lean - robeW / 2, + cy - robe / 2, + robeW, + robe, + bodyColor, + ); + + const armColor = [bodyColor[0] * 0.72, bodyColor[1] * 0.72, bodyColor[2] * 0.72, 1]; + const armW = s * 0.16; + const armLen = s * (0.3 + castRatio * 0.26); + const armSpread = s * (0.36 + castRatio * 0.14); + const armY = cy - robe * 0.2 - castRatio * s * 0.14; + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const raise = Math.sin(this._time * 6) * castRatio * s * 0.06; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x + sign * armSpread - armW / 2, + armY - armLen / 2 + sign * raise, + armW, + armLen, + armColor, + ); + } + + const hatW = s * 0.7; + const hatH = s * 0.9; + const hatCx = this.pos.x + lean + sway * s * 0.14; + const hatCy = cy - robe / 2 - hatH * 0.55 + bob * 0.4; + const hatAngle = sway * 0.8 + castRatio * -0.08; + renderer.drawTexture( + renderer.whiteTexture, + hatCx - hatW / 2, + hatCy - hatH / 2, + hatW, + hatH, + [0.15, 0.08, 0.2, 1], + hatAngle, + ); + + const brimW = s * 0.98; + const brimH = s * 0.14; + renderer.drawTexture( + renderer.whiteTexture, + hatCx - brimW / 2, + hatCy + hatH * 0.1 - brimH / 2, + brimW, + brimH, + [0.15, 0.08, 0.2, 1], + hatAngle, + ); + + const seatY = cy + robe * 0.14 + Math.sin(this._time * 2.3) * s * 0.02; + const seatX = this.pos.x + lean; + const handleX = seatX + facing * s * 0.5; + const bristleX = seatX - facing * s * 0.24; + drawLimb(handleX, seatY, bristleX, seatY, s * 0.13, [0.4, 0.3, 0.15, 1]); + drawLimb(bristleX, seatY - s * 0.02, bristleX - facing * s * 0.14, seatY - s * 0.09, s * 0.18, [0.62, 0.48, 0.24, 1]); + + if (charge > 0.25) { + const eyeFade = (charge - 0.25) / 0.75; + const eyeY = hatCy + hatH * 0.32; + const eyeSpread = s * 0.13; + const eyeS = s * 0.11; + const eyeCx = this.pos.x + lean + targetSide * s * 0.06; + renderer.setBlendMode('additive'); + renderer.drawTexture( + renderer.glowTexture, + eyeCx - eyeSpread - eyeS, + eyeY - eyeS, + eyeS * 2, + eyeS * 2, + [0.85, 0.35, 1, 0.75 * eyeFade], + ); + renderer.drawTexture( + renderer.glowTexture, + eyeCx + eyeSpread - eyeS, + eyeY - eyeS, + eyeS * 2, + eyeS * 2, + [0.85, 0.35, 1, 0.75 * eyeFade], + ); + renderer.setBlendMode('normal'); + } + + if (castRatio > 0) { + renderer.setBlendMode('additive'); + const fx = this.pos.x; + const fy = hatCy - hatH * 0.72; + renderer.drawTexture( + renderer.glowTexture, + fx - s * 0.5, + fy - s * 0.5, + s, + s, + [0.8, 0.4, 1, 0.9 * castRatio], + ); + renderer.setBlendMode('normal'); + } + } +} diff --git a/resources/js/game/WitchProjectile.js b/resources/js/game/WitchProjectile.js new file mode 100644 index 0000000..06db6ce --- /dev/null +++ b/resources/js/game/WitchProjectile.js @@ -0,0 +1,49 @@ +import { GameObject } from '../engine/GameObject.js'; +import { Vector2D } from '../engine/Vector2D.js'; + +export class WitchProjectile extends GameObject { + constructor( + x, + y, + direction, + { container, viewport, speed = 220, size = 12, color = [0.7, 0.2, 1, 1] } = {}, + ) { + super(x, y); + this.velocity = direction.mulS(speed); + this.container = container; + this.viewport = viewport; + this.size = size; + this.color = color; + this.width = size; + this.height = size; + this._time = 0; + } + + update(dt) { + super.update(dt); + this._time += dt; + + const { width, height } = this.viewport; + if ( + this.pos.x < -this.size || + this.pos.x > width + this.size || + this.pos.y < -this.size || + this.pos.y > height + this.size + ) { + this.remove(this.container); + } + } + + draw(renderer) { + const pulse = 1 + 0.15 * Math.sin(this._time * 12); + const s = this.size * pulse; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s / 2, + this.pos.y - s / 2, + s, + s, + this.color, + ); + } +} diff --git a/resources/js/game/Zombie.js b/resources/js/game/Zombie.js new file mode 100644 index 0000000..3c29d85 --- /dev/null +++ b/resources/js/game/Zombie.js @@ -0,0 +1,377 @@ +import { GameObject } from '../engine/GameObject.js'; +import { Vector2D } from '../engine/Vector2D.js'; + +export class Zombie extends GameObject { + constructor( + x, + y, + target, + { + container, + viewport, + speed = 110, + size = 24, + color = [0.15, 0.65, 0.2, 1], + health = 1, + knockback = 260, + knockbackDamping = 10, + scoreValue = 1, + emergeTime = 0, + onDeath = null, + } = {}, + ) { + super(x, y); + this.target = target; + this.container = container; + this.viewport = viewport; + this.speed = speed; + this.size = size; + this.baseColor = [...color]; + this.health = health; + this.maxHealth = health; + this.knockback = knockback; + this.knockbackDamping = knockbackDamping; + this.scoreValue = scoreValue; + this.onDeath = onDeath; + this._hitKnockback = new Vector2D(0, 0); + this._flash = 0; + this._time = 0; + this._broken = Math.random() * Math.PI * 2; + this._emergeTotal = emergeTime; + this._emerge = emergeTime; + this.width = size; + this.height = size; + } + + die() { + if (this.onDeath) { + this.onDeath(this); + } + this.remove(this.container); + } + + hit(impulse, damage = 1) { + this.health -= damage; + this._hitKnockback = impulse.mulS(this.knockback); + this._flash = 0.08; + return this.health <= 0; + } + + update(dt) { + this._time += dt; + if (this._updateEmerge(dt)) return; + const delta = this.target.pos.subV(this.pos); + const distance = delta.length(); + let chaseVelocity = new Vector2D(0, 0); + if (distance > 1) { + chaseVelocity = delta.divS(distance).mulS(this.speed); + } + + this.velocity = chaseVelocity.addV(this._hitKnockback); + super.update(dt); + + this._hitKnockback = this._hitKnockback.mulS(Math.max(0, 1 - dt * this.knockbackDamping)); + this._flash = Math.max(0, this._flash - dt); + } + + _updateEmerge(dt) { + if (this._emerge <= 0) return false; + this._emerge -= dt; + if (this._emerge <= 0) { + this._emerge = 0; + if (this._onEmergenceComplete) this._onEmergenceComplete(); + } + return true; + } + + _emergeSize() { + if (this._emerge > 0 && this._emergeTotal > 0) { + return 0.3 + 0.7 * (1 - this._emerge / this._emergeTotal); + } + return 1; + } + + _drawEmerge(renderer) { + if (this._emerge <= 0) return; + const gear = 1 - this._emerge / this._emergeTotal; + renderer.setBlendMode('additive'); + const g = this.size * (3.4 - gear * 1.4); + renderer.drawTexture( + renderer.glowTexture, + this.pos.x - g / 2, + this.pos.y - g / 2, + g, + g, + [this.baseColor[0], this.baseColor[1], this.baseColor[2], 0.5 * (1 - gear)], + ); + renderer.setBlendMode('normal'); + } + + draw(renderer) { + this.width = this.size; + this.height = this.size; + + let color = this.baseColor; + if (this._flash > 0) { + color = [1, 1, 1, 1]; + } else if (this.maxHealth > 1) { + const hpRatio = this.health / this.maxHealth; + color = [ + this.baseColor[0] + (1 - this.baseColor[0]) * (1 - hpRatio), + this.baseColor[1] * hpRatio, + this.baseColor[2] * hpRatio, + 1, + ]; + } + + if (this.maxHealth > 1) { + this._drawJuggernaut(renderer, color); + return; + } + + this._drawEmerge(renderer); + const s = this.size * this._emergeSize(); + const lean = s <= 16; + const facing = Math.atan2( + this.target.pos.y - this.pos.y, + this.target.pos.x - this.pos.x, + ); + const cosF = Math.cos(facing); + const sinF = Math.sin(facing); + const walk = Math.sin(this._time * (lean ? 14 : 10) + this._broken); + + const shade = [color[0] * 0.6, color[1] * 0.6, color[2] * 0.6, 1]; + const shadeDark = [color[0] * 0.38, color[1] * 0.38, color[2] * 0.38, 1]; + const light = [ + Math.min(1, color[0] + 0.25), + Math.min(1, color[1] + 0.25), + Math.min(1, color[2] + 0.25), + 1, + ]; + + const drawLimb = (ax, ay, bx, by, thickness, limbColor) => { + const len = Math.hypot(bx - ax, by - ay); + if (len < 1) return; + renderer.drawTexture( + renderer.whiteTexture, + (ax + bx) / 2 - len / 2, + (ay + by) / 2 - thickness / 2, + len, + thickness, + limbColor, + Math.atan2(by - ay, bx - ax), + ); + }; + + if (!lean) { + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s * 0.62, + this.pos.y - s * 0.5 + s * 0.06, + s * 1.24, + s * 0.92, + [0, 0, 0, 0.35], + ); + } + + const legLen = s * (lean ? 0.32 : 0.4); + const legSpread = s * 0.22; + const hipX = this.pos.x - cosF * s * 0.18; + const hipY = this.pos.y - sinF * s * 0.18; + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const step = sign * walk * s * (lean ? 0.3 : 0.18); + const footX = hipX + cosF * legLen - sinF * legSpread * sign + cosF * step; + const footY = hipY + sinF * legLen + cosF * legSpread * sign + sinF * step; + drawLimb(hipX, hipY, footX, footY, s * 0.15, shadeDark); + } + + const torsoW = s * (lean ? 0.5 : 0.62); + const torsoH = s * (lean ? 0.46 : 0.55); + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - torsoW / 2, + this.pos.y - torsoH / 2, + torsoW, + torsoH, + color, + ); + + const armLen = s * 0.5; + const armSpread = s * 0.27; + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const shX = this.pos.x - sinF * armSpread * sign; + const shY = this.pos.y + cosF * armSpread * sign - s * 0.02; + const reach = 1 + walk * 0.1; + const handX = shX + cosF * armLen * reach; + const handY = shY + sinF * armLen * reach; + drawLimb(shX, shY, handX, handY, s * 0.17, shade); + } + + const headSize = s * (lean ? 0.4 : 0.44); + const headX = this.pos.x; + const headY = this.pos.y - s * (lean ? 0.34 : 0.4) + walk * s * 0.03; + renderer.drawTexture( + renderer.whiteTexture, + headX - headSize / 2, + headY - headSize / 2, + headSize, + headSize, + light, + ); + + const eyeSize = headSize * 0.2; + const eyeSpread = headSize * 0.24; + const look = lean ? 0.1 : 0.04; + const eyeY = headY + sinF * headSize * look; + renderer.drawTexture( + renderer.whiteTexture, + headX - eyeSize / 2 - sinF * eyeSpread, + eyeY - eyeSize / 2 + cosF * eyeSpread, + eyeSize, + eyeSize, + [0.05, 0.05, 0.05, 1], + ); + renderer.drawTexture( + renderer.whiteTexture, + headX - eyeSize / 2 + sinF * eyeSpread, + eyeY - eyeSize / 2 - cosF * eyeSpread, + eyeSize, + eyeSize, + [0.05, 0.05, 0.05, 1], + ); + } + + _drawJuggernaut(renderer, color) { + const s = this.size; + const facing = Math.atan2( + this.target.pos.y - this.pos.y, + this.target.pos.x - this.pos.x, + ); + const cosF = Math.cos(facing); + const sinF = Math.sin(facing); + const walk = Math.sin(this._time * 5 + this._broken); + + const shade = [color[0] * 0.6, color[1] * 0.6, color[2] * 0.6, 1]; + const shadeDark = [color[0] * 0.38, color[1] * 0.38, color[2] * 0.38, 1]; + const light = [ + Math.min(1, color[0] + 0.25), + Math.min(1, color[1] + 0.25), + Math.min(1, color[2] + 0.25), + 1, + ]; + const mix = (a, b, t) => a + (b - a) * t; + + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s * 0.78, + this.pos.y - s * 0.55 + s * 0.08, + s * 1.56, + s * 1.14, + [0, 0, 0, 0.35], + ); + + const footSize = s * 0.16; + const footBaseY = this.pos.y + s * 0.46; + for (let i = 0; i < 2; i += 1) { + const sign = i === 0 ? 1 : -1; + const stepX = -sinF * s * 0.2 * sign + cosF * walk * s * 0.06 * sign; + const stepY = cosF * s * 0.2 * sign + sinF * walk * s * 0.06 * sign; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x + stepX - footSize / 2, + footBaseY + stepY - footSize / 2, + footSize, + footSize, + shadeDark, + ); + } + + const shellW = s * 1.0; + const shellH = s * 0.95; + const bottomY = this.pos.y + s * 0.4; + const topY = this.pos.y - s * 0.5; + const layers = 6; + for (let i = 0; i < layers; i += 1) { + const u = i / (layers - 1); + const w = shellW * (1 - u * 0.66); + const h = (shellH / layers) * 1.4; + const cy = bottomY - u * (bottomY - topY) + walk * s * 0.02; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - w / 2, + cy - h / 2, + w, + h, + [ + mix(shadeDark[0], light[0], u), + mix(shadeDark[1], light[1], u), + mix(shadeDark[2], light[2], u), + 1, + ], + ); + } + + for (let i = 0; i < 3; i += 1) { + const rx = this.pos.x + (i - 1) * s * 0.16; + renderer.drawTexture( + renderer.whiteTexture, + rx - s * 0.045, + topY + s * 0.04, + s * 0.09, + shellH * 0.9, + shadeDark, + ); + } + + const rivet = s * 0.055; + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x - s * 0.44, + bottomY - rivet * 0.4, + rivet, + rivet, + shadeDark, + ); + renderer.drawTexture( + renderer.whiteTexture, + this.pos.x + s * 0.44 - rivet, + bottomY - rivet * 0.4, + rivet, + rivet, + shadeDark, + ); + + const headSize = s * 0.24; + const headX = this.pos.x + cosF * s * 0.52; + const headY = this.pos.y + sinF * s * 0.52; + renderer.drawTexture( + renderer.whiteTexture, + headX - headSize / 2, + headY - headSize / 2, + headSize, + headSize, + light, + ); + + const eyeSize = headSize * 0.22; + const eyeSpread = headSize * 0.3; + renderer.drawTexture( + renderer.whiteTexture, + headX - eyeSize / 2 - sinF * eyeSpread, + headY - eyeSize / 2 + cosF * eyeSpread, + eyeSize, + eyeSize, + [0.05, 0.05, 0.05, 1], + ); + renderer.drawTexture( + renderer.whiteTexture, + headX - eyeSize / 2 + sinF * eyeSpread, + headY - eyeSize / 2 - cosF * eyeSpread, + eyeSize, + eyeSize, + [0.05, 0.05, 0.05, 1], + ); + } +} \ No newline at end of file diff --git a/resources/js/main.js b/resources/js/main.js new file mode 100644 index 0000000..278b2d6 --- /dev/null +++ b/resources/js/main.js @@ -0,0 +1,1103 @@ +import { Renderer } from './engine/Renderer.js'; +import { TextRenderer } from './engine/TextRenderer.js'; +import { Input } from './engine/Input.js'; +import { ParticleSystem } from './engine/ParticleSystem.js'; +import { Effects } from './engine/Effects.js'; +import { SoundSystem } from './engine/SoundSystem.js'; +import { PICKUP_TYPES } from './game/Pickup.js'; +import { NetworkClient } from './net/NetworkClient.js'; +import { NetEntities } from './net/NetEntities.js'; + +const WORLD_W = 1920; +const WORLD_H = 1080; +const INTERP_DELAY = 60; + +const canvas = document.getElementById('game'); +const renderer = new Renderer(canvas); +const textRenderer = new TextRenderer(renderer.gl); + +const envScheme = String(import.meta.env.VITE_REVERB_SCHEME || '').toLowerCase(); +const wsScheme = ['wss', 'https'].includes(envScheme) ? 'wss' : (window.location.protocol === 'https:' ? 'wss' : 'ws'); +const wsPort = import.meta.env.VITE_REVERB_PORT || (window.location.port || (wsScheme === 'wss' ? '443' : '80')); + +const networking = new NetworkClient({ + scheme: wsScheme, + host: import.meta.env.VITE_REVERB_HOST || window.location.hostname, + port: wsPort, + key: import.meta.env.VITE_REVERB_KEY || 'game-local-key', +}); +const entities = new NetEntities(); + +const viewport = { width: 0, height: 0 }; + +const resizeToWindow = () => { + viewport.width = window.innerWidth; + viewport.height = window.innerHeight; + + const scale = window.devicePixelRatio || 1; + const deviceWidth = Math.round(viewport.width * scale); + const deviceHeight = Math.round(viewport.height * scale); + + renderer.resize(deviceWidth, deviceHeight, viewport.width, viewport.height); +}; + +window.addEventListener('resize', resizeToWindow); +window.addEventListener('orientationchange', resizeToWindow); +resizeToWindow(); + +const input = new Input(canvas, viewport); + +const fullscreenButton = document.getElementById('fullscreen'); +const updateFullscreenLabel = () => { + fullscreenButton.textContent = document.fullscreenElement ? 'Exit Fullscreen' : 'Fullscreen'; +}; +const toggleFullscreen = () => { + if (document.fullscreenElement) { + document.exitFullscreen().catch(() => {}); + } else { + document.documentElement.requestFullscreen().catch(() => {}); + } +}; +fullscreenButton.addEventListener('click', toggleFullscreen); +document.addEventListener('keydown', (event) => { + if (event.key === 'f' || event.key === 'F') { + toggleFullscreen(); + } +}); +document.addEventListener('fullscreenchange', updateFullscreenLabel); + +const particles = new ParticleSystem(); +const effects = new Effects(); +const sounds = new SoundSystem(); + +let me = { + id: localStorage.getItem('zombies.id') || randId(), + name: localStorage.getItem('zombies.name') || 'Player', + room: localStorage.getItem('zombies.room') || randId(), +}; +localStorage.setItem('zombies.id', me.id); + +const camera = { x: 0, y: 0 }; + +const updateCamera = (cx, cy) => { + const targetX = cx - viewport.width / 2; + const targetY = cy - viewport.height / 2; + camera.x = Math.min(Math.max(0, targetX), Math.max(0, WORLD_W - viewport.width)); + camera.y = Math.min(Math.max(0, targetY), Math.max(0, WORLD_H - viewport.height)); +}; + +let autoAim = false; +let muted = false; +let autoAimNotice = 0; +let muteNotice = 0; +let gameStarted = false; +let lastEventSeq = -1; +let lastMuzzle = 0; + +let shakeIntensity = 0; +let flashAlpha = 0; +let flashColor = [1, 1, 1, 1]; +let damageFlash = 0; + +const bloodPools = []; +const addBloodPool = (x, y, size = 1) => { + if (bloodPools.length >= 45) bloodPools.shift(); + const radius = (12 + Math.random() * 10) * Math.max(0.6, size / 24); + const blobs = []; + for (let i = 0; i < 7; i += 1) { + const angle = Math.random() * Math.PI * 2; + const dist = Math.random() * 0.55; + blobs.push({ + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + size: 0.35 + Math.random() * 0.45, + alpha: 0.7 + Math.random() * 0.3, + }); + } + const droplets = []; + for (let i = 0; i < 5; i += 1) { + const angle = Math.random() * Math.PI * 2; + const dist = 0.45 + Math.random() * 0.6; + droplets.push({ + x: Math.cos(angle) * dist, + y: Math.sin(angle) * dist, + size: 0.12 + Math.random() * 0.16, + }); + } + bloodPools.push({ + x, + y, + r: radius, + alpha: 0.5 + Math.random() * 0.15, + blobs, + droplets, + life: 0, + maxLife: 8 + Math.random() * 4, + }); +}; + +const drawBloodPools = (dt) => { + for (let i = bloodPools.length - 1; i >= 0; i -= 1) { + const pool = bloodPools[i]; + pool.life += dt; + if (pool.life >= pool.maxLife) { + bloodPools.splice(i, 1); + continue; + } + let alpha = pool.alpha; + if (pool.life > 1.2) { + alpha = pool.alpha * Math.max(0, 1 - (pool.life - 1.2) / (pool.maxLife - 1.2)); + } + const r = pool.r * Math.min(1, 0.4 + pool.life * 5); + + for (const blob of pool.blobs) { + const w = r * blob.size; + renderer.drawTexture( + renderer.whiteTexture, + pool.x + blob.x * r * 2 - w / 2, + pool.y + blob.y * r * 2 - w / 2, + w, + w, + [0.35, 0.045, 0.045, alpha * blob.alpha], + ); + } + + const coreW = r * 0.7; + renderer.drawTexture(renderer.whiteTexture, pool.x - coreW / 2, pool.y - coreW / 2, coreW, coreW, [0.14, 0.02, 0.02, alpha * 0.85]); + + for (const drop of pool.droplets) { + const w = r * drop.size; + renderer.drawTexture( + renderer.whiteTexture, + pool.x + drop.x * r * 2 - w / 2, + pool.y + drop.y * r * 2 - w / 2, + w, + w, + [0.42, 0.055, 0.055, alpha * 0.8], + ); + } + } +}; + +const embers = []; +const initEmbers = () => { + embers.length = 0; + for (let i = 0; i < 70; i += 1) { + embers.push({ + x: Math.random() * WORLD_W, + y: Math.random() * WORLD_H, + size: 1.5 + Math.random() * 2.5, + vx: (Math.random() - 0.5) * 24, + vy: -(18 + Math.random() * 40), + flicker: Math.random() * Math.PI * 2, + }); + } +}; +const drawEmbers = (dt, t) => { + if (embers.length === 0) initEmbers(); + for (const ember of embers) { + ember.x += ember.vx * dt + Math.sin(t * 2 + ember.flicker) * 12 * dt; + ember.y += ember.vy * dt; + if (ember.y < -12) { + ember.y = WORLD_H + 12; + ember.x = Math.random() * WORLD_W; + } else if (ember.x < -12) { + ember.x = WORLD_W + 12; + } else if (ember.x > WORLD_W + 12) { + ember.x = -12; + } + const alpha = 0.3 + 0.35 * Math.abs(Math.sin(t * 3 + ember.flicker)); + renderer.drawTexture(renderer.whiteTexture, ember.x - ember.size / 2, ember.y - ember.size / 2, ember.size, ember.size, [1, 0.68, 0.25, alpha]); + } +}; + +const addShake = (amount) => { + shakeIntensity = Math.min(26, shakeIntensity + amount); +}; + +const addFlash = (color, alpha) => { + flashColor = color; + flashAlpha = Math.max(flashAlpha, alpha); +}; + +const playerDamageImpact = (x, y) => { + effects.glow(x, y, { + radius: 80, + life: 0.3, + color: [1, 0.25, 0.2, 1], + additive: true, + }); + effects.ring(x, y, { + endRadius: 90, + width: 4, + life: 0.35, + color: [1, 0.3, 0.25, 1], + additive: true, + }); +}; + +const playerDeathExplosion = (x, y) => { + effects.ring(x, y, { + endRadius: 240, + width: 10, + life: 0.9, + color: [1, 0.4, 0.2, 1], + additive: true, + }); + effects.ring(x, y, { + endRadius: 140, + width: 5, + life: 0.7, + color: [1, 1, 1, 1], + additive: true, + }); + effects.glow(x, y, { + radius: 160, + life: 0.7, + color: [1, 0.45, 0.2, 1], + additive: true, + }); + particles.burst(x, y, { + count: 40, + speed: 280, + life: 0.6, + size: 6, + color: [1, 1, 1, 1], + gravity: 200, + shape: 'glow', + additive: true, + }); + particles.burst(x, y, { + count: 30, + speed: 180, + life: 1, + size: 8, + color: [1, 0.35, 0.1, 1], + gravity: 100, + shape: 'glow', + additive: true, + }); +}; + +const hitSpark = (x, y, color) => { + particles.burst(x, y, { + count: 6, + speed: 170, + life: 0.22, + size: 3, + color: color || [1, 0.9, 0.45, 1], + gravity: 130, + shape: 'glow', + additive: true, + }); + effects.ring(x, y, { + endRadius: 24, + width: 2, + life: 0.16, + color: color || [1, 0.85, 0.4, 1], + additive: true, + }); +}; + +const emitPortal = (x, y, color, scale = 1) => { + const rgb = color.slice(0, 3); + effects.glow(x, y, { + radius: 70 * scale, + life: 0.45, + color: rgb, + additive: true, + }); + effects.glow(x, y, { + radius: 36 * scale, + life: 0.3, + color: [1, 1, 1, 1], + additive: true, + }); + effects.ring(x, y, { + endRadius: 70 * scale, + width: 3 * scale, + life: 0.4, + color: rgb, + additive: true, + }); + particles.burst(x, y, { + count: Math.round(14 * scale), + speed: 150 * scale, + life: 0.45, + size: 4 * scale, + color: rgb, + gravity: 0, + shape: 'glow', + additive: true, + }); + particles.burst(x, y, { + count: Math.round(6 * scale), + speed: 70 * scale, + life: 0.5, + size: 5 * scale, + color: [1, 1, 1, 1], + gravity: 0, + shape: 'glow', + additive: true, + }); +}; + +const floatingMessages = []; +const addMessage = (text, x, y, life = 1.5, screen = false) => { + floatingMessages.push({ text, x, y, t: 0, life, screen }); +}; + +const pickupColor = (type) => { + if (!PICKUP_TYPES[type]) return [1, 1, 1, 1]; + return PICKUP_TYPES[type].color; +}; + +const pickupName = (type) => { + if (!PICKUP_TYPES[type]) return type; + return PICKUP_TYPES[type].name; +}; + +const killCrash = (x, y, color, big) => { + addBloodPool(x, y, big ? 40 : 24); + effects.glow(x, y, { + radius: 60 + (big ? 50 : 20), + life: 0.5, + color, + additive: true, + }); + effects.ring(x, y, { + endRadius: 60 + (big ? 70 : 34), + width: big ? 4 : 3, + life: 0.5, + color, + additive: true, + }); + particles.burst(x, y, { + count: big ? 26 : 18, + speed: big ? 150 : 170, + life: 0.5, + size: big ? 6 : 5, + color, + gravity: 400, + drag: 2, + shape: 'glow', + additive: true, + }); + particles.burst(x, y, { + count: big ? 12 : 8, + speed: 60, + life: 0.35, + size: 5, + color: [1, 1, 1, 1], + gravity: 200, + shape: 'glow', + additive: true, + }); +}; + +const bossApproach = (x, y) => { + sounds.setBossMode(true); + sounds.bossEntrance(); + emitPortal(x, y, [0.45, 0.05, 0.1, 1], 3.5); + effects.ring(x, y, { + endRadius: Math.max(WORLD_W, WORLD_H) * 0.6, + width: 10, + life: 0.8, + color: [1, 0.3, 0.25, 1], + additive: true, + }); + effects.ring(x, y, { + endRadius: Math.max(WORLD_W, WORLD_H) * 0.35, + width: 4, + life: 0.6, + color: [1, 1, 1, 1], + additive: true, + }); + const arcColor = [0.9, 0.3, 0.3, 1]; + for (let i = 0; i < 5; i += 1) { + effects.arc(x, -60, Math.random() * WORLD_W, Math.random() * WORLD_H * 0.4, { + segments: 10, + width: 3, + life: 0.35, + color: arcColor, + jitter: 40, + additive: true, + }); + } + addMessage('BOSS APPROACHES', viewport.width / 2, viewport.height / 3, 3, true); +}; + +const bossDefeated = (x, y) => { + sounds.setBossMode(false); + addShake(16); + addFlash([1, 1, 1], 0.55); + addMessage('BOSS DEFEATED', viewport.width / 2, viewport.height / 3, 3, true); + effects.ring(x, y, { + endRadius: 320, + width: 12, + life: 0.8, + color: [1, 0.5, 0.2, 1], + additive: true, + }); + effects.ring(x, y, { + endRadius: 190, + width: 6, + life: 0.6, + color: [1, 1, 1, 1], + additive: true, + }); + for (let i = 0; i < 8; i += 1) { + const angle = (i / 8) * Math.PI * 2; + effects.arc(x, y, x + Math.cos(angle) * 220, y + Math.sin(angle) * 220, { + segments: 9, + width: 3, + life: 0.4, + color: [1, 0.7, 0.4, 1], + jitter: 30, + additive: true, + }); + } + particles.burst(x, y, { + count: 70, + speed: 340, + life: 0.9, + size: 8, + color: [0.45, 0.05, 0.1, 1], + gravity: 300, + }); + particles.burst(x, y, { + count: 50, + speed: 220, + life: 0.7, + size: 6, + color: [1, 0.6, 0.1, 1], + gravity: 200, + }); +}; + +const pickupFizz = (x, y, type) => { + const color = pickupColor(type).slice(0, 3); + effects.glow(x, y, { + radius: 80, + life: 0.45, + color, + additive: true, + }); + effects.ring(x, y, { + endRadius: 80, + width: 4, + life: 0.45, + color: [1, 1, 1, 1], + additive: true, + }); + particles.burst(x, y, { + count: 12, + speed: 140, + life: 0.5, + size: 5, + color, + gravity: 0, + shape: 'glow', + additive: true, + }); + particles.burst(x, y, { + count: 8, + speed: 260, + life: 0.35, + size: 3, + color: [1, 1, 1, 1], + gravity: 0, + }); +}; + +const maxFizz = (x, y) => { + particles.burst(x, y - 24, { + count: 26, + speed: 200, + spread: Math.PI * 2, + life: 0.9, + size: 5, + color: [1, 0.85, 0.25, 1], + gravity: 220, + }); + particles.burst(x, y - 24, { + count: 10, + speed: 320, + spread: Math.PI * 2, + life: 0.5, + size: 3, + color: [1, 0.6, 0.1, 1], + gravity: 60, + }); +}; + +const Lobby = (() => { + const el = { + lobby: document.getElementById('lobby'), + status: document.getElementById('lobby-status'), + players: document.getElementById('lobby-players'), + name: document.getElementById('name'), + room: document.getElementById('room'), + join: document.getElementById('lobby-join'), + confirm: document.getElementById('lobby-join-confirm'), + host: document.getElementById('lobby-host'), + joinForm: document.getElementById('join-form'), + start: document.getElementById('lobby-start'), + prejoin: document.getElementById('prejoin'), + inroom: document.getElementById('inroom'), + roomCode: document.getElementById('room-code'), + roomCopy: document.getElementById('room-copy'), + gameover: document.getElementById('gameover'), + scores: document.getElementById('gameover-scores'), + wait: document.getElementById('gameover-wait'), + again: document.getElementById('gameover-again'), + }; + + el.name.value = me.name; + el.room.value = localStorage.getItem('zombies.room') || ''; + + const setStatus = (text, error = false) => { + el.status.textContent = text; + el.status.classList.toggle('error', error); + }; + + const showPreJoin = () => { + el.prejoin.classList.remove('hidden'); + el.inroom.classList.add('hidden'); + el.players.textContent = ''; + }; + + const showInRoom = () => { + el.prejoin.classList.add('hidden'); + el.inroom.classList.remove('hidden'); + }; + + const refreshPlayers = (players) => { + if (!players || players.length === 0) { + el.players.textContent = ''; + return; + } + const my = players.find((p) => p.id === me.id) || null; + el.players.textContent = `In room: ${players.map((p) => `${p.name}${p.host ? ' (host)' : ''}${p.id === me.id ? ' (you)' : ''}`).join(', ')}`; + el.roomCode.textContent = me.room; + showInRoom(); + if (players.length === 1) { + setStatus('Room ready — share the code to let friends in.'); + } else if (my && my.host) { + setStatus('Everyone is in — press Start when ready.'); + } else { + setStatus('Waiting for the host to start...'); + } + }; + + const setStartEnabled = (enabled) => { + el.start.disabled = !enabled; + el.start.style.display = enabled ? '' : 'none'; + }; + + const show = (visible) => { + el.lobby.classList.toggle('hidden', !visible); + }; + + const hideGameOver = () => { + el.gameover.classList.remove('show'); + }; + + const showGameOver = (players) => { + const scores = [...players] + .sort((a, b) => b.score - a.score) + .map((p) => `${p.name}: ${p.score}${p.id === me.id ? ' (you)' : ''}${p.host ? ' (host)' : ''}`) + .join('\n'); + const my = players.find((p) => p.id === me.id) || null; + el.scores.textContent = scores; + if (my && my.host) { + el.again.style.display = ''; + el.wait.textContent = ''; + } else { + el.again.style.display = 'none'; + el.wait.textContent = 'Waiting for the host to play again...'; + } + el.gameover.classList.add('show'); + }; + + const joinNow = (asHost) => { + me.name = (el.name.value || 'Player').trim().slice(0, 16); + localStorage.setItem('zombies.name', me.name); + localStorage.setItem('zombies.room', me.room); + if (asHost) { + networking.create(me.room, { id: me.id, name: me.name }); + } else { + networking.join(me.room, { id: me.id, name: me.name }); + } + setStatus('Connecting...'); + }; + + const hostNow = () => { + me.room = randId(); + joinNow(true); + }; + + const joinWithCode = () => { + me.room = (el.room.value || '').trim().slice(0, 16); + if (!me.room) { + setStatus('Enter a room code to join.', true); + return; + } + joinNow(false); + }; + + el.host.addEventListener('click', hostNow); + el.join.addEventListener('click', () => { + el.joinForm.classList.remove('hidden'); + el.room.focus(); + }); + el.confirm.addEventListener('click', joinWithCode); + el.start.addEventListener('click', () => { + networking.start(); + }); + el.roomCopy.addEventListener('click', async () => { + const ok = await copyText(me.room); + setStatus(ok ? `Room code ${me.room} copied.` : 'Copy failed - select the code manually.', !ok); + }); + el.room.addEventListener('keydown', (event) => { + if (event.key === 'Enter') joinWithCode(); + }); + + networking.onStateChange = ({ type, connected }) => { + if (type === 'connection') { + setStatus(connected ? 'Connected - host a room or join one.' : 'Disconnected - reconnecting...'); + } + }; + + networking.onRoomError = ({ code } = {}) => { + const messages = { + room_not_found: 'Room code not found - check the code with the host and try again.', + room_exists: 'That code is taken by another host - use your own code.', + room_not_open: 'That game is already in progress - join a lobby room instead.', + room_full: 'That room is full - try another code.', + }; + setStatus(messages[code] || 'Could not join that room.', true); + networking.abandonRoom(); + showPreJoin(); + }; + + el.again.addEventListener('click', () => { + networking.reset(); + hideGameOver(); + }); + + el.name.focus(); + + return { setStatus, refreshPlayers, setStartEnabled, show, hostNow, joinWithCode, hideGameOver, showGameOver, showPreJoin }; +})(); + +function copyText(text) { + if (navigator.clipboard && window.isSecureContext) { + return navigator.clipboard.writeText(text).then( + () => true, + () => false, + ); + } + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.opacity = '0'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch { + ok = false; + } + document.body.removeChild(ta); + + return Promise.resolve(ok); +} + +function randId() { + const chars = 'abcdefghjkmnpqrstuvwxyz23456789'; + let out = ''; + for (let i = 0; i < 5; i += 1) { + out += chars[Math.floor(Math.random() * chars.length)]; + } + return out; +} + +const mouseWorld = () => ({ + x: camera.x + input.mouse.x, + y: camera.y + input.mouse.y, +}); + +const interpolate = (now) => { + const { prev, prevAt, curr, currAt } = networking; + if (!curr) return null; + + let alpha = 0; + if (prev && curr.seq > prev.seq) { + const span = currAt - prevAt; + if (span > 0) { + alpha = Math.min(1, Math.max(0, (now - INTERP_DELAY - prevAt) / span)); + } + } + + const prevPlayers = new Map(); + for (const p of prev?.players || []) prevPlayers.set(p.id, p); + const prevEntities = new Map(); + for (const e of prev?.entities || []) prevEntities.set(`${e.d}#${e.eid}`, e); + + const players = curr.players.map((p) => { + const o = prevPlayers.get(p.id); + if (!o) return { ...p }; + return { + ...p, + x: o.x + (p.x - o.x) * alpha, + y: o.y + (p.y - o.y) * alpha, + }; + }); + + const list = curr.entities.map((e) => { + const key = `${e.d}#${e.eid}`; + const o = prevEntities.get(key); + if (!o) return { ...e }; + return { + ...e, + x: o.x + (e.x - o.x) * alpha, + y: o.y + (e.y - o.y) * alpha, + }; + }); + + return { players, entities: list }; +}; + +const processEvents = (events) => { + for (const event of events) { + const { k: kind, x, y, d } = event; + switch (kind) { + case 'portal': + emitPortal(x, y, d.color || [1, 1, 1], d.scale || 1); + break; + case 'boss': + bossApproach(x, y); + break; + case 'kill': + killCrash(x, y, d.color || [1, 1, 1], d.big || d.boss); + sounds.death(Boolean(d.big || d.boss)); + if (d.boss) { + bossDefeated(x, y); + } + break; + case 'bullethit': + hitSpark(x, y, d.color); + sounds.hit(); + break; + case 'playerdamage': + playerDamageImpact(x, y); + sounds.damage(); + if (d.id === me.id) { + addShake(4); + damageFlash = 0.5; + } + break; + case 'playerdeath': + playerDeathExplosion(x, y); + sounds.explosion(); + if (d.id === me.id) { + addShake(20); + sounds.playGameOverMusic(); + } else { + addFlash([1, 0.4, 0.2], 0.15); + } + break; + case 'bloodpool': + addBloodPool(x, y, d.s || 24); + break; + case 'pickup': + pickupFizz(x, y, d.type); + if (d.id === me.id) { + sounds.pickup(); + addMessage(pickupName(d.type), x, y - 32); + } + break; + case 'pickupmax': + maxFizz(x, y); + if (d.id === me.id) { + addMessage(`${pickupName(d.type)} MAX`, x, y - 32, 1); + } + break; + case 'gameover': + sounds.setBossMode(false); + break; + default: + break; + } + } +}; + +const drawScreenEffects = (state, my) => { + renderer.setViewOffset( + (Math.random() - 0.5) * 2 * shakeIntensity, + (Math.random() - 0.5) * 2 * shakeIntensity, + ); + + const boss = (state.entities || []).find((entity) => entity.d === 'boss'); + if (boss && boss.hp >= 0) { + const barW = Math.min(360, viewport.width * 0.4); + const barH = 14; + const bx = viewport.width / 2 - barW / 2; + const by = 16; + renderer.drawTexture(renderer.whiteTexture, bx - 2, by - 2, barW + 4, barH + 4, [0, 0, 0, 0.6]); + renderer.drawTexture(renderer.whiteTexture, bx, by, barW, barH, [0.45, 0.08, 0.12, 0.9]); + const hpRatio = Math.max(0, boss.hp / boss.mhp); + renderer.drawTexture(renderer.whiteTexture, bx, by, barW * hpRatio, barH, [1, 0.25, 0.3, 1]); + } + + if (damageFlash > 0) { + renderer.drawTexture(renderer.whiteTexture, 0, 0, viewport.width, viewport.height, [ + 0.85, 0.05, 0.1, Math.min(0.35, damageFlash * 0.4), + ]); + } + + if (my && my.hp === 1 && !my.dead) { + const pulse = 0.1 + 0.08 * Math.sin(performance.now() * 0.006); + const l = 12; + renderer.drawTexture(renderer.whiteTexture, 0, 0, viewport.width, l, [0.85, 0.1, 0.15, pulse]); + renderer.drawTexture(renderer.whiteTexture, 0, viewport.height - l, viewport.width, l, [0.85, 0.1, 0.15, pulse]); + renderer.drawTexture(renderer.whiteTexture, 0, 0, l, viewport.height, [0.85, 0.1, 0.15, pulse]); + renderer.drawTexture(renderer.whiteTexture, viewport.width - l, 0, l, viewport.height, [0.85, 0.1, 0.15, pulse]); + } + + if (flashAlpha > 0) { + renderer.drawTexture(renderer.whiteTexture, 0, 0, viewport.width, viewport.height, [ + flashColor[0], + flashColor[1], + flashColor[2], + Math.min(0.8, flashAlpha), + ]); + } +}; + +const drawWorldMessages = (t) => { + for (let i = floatingMessages.length - 1; i >= 0; i -= 1) { + const message = floatingMessages[i]; + if (message.screen) continue; + message.t += t; + if (message.t >= message.life) { + floatingMessages.splice(i, 1); + continue; + } + textRenderer.draw(renderer, message.text, message.x - message.text.length * 5.5, message.y - message.t * 40, { + size: 20, + fillStyle: '#fff', + }); + } +}; + +const drawScreenMessages = (t) => { + for (let i = floatingMessages.length - 1; i >= 0; i -= 1) { + const message = floatingMessages[i]; + if (!message.screen) continue; + message.t += t; + if (message.t >= message.life) { + floatingMessages.splice(i, 1); + continue; + } + const entry = textRenderer.measure(message.text, { size: 32, fillStyle: '#fff' }); + textRenderer.draw(renderer, message.text, viewport.width / 2 - entry.width / 2, 200 - message.t * 20, { + size: 32, + fillStyle: '#fff', + }); + } +}; + +const drawHud = (state, my, now, t) => { + if (!my) return; + + textRenderer.draw(renderer, `Score: ${my.score}`, 12, 12, { size: 20, fillStyle: '#fff' }); + textRenderer.draw(renderer, `HP: ${my.hp}`, 12, 40, { size: 20, fillStyle: '#fff' }); + + if (autoAim) { + const aaEntry = textRenderer.measure('AUTO-AIM', { size: 14, fillStyle: '#4fe3ff' }); + textRenderer.draw(renderer, 'AUTO-AIM', viewport.width - aaEntry.width - 12, 12, { + size: 14, + fillStyle: '#4fe3ff', + }); + } + + let uy = 68; + for (const type of ['rapid', 'damage', 'multishot', 'speed']) { + const level = my.up?.[type] ?? 0; + if (level <= 0) continue; + const { name, color } = PICKUP_TYPES[type]; + renderer.drawTexture(renderer.whiteTexture, 12, uy, 12, 12, [...color.slice(0, 3), 0.5]); + textRenderer.draw(renderer, `${name} x${level}${level >= 3 ? ' (MAX)' : ''}`, 30, uy + 2, { + size: 14, + fillStyle: '#fff', + }); + uy += 20; + } + + if (muteNotice > 0) { + muteNotice -= t; + const notice = muted ? 'Sound OFF' : 'Sound ON'; + const entry = textRenderer.measure(notice, { size: 22, fillStyle: '#fff' }); + textRenderer.draw(renderer, notice, viewport.width / 2 - entry.width / 2, viewport.height / 2 - 60, { + size: 22, + fillStyle: '#fff', + }); + } + if (autoAimNotice > 0) { + autoAimNotice -= t; + const notice = autoAim ? 'Auto-aim ON' : 'Auto-aim OFF'; + const entry = textRenderer.measure(notice, { size: 22, fillStyle: '#fff' }); + textRenderer.draw(renderer, notice, viewport.width / 2 - entry.width / 2, viewport.height / 2 - 20, { + size: 22, + fillStyle: '#4fe3ff', + }); + } + + void now; +}; + +let lastTime = performance.now(); + +const gameLoop = (now) => { + requestAnimationFrame(gameLoop); + + const rawDt = Math.min((now - lastTime) / 1000, 0.1); + lastTime = now; + const t = now / 1000; + + shakeIntensity = shakeIntensity > 0.2 ? shakeIntensity * Math.max(0, 1 - rawDt * 7) : 0; + flashAlpha = Math.max(0, flashAlpha - rawDt * 2.4); + damageFlash = Math.max(0, damageFlash - rawDt * 2); + + if (input.pressed.has('m')) { + input.clearPressed(); + muted = sounds.toggleMuted(); + muteNotice = 1.2; + } + if (input.pressed.has('q')) { + input.clearPressed(); + autoAim = !autoAim; + autoAimNotice = 1.2; + } + + renderer.clear(0, 0, 0, 1); + + const snapshot = networking.curr; + const frame = snapshot ? interpolate(now) : null; + const own = frame ? frame.players.find((p) => p.id === me.id) || null : null; + if (own) { + entities.setTarget(own.x, own.y); + updateCamera(own.x, own.y); + } else { + updateCamera(WORLD_W / 2, WORLD_H / 2); + } + + if (own && input.mouseDown && !own.dead) { + if (now - lastMuzzle > 170) { + lastMuzzle = now; + const { aimX, aimY } = own; + particles.burst(own.x + aimX * 22, own.y + aimY * 22, { + count: 3, + speed: 120, + life: 0.14, + size: 4, + color: [1, 0.85, 0.3, 1], + gravity: 0, + direction: Math.atan2(aimY, aimX), + spread: 0.4, + }); + sounds.shoot(); + } + } + + renderer.setViewOffset(camera.x, camera.y); + + drawBloodPools(rawDt); + drawEmbers(rawDt, t); + + if (!snapshot) { + particles.update(rawDt); + particles.draw(renderer); + effects.update(rawDt); + effects.draw(renderer); + drawWorldMessages(rawDt); + renderer.setViewOffset(0, 0); + drawScreenEffects(snapshot || { players: [], entities: [] }, null); + return; + } + + if (snapshot.state === 'lobby') { + const my = snapshot.players.find((p) => p.id === me.id) || null; + Lobby.refreshPlayers(snapshot.players); + Lobby.setStartEnabled(Boolean(my && my.host)); + Lobby.hideGameOver(); + Lobby.show(true); + gameStarted = false; + } + + const boss = entities.draw(frame.entities, frame.players, renderer, textRenderer, t); + + particles.update(rawDt); + particles.draw(renderer); + effects.update(rawDt); + effects.draw(renderer); + + drawWorldMessages(rawDt); + + if (snapshot.seq !== lastEventSeq) { + lastEventSeq = snapshot.seq; + processEvents(snapshot.events || []); + } + + if (snapshot.state === 'playing' && !gameStarted) { + gameStarted = true; + floatingMessages.length = 0; + bloodPools.length = 0; + sounds.stingerStart(); + sounds.startMusic(); + Lobby.show(false); + } + if (snapshot.state === 'playing') { + sounds.setIntensity(Math.min(1, snapshot.elapsed / 120)); + } + + if (snapshot.state === 'over') { + sounds.setBossMode(false); + Lobby.showGameOver(snapshot.players); + } + + renderer.setViewOffset(0, 0); + + const mySnapshot = frame.players.find((p) => p.id === me.id) || frame.players[0] || null; + drawScreenMessages(rawDt); + drawScreenEffects(snapshot, mySnapshot); + drawHud(snapshot, mySnapshot, now, rawDt); + + const mouse = mouseWorld(); + if (snapshot.state === 'playing' && own && !own.dead) { + networking.sendInput({ + id: me.id, + keys: [...input.keys], + aim: { x: mouse.x - own.x, y: mouse.y - own.y }, + fire: input.mouseDown, + autoAim, + }); + } else { + networking.stopSendingInput(); + } + + void boss; +}; + +Lobby.show(true); +Lobby.showPreJoin(); +networking.connect(); + +window.addEventListener('beforeunload', () => { + networking.leave(); +}); + +requestAnimationFrame(gameLoop); \ No newline at end of file diff --git a/resources/js/net/NetEntities.js b/resources/js/net/NetEntities.js new file mode 100644 index 0000000..dfd39a8 --- /dev/null +++ b/resources/js/net/NetEntities.js @@ -0,0 +1,172 @@ +import { Zombie } from '../game/Zombie.js'; +import { MegaTank } from '../game/MegaTank.js'; +import { Leaper } from '../game/Leaper.js'; +import { Witch } from '../game/Witch.js'; +import { MegaWitch } from '../game/MegaWitch.js'; +import { Boss } from '../game/Boss.js'; +import { Bullet } from '../game/Bullet.js'; +import { WitchProjectile } from '../game/WitchProjectile.js'; +import { Pickup } from '../game/Pickup.js'; +import { Player } from '../game/Player.js'; +import { Vector2D } from '../engine/Vector2D.js'; + +const CLASSES = { + zombie: Zombie, + megatank: MegaTank, + leaper: Leaper, + witch: Witch, + megawitch: MegaWitch, + boss: Boss, + bullet: Bullet, + projectile: WitchProjectile, +}; + +const PLAYER_SIZE = 24; + +export class NetEntities { + constructor() { + this.target = { pos: new Vector2D(960, 540) }; + this.views = new Map(); + this.players = new Map(); + } + + setTarget(x, y) { + this.target.pos.x = x; + this.target.pos.y = y; + } + + _timeFor(eid, now) { + return now + (eid % 17) * 0.21 + (eid % 5) * 0.07; + } + + _key(entity) { + return `${entity.d}#${entity.eid}`; + } + + _createView(entity) { + const Ctor = CLASSES[entity.d]; + if (entity.d === 'pickup') { + return new Pickup(entity.x, entity.y, entity.t, { container: null, viewport: {}, life: 10 }); + } + if (entity.d === 'bullet' || entity.d === 'projectile') { + return new Ctor(entity.x, entity.y, new Vector2D(1, 0), {}); + } + return new Ctor(entity.x, entity.y, this.target, {}); + } + + draw(entities, players, renderer, textRenderer, now) { + const seen = new Set(); + let boss = null; + + for (const entity of entities) { + const key = this._key(entity); + seen.add(key); + let view = this.views.get(key); + if (!view) { + view = this._createView(entity); + this.views.set(key, view); + } + this._stamp(view, entity, now); + view.draw(renderer, textRenderer); + if (entity.d === 'boss') { + boss = view; + } + } + + for (const [key, view] of this.views) { + if (!seen.has(key)) { + this.views.delete(key); + } + } + + for (const snapshot of players) { + let view = this.players.get(snapshot.id); + if (!view) { + view = new Player({ + input: null, + container: null, + viewport: { width: 1920, height: 1080 }, + size: PLAYER_SIZE, + }); + this.players.set(snapshot.id, view); + } + this._stampPlayer(view, snapshot, now); + if (!snapshot.dead) { + view.draw(renderer); + } + } + + for (const [id, view] of this.players) { + if (!players.some((snapshot) => snapshot.id === id)) { + this.players.delete(id); + } + } + + return boss; + } + + clear() { + this.views.clear(); + this.players.clear(); + } + + _stamp(view, entity, now) { + view.pos.x = entity.x; + view.pos.y = entity.y; + if (entity.s !== undefined) { + view.size = entity.s; + } + if (entity.c) { + view.baseColor = [entity.c[0], entity.c[1], entity.c[2], 1]; + } + if (entity.hp !== undefined && entity.mhp !== undefined) { + view.health = entity.hp; + view.maxHealth = entity.mhp; + } + view._time = this._timeFor(entity.eid, now); + view._flash = 0; + + if (entity.et !== undefined) { + view._emergeTotal = entity.et; + view._emerge = entity.er ?? 0; + } + + switch (entity.d) { + case 'leaper': + view._dashTimer = entity.dash ? 999 : 0; + view._windupTimer = entity.wind ? 999 : 0; + if (!view._dashDirection) { + view._dashDirection = { x: 1, y: 0 }; + } + break; + case 'megawitch': + view._novaTimer = (1 - (entity.charge ?? 0)) * view.novaInterval; + break; + case 'witch': + view._cast = 0; + view._fireTimer = view.fireInterval * (1 - ((entity.eid * 0.37 + now * 0.11) % 1)); + break; + case 'boss': + view._hurt = 0; + break; + case 'pickup': + view._time = Math.max(0, 10 - entity.life); + break; + default: + break; + } + } + + _stampPlayer(view, snapshot, now) { + view.pos.x = snapshot.x; + view.pos.y = snapshot.y; + const aim = new Vector2D(snapshot.aimX, snapshot.aimY); + if (aim.lengthSqrt() > 0.01) { + view._aimDirection = aim.normalize(); + } + view._time = now; + view._damageCooldown = 0; + view.health = snapshot.hp; + view.maxHealth = snapshot.mhp; + } +} \ No newline at end of file diff --git a/resources/js/net/NetworkClient.js b/resources/js/net/NetworkClient.js new file mode 100644 index 0000000..cb09be3 --- /dev/null +++ b/resources/js/net/NetworkClient.js @@ -0,0 +1,201 @@ +import { SocketClient } from './SocketClient.js'; + +const INPUT_RATE = 30; + +export class NetworkClient { + constructor(options = {}) { + this.socket = new SocketClient(options); + this.room = null; + this.playerId = null; + this.playerName = null; + this.joined = false; + this.creator = false; + this.prev = null; + this.prevAt = 0; + this.curr = null; + this.currAt = 0; + this.onStateChange = null; + this.onRoomError = null; + this._input = null; + this._inputTimer = null; + + this.socket.onConnectionChange = (connected) => { + if (connected && this.joined && this.room) { + if (this.creator) { + this._sendCreate(); + } else { + this._sendJoin(); + } + } + this.onStateChange?.({ type: 'connection', connected }); + }; + } + + connect() { + this.socket.connect(); + } + + get connected() { + return this.socket.connected; + } + + join(room, { id, name }) { + this._enterRoom(room, { id, name }, false); + } + + create(room, { id, name }) { + this._enterRoom(room, { id, name }, true); + } + + _enterRoom(room, { id, name }, isCreator) { + const changingRoom = this.room && this.room !== room; + if (changingRoom) { + this.socket.unsubscribe(this._snapshotChannel()); + this.socket.unsubscribe(this._inputChannel()); + this._sendControl('leave'); + } + this.room = room; + this.playerId = id; + this.playerName = name; + this.joined = true; + this.creator = isCreator; + + this.prev = null; + this.prevAt = 0; + this.curr = null; + this.currAt = 0; + + this.socket.subscribe(this._snapshotChannel(), { + onEvent: (event, payload) => { + if (event === 'snapshot') { + this._ingest(payload); + } else if (event === 'room-error') { + this.onRoomError?.(payload); + } + }, + }); + this.socket.subscribe(this._inputChannel()); + if (isCreator) { + this._sendCreate(); + } else { + this._sendJoin(); + } + + if (!this._inputTimer) { + this._inputTimer = setInterval(() => this._flushInput(), 1000 / INPUT_RATE); + } + } + + leave() { + if (!this.joined) return; + this._sendControl('leave'); + this.joined = false; + this.creator = false; + this.room = null; + this.prev = null; + this.prevAt = 0; + this.curr = null; + this.currAt = 0; + + if (this._inputTimer) { + clearInterval(this._inputTimer); + this._inputTimer = null; + } + } + + abandonRoom() { + if (this.room) { + this.socket.unsubscribe(this._snapshotChannel()); + this.socket.unsubscribe(this._inputChannel()); + } + this.joined = false; + this.creator = false; + this.room = null; + this.prev = null; + this.prevAt = 0; + this.curr = null; + this.currAt = 0; + + if (this._inputTimer) { + clearInterval(this._inputTimer); + this._inputTimer = null; + } + } + + start() { + this._sendControl('start'); + } + + reset() { + this._sendControl('reset'); + } + + sendInput(frame) { + this._input = frame; + } + + stopSendingInput() { + this._input = null; + } + + _flushInput() { + if (!this._input || !this._connected()) return; + const { id, keys, aim, fire, autoAim } = this._input; + this.socket.trigger('client-input', this._inputChannel(), { + id, + keys, + aim, + fire, + autoAim, + }); + } + + _connected() { + return this.joined && this.room && this.socket.connected; + } + + _snapshotChannel() { + return `game.${this.room}`; + } + + _inputChannel() { + return `game.${this.room}.input`; + } + + _sendControl(type) { + if (!this._connected()) return; + this.socket.trigger(`client-${type}`, this._inputChannel(), { + type, + id: this.playerId, + name: this.playerName, + }); + } + + _sendJoin() { + if (!this.room || !this.playerId) return; + this.socket.trigger('client-join', this._inputChannel(), { + type: 'join', + id: this.playerId, + name: this.playerName, + }); + } + + _sendCreate() { + if (!this.room || !this.playerId) return; + this.socket.trigger('client-create', this._inputChannel(), { + type: 'create', + id: this.playerId, + name: this.playerName, + }); + } + + _ingest(payload) { + if (this.curr && payload.seq !== undefined && this.curr.seq !== undefined && payload.seq <= this.curr.seq) { + return; + } + this.prev = this.curr; + this.prevAt = this.currAt; + this.curr = payload; + this.currAt = performance.now(); + } +} \ No newline at end of file diff --git a/resources/js/net/SocketClient.js b/resources/js/net/SocketClient.js new file mode 100644 index 0000000..647cf98 --- /dev/null +++ b/resources/js/net/SocketClient.js @@ -0,0 +1,155 @@ +const DEFAULT_OPTIONS = { + scheme: 'ws', + host: '127.0.0.1', + port: 8080, + key: 'game-local-key', + client: 'zombie-client', + version: '1.0.0', +}; + +export class SocketClient { + constructor(options = {}) { + this.opts = { ...DEFAULT_OPTIONS, ...options }; + this.socket = null; + this.socketId = null; + this.connected = false; + this.closedByUser = false; + this.retries = 0; + this.handlers = new Map(); + this.queue = []; + this.onConnectionChange = null; + } + + get url() { + const { scheme, host, port, key, client, version } = this.opts; + return `${scheme}://${host}:${port}/app/${key}?protocol=7&client=${client}&version=${version}&flash=false`; + } + + _connect() { + if (this.socket) return; + this.closedByUser = false; + const socket = new WebSocket(this.url); + this.socket = socket; + + socket.onopen = () => { + this.retries = 0; + }; + + socket.onmessage = (event) => { + let msg; + try { + msg = JSON.parse(event.data); + } catch { + return; + } + this._handle(msg); + }; + + socket.onclose = () => { + this.socket = null; + if (this.socketId) { + this.connected = false; + this.socketId = null; + this.onConnectionChange?.(false); + } + if (this.closedByUser) return; + if (!this.handlers.size) return; + const delay = Math.min(5000, 300 * 2 ** this.retries); + this.retries += 1; + setTimeout(() => this._connect(), delay); + }; + + socket.onerror = () => {}; + } + + connect() { + if (this.socket) return; + this._connect(); + } + + close() { + this.closedByUser = true; + this.handlers.clear(); + this.socket?.close(); + this.socket = null; + this.connected = false; + this.socketId = null; + } + + _handle(msg) { + const { event, channel } = msg; + + if (event === 'pusher:connection_established') { + try { + this.socketId = JSON.parse(msg.data).socket_id; + } catch { + this.socketId = null; + } + this.connected = true; + this.onConnectionChange?.(true); + for (const channel of this.handlers.keys()) { + this._sendSubscribe(channel); + } + this.queue = []; + return; + } + + if (event === 'pusher_internal:subscription_succeeded') { + if (channel) this.handlers.get(channel)?.onSubscribed?.(); + return; + } + + if (event === 'pusher:ping') { + this.send({ event: 'pusher:pong', data: {} }); + return; + } + + if (event === 'pusher:pong') { + return; + } + + if (channel && this.handlers.has(channel)) { + let payload = msg.data; + if (typeof payload === 'string') { + try { + payload = JSON.parse(payload); + } catch { + return; + } + } + this.handlers.get(channel)?.onEvent?.(event, payload); + } + } + + _sendSubscribe(channel) { + this.send({ event: 'pusher:subscribe', data: { channel } }); + } + + subscribe(channel, { onEvent = null, onSubscribed = null } = {}) { + this.handlers.set(channel, { onEvent, onSubscribed }); + if (this.connected) { + this._sendSubscribe(channel); + } else { + this.queue.push({ channel }); + } + } + + unsubscribe(channel) { + this.handlers.delete(channel); + this.queue = this.queue.filter((entry) => entry.channel !== channel); + if (this.connected) { + this.send({ event: 'pusher:unsubscribe', data: { channel } }); + } + } + + trigger(event, channel, data) { + if (!this.connected) return; + this.send({ event, channel, data }); + } + + send(payload) { + if (this.socket?.readyState === WebSocket.OPEN) { + this.socket.send(JSON.stringify(payload)); + } + } +} \ No newline at end of file diff --git a/resources/views/app.blade.php b/resources/views/app.blade.php new file mode 100644 index 0000000..1b4de9d --- /dev/null +++ b/resources/views/app.blade.php @@ -0,0 +1,56 @@ + + + + + + Zombies + @vite(['resources/css/game.css', 'resources/js/main.js']) + + + + + +
+
+

ZOMBIES

+

Co-op survival from your browser.

+ +
+ + +
+ + +
+ +
+ +
+ +
+ + +
+ +
+
+
+
+
+ +
+
+

GAME OVER

+
+ +
+
+
+ + \ No newline at end of file diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..7549b00 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,6 @@ + true); +Broadcast::channel('game.{room}.input', fn () => true); diff --git a/routes/console.php b/routes/console.php new file mode 100644 index 0000000..3c9adf1 --- /dev/null +++ b/routes/console.php @@ -0,0 +1,8 @@ +comment(Inspiring::quote()); +})->purpose('Display an inspiring quote'); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..bf67d80 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,7 @@ +get('/'); + + $response->assertStatus(200); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..fe1ffc2 --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,10 @@ +assertTrue(true); + } +} diff --git a/tests/Unit/Game/EntityListTest.php b/tests/Unit/Game/EntityListTest.php new file mode 100644 index 0000000..bf78f76 --- /dev/null +++ b/tests/Unit/Game/EntityListTest.php @@ -0,0 +1,83 @@ +append($zombie); + $this->assertCount(1, $list); + } + + #[Test] + public function removeRemovesInstance(): void + { + $room = new Room('r'); + $list = new EntityList(); + $a = new Zombie(0.0, 0.0, $list, $room); + $b = new Zombie(10.0, 0.0, $list, $room); + $list->append($a); + $list->append($b); + $list->remove($a); + $this->assertCount(1, $list); + $this->assertSame($b, $list[1]); + } + + #[Test] + public function removeLeavesNoGapsAfterManyRemovals(): void + { + $room = new Room('r'); + $list = new EntityList(); + $items = []; + for ($i = 0; $i < 20; $i++) { + $items[] = new Zombie((float) $i, 0.0, $list, $room); + } + foreach ($items as $item) { + $list->append($item); + } + foreach ([10, 3, 17, 0] as $index) { + $list->remove($items[$index]); + } + $this->assertSame(16, $list->count()); + $this->assertFalse($list->offsetExists(10)); + $this->assertTrue($list->offsetExists(2)); + } + + #[Test] + public function findByKindFilters(): void + { + $room = new Room('r'); + $list = new EntityList(); + $list->append(new Zombie(0.0, 0.0, $list, $room)); + $list->append(new Zombie(10.0, 0.0, $list, $room)); + $found = $list->findByKind('zombie'); + $this->assertCount(2, $found); + $this->assertSame([], $list->findByKind('player')); + } + + #[Test] + public function copyReturnsDetachedArray(): void + { + $room = new Room('r'); + $list = new EntityList(); + $list->append(new Zombie(0.0, 0.0, $list, $room)); + $snapshot = $list->copy(); + $list->append(new Zombie(1.0, 1.0, $list, $room)); + $this->assertCount(1, $snapshot); + $this->assertCount(2, $list); + } +} \ No newline at end of file diff --git a/tests/Unit/Game/GameEngineTest.php b/tests/Unit/Game/GameEngineTest.php new file mode 100644 index 0000000..da679a5 --- /dev/null +++ b/tests/Unit/Game/GameEngineTest.php @@ -0,0 +1,226 @@ +tick(1.0 / 60.0); + $this->assertSame(Room::STATE_LOBBY, $room->state); + } + + #[Test] + public function zombies_spawn_during_play(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $engine = new GameEngine($room, new Spawner); + $room->addPlayer(new Player('p1', 'Tester', new InputState(fire: true), $room->entities, $room, ['x' => 960.0, 'y' => 540.0])); + $sawZombie = false; + for ($i = 0; $i < 600; $i++) { + $engine->tick(1.0 / 60.0); + if ($room->entities->findByKind('zombie') !== []) { + $sawZombie = true; + } + if ($room->state === Room::STATE_OVER) { + break; + } + } + $this->assertTrue($sawZombie, 'zombies should spawn during play'); + } + + #[Test] + public function game_ends_when_all_players_die(): void + { + $room = new Room('test', 1); + $room->state = Room::STATE_PLAYING; + $engine = new GameEngine($room, new Spawner); + $player = new Player('p1', 'Tester', new InputState, $room->entities, $room, [ + 'x' => 960.0, + 'y' => 540.0, + 'health' => 1, + ]); + $room->addPlayer($player); + $zombie = new Zombie(944.4, 540.0, $room->entities, $room, ['emergeTime' => 0.0]); + $zombie->target = $player; + $room->entities->append($zombie); + $engine->tick(1.0 / 60.0); + $this->assertSame(Room::STATE_OVER, $room->state); + $this->assertTrue($player->dead); + } + + #[Test] + public function bullets_damage_zombies(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $engine = new GameEngine($room, new Spawner); + $input = new InputState(fire: true); + $player = new Player('p1', 'T', $input, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]); + $room->addPlayer($player); + $zombie = new Zombie(1000.0, 500.0, $room->entities, $room, ['emergeTime' => 0.0]); + $zombie->target = $player; + $room->entities->append($zombie); + $engine->tick(1.0 / 60.0); + $bullets = $room->entities->findByKind('bullet'); + $this->assertNotEmpty($bullets, 'player shot a bullet'); + } + + #[Test] + public function boss_spawns_at_score_threshold(): void + { + $room = new Room('test', 1); + $room->state = Room::STATE_PLAYING; + $engine = new GameEngine($room, new Spawner); + $player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]); + $room->addPlayer($player); + $zombie = new Zombie(510.0, 500.0, $room->entities, $room, ['health' => 1, 'emergeTime' => 0.0, 'scoreValue' => 55]); + $zombie->target = $player; + $room->entities->append($zombie); + $bullet = new Bullet(509.0, 500.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 1); + $room->entities->append($bullet); + $engine->tick(1.0 / 60.0); + $this->assertGreaterThan(0, $player->score, 'player killed zombie at score threshold'); + $this->assertTrue($room->hasBoss(), 'boss should spawn when score reaches threshold'); + } + + #[Test] + public function kills_increment_score(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $engine = new GameEngine($room, new Spawner); + $player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]); + $room->addPlayer($player); + $zombie = new Zombie(510.0, 500.0, $room->entities, $room, ['health' => 1, 'emergeTime' => 0.0]); + $zombie->target = $player; + $room->entities->append($zombie); + $bullet = new Bullet(509.0, 500.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 10); + $room->entities->append($bullet); + $engine->tick(1.0 / 60.0); + $this->assertGreaterThan(0, $player->score, 'player scored from killing zombie'); + } + + #[Test] + public function zombies_appear_when_megatank_dies(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $room->elapsed = 400.0; + $spawner = new Spawner; + $engine = new GameEngine($room, $spawner); + $player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 960.0, 'y' => 540.0]); + $room->addPlayer($player); + + $mega = null; + for ($i = 0; $i < 100 && $mega === null; $i++) { + $spawner->spawnZombie($room); + $megas = $room->entities->findByKind('megatank'); + if ($megas !== []) { + $mega = $megas[0]; + } + } + $this->assertNotNull($mega, 'a megatank should spawn when elapsed is high'); + $mega->pos = new Vector2(960.0, 250.0); + + $zombiesBefore = count($room->entities->findByKind('zombie')); + $portalsBefore = $this->portalEventCount($room); + + $room->entities->append(new Bullet(960.0, 250.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 100)); + $engine->tick(1.0 / 60.0); + + $this->assertSame([], $room->entities->findByKind('megatank'), 'megatank should be removed on death'); + $this->assertSame($zombiesBefore + 3, count($room->entities->findByKind('zombie')), 'megatank death should spawn three zombies'); + $this->assertSame($portalsBefore + 3, $this->portalEventCount($room), 'each spawned zombie should get a portal effect'); + $this->assertContains('kill', array_map(fn (array $ev) => $ev['k'], $room->events)); + } + + #[Test] + public function witches_appear_when_mega_witch_dies(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $spawner = new Spawner; + $engine = new GameEngine($room, $spawner); + $player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 960.0, 'y' => 540.0]); + $room->addPlayer($player); + $spawner->spawnMegaWitch($room, 960.0, 250.0, 1.0, 1.0); + $this->assertSame(1, count($room->entities->findByKind('megawitch'))); + + $portalsBefore = $this->portalEventCount($room); + $mega = $room->entities->findByKind('megawitch')[0]; + $room->entities->append(new Bullet($mega->pos->x, $mega->pos->y, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 100)); + $engine->tick(1.0 / 60.0); + + $this->assertSame([], $room->entities->findByKind('megawitch')); + $this->assertCount(3, $room->entities->findByKind('witch')); + $this->assertSame($portalsBefore + 3, $this->portalEventCount($room)); + } + + #[Test] + public function emerging_minions_report_emerge_progress_in_snapshot(): void + { + $room = new Room('test'); + $zombie = new Zombie(10.0, 10.0, $room->entities, $room, ['emergeTime' => 0.8]); + $snap = $zombie->toSnapshot(); + $this->assertSame(0.8, $snap['er']); + $this->assertSame(0.8, $snap['et']); + + $zombie->update(0.3); + $this->assertSame(0.5, $zombie->toSnapshot()['er'], 'emerge remaining should shrink as the zombie emerges'); + + $plain = new Zombie(30.0, 30.0, $room->entities, $room); + $this->assertArrayNotHasKey('er', $plain->toSnapshot(), 'non-emerging entities need no emerge data'); + } + + private function portalEventCount(Room $room): int + { + $count = 0; + foreach ($room->events as $event) { + if ($event['k'] === 'portal') { + $count++; + } + } + + return $count; + } + + #[Test] + public function boss_defeated_event_emitted_on_boss_death(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $engine = new GameEngine($room, new Spawner); + $player = new Player('p1', 'T', new InputState, $room->entities, $room, ['x' => 500.0, 'y' => 500.0]); + $room->addPlayer($player); + $boss = new Boss(505.0, 500.0, $room->entities, $room, $player, [ + 'health' => 1, + 'emergeTime' => 0.0, + ]); + $boss->target = $player; + $room->entities->append($boss); + $bullet = new Bullet(503.0, 500.0, new Vector2(1.0, 0.0), $room->entities, $room, $player, 800.0, 8.0, 10); + $room->entities->append($bullet); + $engine->tick(1.0 / 60.0); + $eventKinds = array_map(fn (array $ev) => $ev['k'], $room->events); + $this->assertContains('bossdefeated', $eventKinds); + } +} diff --git a/tests/Unit/Game/GameSimulatorTest.php b/tests/Unit/Game/GameSimulatorTest.php new file mode 100644 index 0000000..e15aa59 --- /dev/null +++ b/tests/Unit/Game/GameSimulatorTest.php @@ -0,0 +1,358 @@ +> */ + private array $broadcasts = []; + + /** @var array */ + private array $errors = []; + + private function makeSimulator(): GameSimulator + { + $this->broadcasts = []; + $this->errors = []; + + return new GameSimulator( + function (Room $room, array $players, array $entities, array $events): void { + $this->broadcasts[] = [ + 'room' => $room->id, + 'state' => $room->state, + 'players' => $players, + 'entities' => $entities, + 'events' => $events, + ]; + }, + function (string $roomId, string $code): void { + $this->errors[] = ['room' => $roomId, 'code' => $code]; + }, + ); + } + + #[Test] + public function create_creates_room_and_broadcasts_lobby(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'Alice']); + $this->assertCount(1, $sim->roomIds()); + $this->assertSame(Room::STATE_LOBBY, $sim->room('room-a')->state); + $this->assertCount(1, $sim->room('room-a')->players); + $this->assertSame('Alice', $this->broadcasts[0]['players'][0]['name']); + $this->assertSame('p1', $sim->room('room-a')->hostId); + } + + #[Test] + public function snapshot_marks_host(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $this->assertTrue($this->broadcasts[0]['players'][0]['host']); + + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $last = $this->broadcasts[array_key_last($this->broadcasts)]; + $hosts = []; + foreach ($last['players'] as $player) { + $hosts[$player['id']] = $player['host']; + } + $this->assertSame(['p1' => true, 'p2' => false], $hosts); + } + + #[Test] + public function start_transitions_to_playing(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); + $this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state); + } + + #[Test] + public function guest_cannot_start_room(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p2']); + $this->assertSame(Room::STATE_LOBBY, $sim->room('room-a')->state); + + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); + $this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state); + } + + #[Test] + public function host_promoted_when_host_leaves(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']); + $this->assertSame('p2', $sim->room('room-a')->hostId); + + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p2']); + $this->assertSame(Room::STATE_PLAYING, $sim->room('room-a')->state); + } + + #[Test] + public function reset_rejects_non_host(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $room = $sim->room('room-a'); + $room->state = Room::STATE_OVER; + + $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p2']); + $this->assertSame(Room::STATE_OVER, $room->state); + + $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']); + $this->assertSame(Room::STATE_LOBBY, $room->state); + } + + #[Test] + public function reset_does_nothing_in_lobby(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $room = $sim->room('room-a'); + + $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']); + $this->assertSame(Room::STATE_LOBBY, $room->state); + $this->assertCount(1, $this->broadcasts); + } + + #[Test] + public function reset_clears_entities_and_restores_players(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $room = $sim->room('room-a'); + $room->state = Room::STATE_OVER; + $room->entities->append(new Zombie(100.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0])); + $player = $room->players['p1']; + $player->health = 1; + $player->score = 99; + $player->pos = new Vector2(5.0, 5.0); + $player->upgrades['rapid'] = 1; + + $sim->applyControl('room-a', ['type' => 'reset', 'id' => 'p1']); + + $this->assertSame(Room::STATE_LOBBY, $room->state); + $this->assertSame([], $room->entities->findByKind('zombie')); + $this->assertSame($player->maxHealth, $player->health); + $this->assertSame(0, $player->score); + $this->assertSame(0, $player->upgrades['rapid']); + $this->assertEqualsWithDelta(960.0, $player->pos->x, 1e-6); + $this->assertSame(Room::STATE_LOBBY, $this->broadcasts[array_key_last($this->broadcasts)]['state']); + } + + #[Test] + public function start_does_nothing_with_empty_world(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('empty-room', ['type' => 'start']); + $this->assertSame([], $sim->roomIds()); + } + + #[Test] + public function apply_input_moves_player(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $room = $sim->room('room-a'); + $x = $room->players['p1']->pos->x; + $y = $room->players['p1']->pos->y; + + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); + $sim->applyInput('room-a', ['id' => 'p1', 'keys' => ['d'], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => false]); + $sim->tick(1.0 / 60.0); + $sim->tick(1.0 / 60.0); + + $player = $room->players['p1']; + $this->assertGreaterThan($x, $player->pos->x, 'player moves right'); + $this->assertEqualsWithDelta($y, $player->pos->y, 1e-6, 'player stays on same y'); + } + + #[Test] + public function tick_spawns_zombies_from_input(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); + $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => true]); + $sawZombie = false; + for ($i = 0; $i < 600; $i++) { + $sim->tick(1.0 / 60.0); + $room = $sim->room('room-a'); + if ($room->entities->findByKind('zombie') !== []) { + $sawZombie = true; + } + if ($room->state === Room::STATE_OVER) { + break; + } + } + $this->assertTrue($sawZombie, 'zombies spawn during play'); + } + + #[Test] + public function leave_removes_player_and_destroys_room_when_empty(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']); + $this->assertSame([], $sim->roomIds()); + } + + #[Test] + public function leave_keeps_room_when_other_players_remain(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $sim->applyControl('room-a', ['type' => 'leave', 'id' => 'p1']); + $this->assertSame(['room-a'], $sim->roomIds()); + $this->assertCount(1, $sim->room('room-a')->players); + } + + #[Test] + public function decode_parses_json_frames(): void + { + $sim = $this->makeSimulator(); + $frame = $sim->decode('{"type":"join","id":"p1"}'); + $this->assertSame('join', $frame['type']); + $this->assertSame('p1', $frame['id']); + $this->assertNull($sim->decode('not json')); + } + + #[Test] + public function snapshot_includes_entities_but_not_players(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $room = $sim->room('room-a'); + $player = $room->players['p1']; + + $zombie = new Zombie(100.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]); + $room->entities->append($zombie); + $bullet = new Bullet(5.0, 5.0, new Vector2(1.0, 0.0), $room->entities, $room, $player); + $room->entities->append($bullet); + + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); + $sim->tick(1.0 / 60.0); + $sim->tick(1.0 / 60.0); + + $snapshot = $this->broadcasts[array_key_last($this->broadcasts)]; + $kinds = array_map(static fn (array $e) => $e['d'] ?? null, $snapshot['entities']); + $this->assertContains('zombie', $kinds); + $this->assertNotContains('player', $kinds); + $this->assertNotContains('player', $snapshot['entities']); + } + + #[Test] + public function join_is_rejected_after_start(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'start', 'id' => 'p1']); + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $this->assertCount(1, $sim->room('room-a')->players, 'no new join after start'); + $this->assertSame('room_not_open', $this->errors[0]['code']); + } + + #[Test] + public function join_rejects_missing_room_without_creating_it(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('nope', ['type' => 'join', 'id' => 'p1', 'name' => 'A']); + $this->assertSame([], $sim->roomIds(), 'no room is created for an unknown code'); + $this->assertSame('room_not_found', $this->errors[0]['code']); + $this->assertSame('nope', $this->errors[0]['room']); + $this->assertSame([], $this->broadcasts, 'nothing is broadcast for an unknown code'); + } + + #[Test] + public function create_rejects_room_owned_by_another_host(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p2', 'name' => 'B']); + $this->assertSame('p1', $sim->room('room-a')->hostId, 'creator keeps host'); + $this->assertCount(1, $sim->room('room-a')->players, 'second host is not added'); + $this->assertSame('room_exists', $this->errors[0]['code']); + } + + #[Test] + public function create_is_idempotent_rejoin_for_the_host(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $this->assertCount(1, $sim->room('room-a')->players); + $this->assertSame('p1', $sim->room('room-a')->hostId); + $this->assertSame([], $this->errors); + } + + #[Test] + public function guest_joins_existing_room_as_non_host(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p2', 'name' => 'B']); + $this->assertCount(2, $sim->room('room-a')->players); + $this->assertSame([], $this->errors, 'joining an existing open room succeeds'); + } + + #[Test] + public function join_rejects_when_room_is_full(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + for ($i = 2; $i <= 8; $i++) { + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p'.$i, 'name' => 'P'.$i]); + } + $sim->applyControl('room-a', ['type' => 'join', 'id' => 'p9', 'name' => 'P9']); + $this->assertCount(8, $sim->room('room-a')->players); + $this->assertSame('room_full', $this->errors[0]['code']); + } + + #[Test] + public function bullet_impact_emits_hit_event(): void + { + $sim = $this->makeSimulator(); + $sim->applyControl('room-a', ['type' => 'create', 'id' => 'p1', 'name' => 'A']); + $room = $sim->room('room-a'); + $room->state = Room::STATE_PLAYING; + $player = $room->players['p1']; + $zombie = new Zombie($player->pos->x + 40.0, $player->pos->y, $room->entities, $room, ['emergeTime' => 0.0]); + $room->entities->append($zombie); + + $sim->applyInput('room-a', ['id' => 'p1', 'keys' => [], 'aim' => ['x' => 1.0, 'y' => 0.0], 'fire' => true, 'autoAim' => false]); + + $hit = false; + $kills = false; + for ($i = 0; $i < 30; $i++) { + $sim->tick(1.0 / 60.0); + foreach ($this->broadcasts[array_key_last($this->broadcasts)]['events'] as $event) { + $hit = $hit || $event['k'] === 'bullethit'; + $kills = $kills || $event['k'] === 'kill'; + } + if ($hit && $kills) { + break; + } + } + + $this->assertTrue($hit, 'a bullet impact emits a bullethit event'); + $this->assertTrue($kills, 'the zombie dies and emits a kill event'); + } +} diff --git a/tests/Unit/Game/PlayerTest.php b/tests/Unit/Game/PlayerTest.php new file mode 100644 index 0000000..4167fb0 --- /dev/null +++ b/tests/Unit/Game/PlayerTest.php @@ -0,0 +1,175 @@ +entities, $room, [ + 'x' => 100.0, + 'y' => 100.0, + 'health' => 3, + ]); + } + + #[Test] + public function movement_keys_affect_velocity(): void + { + $room = new Room('test'); + $input = new InputState(['d']); + $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); + $player->update(1.0 / 60.0); + $this->assertGreaterThan(100.0, $player->pos->x); + } + + #[Test] + public function fire_spawns_bullets(): void + { + $room = new Room('test'); + $input = new InputState(fire: true); + $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); + $room->addPlayer($player); + $player->update(1.0 / 60.0); + $bullets = $room->entities->findByKind('bullet'); + $this->assertNotEmpty($bullets); + } + + #[Test] + public function auto_aim_fires_at_nearest_zombie_without_mouse(): void + { + $room = new Room('test'); + $room->state = Room::STATE_PLAYING; + $input = new InputState(fire: false, autoAim: true); + $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); + $room->addPlayer($player); + $zombie = new Zombie(300.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]); + $zombie->target = $player; + $room->entities->append($zombie); + $player->update(1.0 / 60.0); + $bullets = $room->entities->findByKind('bullet'); + $this->assertNotEmpty($bullets, 'auto-aim should fire at a zombie without the mouse held'); + $direction = $bullets[0]->velocity->normalize(); + $this->assertGreaterThan(0.9, $direction->x, 'bullet should fly toward the zombie to the right'); + } + + #[Test] + public function auto_aim_does_not_fire_without_target(): void + { + $room = new Room('test'); + $input = new InputState(fire: false, autoAim: true); + $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); + $room->addPlayer($player); + $player->update(1.0 / 60.0); + $this->assertEmpty($room->entities->findByKind('bullet'), 'no auto-fire when no zombie is in range'); + } + + #[Test] + public function take_damage_decreases_health_and_cooldown_prevents_double_hit(): void + { + $room = new Room('test'); + $player = $this->makePlayer($room); + $player->takeDamage(); + $this->assertSame(2, $player->health); + $this->assertFalse($player->dead); + $player->takeDamage(); + $this->assertSame(2, $player->health, 'damage cooldown prevented second hit'); + } + + #[Test] + public function take_damage_kills_at_zero_health(): void + { + $room = new Room('test'); + $player = $this->makePlayer($room); + $player->takeDamage(); + $player->update(0.6); + $player->takeDamage(); + $player->update(0.6); + $player->takeDamage(); + $this->assertSame(0, $player->health); + $this->assertTrue($player->dead); + } + + #[Test] + public function apply_upgrade_increases_level_and_effects(): void + { + $room = new Room('test'); + $player = $this->makePlayer($room); + $originalFireRate = $player->fireRate; + $this->assertTrue($player->applyUpgrade('rapid')); + $this->assertSame(1, $player->upgrades['rapid']); + $this->assertLessThan($originalFireRate, $player->fireRate); + } + + #[Test] + public function apply_upgrade_returns_false_when_maxed(): void + { + $room = new Room('test'); + $player = $this->makePlayer($room); + for ($i = 0; $i < $player->maxUpgradeLevel; $i++) { + $this->assertTrue($player->applyUpgrade('damage')); + } + $this->assertFalse($player->applyUpgrade('damage')); + $this->assertSame($player->maxUpgradeLevel, $player->upgrades['damage']); + } + + #[Test] + public function apply_upgrade_unknown_type_returns_false(): void + { + $player = $this->makePlayer(); + $this->assertFalse($player->applyUpgrade('nonexistent')); + } + + #[Test] + public function reset_for_round_restores_derived_stats(): void + { + $room = new Room('test'); + $input = new InputState; + $player = new Player('p1', 'Tester', $input, $room->entities, $room, [ + 'x' => 100.0, + 'y' => 100.0, + 'fireRate' => 0.5, + 'damage' => 2, + 'bulletCount' => 3, + 'bulletSpeed' => 900.0, + ]); + $player->applyUpgrade('rapid'); + $player->applyUpgrade('damage'); + $player->applyUpgrade('multishot'); + $player->applyUpgrade('speed'); + + $player->resetForRound(); + + $this->assertSame(['rapid' => 0, 'damage' => 0, 'multishot' => 0, 'speed' => 0], $player->upgrades); + $this->assertSame(0.5, $player->fireRate); + $this->assertSame(2, $player->damage); + $this->assertSame(3, $player->bulletCount); + $this->assertSame(900.0, $player->bulletSpeed); + } + + #[Test] + public function auto_aim_tracks_nearest_zombie(): void + { + $room = new Room('test'); + $input = new InputState(fire: true, autoAim: true); + $player = new Player('p1', 'Tester', $input, $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); + $room->addPlayer($player); + $zombie = new Zombie(200.0, 100.0, $room->entities, $room, ['emergeTime' => 0.0]); + $room->entities->append($zombie); + $player->update(1.0 / 60.0); + $bullets = $room->entities->findByKind('bullet'); + $this->assertNotEmpty($bullets); + } +} diff --git a/tests/Unit/Game/RoomTest.php b/tests/Unit/Game/RoomTest.php new file mode 100644 index 0000000..8634de9 --- /dev/null +++ b/tests/Unit/Game/RoomTest.php @@ -0,0 +1,69 @@ +assertSame(Room::STATE_LOBBY, $room->state); + } + + #[Test] + public function addPlayerIncrementsCount(): void + { + $room = new Room('test'); + $player = new Player('p1', 'Alice', new InputState(), $room->entities, $room); + $room->addPlayer($player); + $this->assertCount(1, $room->players); + $this->assertArrayHasKey('p1', $room->players); + } + + #[Test] + public function alivePlayersReturnsNonDead(): void + { + $room = new Room('test'); + $p1 = new Player('p1', 'A', new InputState(), $room->entities, $room); + $p2 = new Player('p2', 'B', new InputState(), $room->entities, $room); + $room->addPlayer($p1); + $room->addPlayer($p2); + $p1->dead = true; + $this->assertCount(1, $room->alivePlayers()); + $this->assertSame('p2', $room->alivePlayers()[0]->id); + } + + #[Test] + public function highestScore(): void + { + $room = new Room('test'); + $p1 = new Player('p1', 'A', new InputState(), $room->entities, $room); + $p2 = new Player('p2', 'B', new InputState(), $room->entities, $room); + $room->addPlayer($p1); + $room->addPlayer($p2); + $p2->score = 42; + $this->assertSame(42, $room->highestScore()); + } + + #[Test] + public function pushEventAndFlush(): void + { + $room = new Room('test'); + $room->pushEvent('kill', ['x' => 1.0, 'y' => 2.0, 'color' => [1.0, 0.0, 0.0, 1.0]]); + $this->assertCount(1, $room->events); + $this->assertSame('kill', $room->events[0]['k']); + $flushed = $room->flushEvents(); + $this->assertCount(1, $flushed); + $this->assertCount(0, $room->events); + } +} \ No newline at end of file diff --git a/tests/Unit/Game/Vector2Test.php b/tests/Unit/Game/Vector2Test.php new file mode 100644 index 0000000..1eb8cc6 --- /dev/null +++ b/tests/Unit/Game/Vector2Test.php @@ -0,0 +1,126 @@ +addV($b); + $this->assertSame(4.0, $c->x); + $this->assertSame(1.0, $c->y); + } + + #[Test] + public function subV(): void + { + $a = new Vector2(5.0, 7.0); + $b = new Vector2(2.0, 3.0); + $c = $a->subV($b); + $this->assertSame(3.0, $c->x); + $this->assertSame(4.0, $c->y); + } + + #[Test] + public function mulS(): void + { + $v = new Vector2(2.0, -3.0); + $r = $v->mulS(4.0); + $this->assertSame(8.0, $r->x); + $this->assertSame(-12.0, $r->y); + } + + #[Test] + public function addS(): void + { + $v = new Vector2(2.0, 3.0); + $r = $v->addS(5.0); + $this->assertSame(7.0, $r->x); + $this->assertSame(8.0, $r->y); + } + + #[Test] + public function divS(): void + { + $v = new Vector2(6.0, -4.0); + $r = $v->divS(2.0); + $this->assertSame(3.0, $r->x); + $this->assertSame(-2.0, $r->y); + } + + #[Test] + public function lengthSquared(): void + { + $v = new Vector2(3.0, 4.0); + $this->assertSame(25.0, $v->lengthSquared()); + } + + #[Test] + public function length(): void + { + $v = new Vector2(3.0, 4.0); + $this->assertSame(5.0, $v->length()); + } + + #[Test] + public function normalizeUnitVectorUnchanged(): void + { + $v = new Vector2(3.0, 0.0); + $n = $v->normalize(); + $this->assertSame(1.0, $n->x); + $this->assertSame(0.0, $n->y); + } + + #[Test] + public function normalizeNormalizesVector(): void + { + $v = new Vector2(3.0, 4.0); + $n = $v->normalize(); + $this->assertEqualsWithDelta(0.6, $n->x, 1e-9); + $this->assertEqualsWithDelta(0.8, $n->y, 1e-9); + } + + #[Test] + public function normalizeZeroVectorReturnsZero(): void + { + $v = new Vector2(0.0, 0.0); + $n = $v->normalize(); + $this->assertSame(0.0, $n->x); + $this->assertSame(0.0, $n->y); + } + + #[Test] + public function zeroReturnsOrigin(): void + { + $v = Vector2::zero(); + $this->assertSame(0.0, $v->x); + $this->assertSame(0.0, $v->y); + } + + #[Test] + public function rotate90DegreesYAxis(): void + { + $v = new Vector2(1.0, 0.0); + $r = $v->rotate(M_PI / 2.0); + $this->assertEqualsWithDelta(0.0, $r->x, 1e-6); + $this->assertEqualsWithDelta(1.0, $r->y, 1e-6); + } + + #[Test] + public function rotate360ReturnsSameDirection(): void + { + $v = new Vector2(2.0, -1.0); + $r = $v->rotate(2.0 * M_PI); + $this->assertEqualsWithDelta($v->x, $r->x, 1e-6); + $this->assertEqualsWithDelta($v->y, $r->y, 1e-6); + } +} \ No newline at end of file diff --git a/tests/Unit/Game/ZombieTest.php b/tests/Unit/Game/ZombieTest.php new file mode 100644 index 0000000..0933a26 --- /dev/null +++ b/tests/Unit/Game/ZombieTest.php @@ -0,0 +1,74 @@ +entities; + $player = new Player('p1', 'Tester', new InputState(), $list, $room, ['x' => 100.0, 'y' => 0.0]); + $room->addPlayer($player); + $zombie = new Zombie(0.0, 0.0, $list, $room, ['emergeTime' => 0.0]); + $list->append($zombie); + $zombie->target = $player; + $zombie->update(0.1); + $this->assertGreaterThan(0.0, $zombie->pos->x, 'zombie moves toward player x'); + $this->assertEqualsWithDelta(0.0, $zombie->pos->y, 1e-6, 'zombie stays on same y'); + } + + #[Test] + public function hitReducesHealthAndKnocksBack(): void + { + $room = new Room('test'); + $zombie = new Zombie(10.0, 0.0, $room->entities, $room); + $impulse = new Vector2(1.0, 0.0); + $dead = $zombie->hit($impulse, 1); + $this->assertTrue($dead); + $this->assertSame(0, $zombie->health); + } + + #[Test] + public function hitDoesNotKillAtHighHealth(): void + { + $room = new Room('test'); + $zombie = new Zombie(10.0, 0.0, $room->entities, $room, ['health' => 10]); + $dead = $zombie->hit(new Vector2(1.0, 0.0), 1); + $this->assertFalse($dead); + $this->assertSame(9, $zombie->health); + } + + #[Test] + public function dieRemovesFromContainer(): void + { + $room = new Room('test'); + $zombie = new Zombie(5.0, 5.0, $room->entities, $room); + $room->entities->append($zombie); + $zombie->die(); + $this->assertCount(0, $room->entities); + } + + #[Test] + public function emergeBlocksMovement(): void + { + $room = new Room('test'); + $zombie = new Zombie(0.0, 0.0, $room->entities, $room, ['emergeTime' => 1.0]); + $room->entities->append($zombie); + $zombie->target = new Player('p1', 'T', new InputState(), $room->entities, $room, ['x' => 100.0, 'y' => 100.0]); + $zombie->update(0.1); + $this->assertSame(0.0, $zombie->pos->x, 'zombie blocked during emerge'); + } +} \ No newline at end of file diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..25f7f4f --- /dev/null +++ b/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import laravel from 'laravel-vite-plugin'; + +export default defineConfig({ + plugins: [ + laravel({ + input: ['resources/css/game.css', 'resources/js/main.js'], + refresh: true, + }), + ], + server: { + watch: { + ignored: ['**/storage/framework/views/**'], + }, + }, +}); \ No newline at end of file